Skip to content

fix: drain remaining PTY data after reader stop on macOS#561

Merged
mangelajo merged 6 commits intojumpstarter-dev:mainfrom
raballew:046-fix-hook-pty-macos
Apr 16, 2026
Merged

fix: drain remaining PTY data after reader stop on macOS#561
mangelajo merged 6 commits intojumpstarter-dev:mainfrom
raballew:046-fix-hook-pty-macos

Conversation

@raballew
Copy link
Copy Markdown
Member

@raballew raballew commented Apr 15, 2026

Summary

  • Fix macOS PTY output capture by draining remaining kernel buffer data after the reader stop flag is set in read_pty_output
  • Extract _flush_lines helper to deduplicate line-processing logic across main loop and drain
  • Bound drain loop with MAX_DRAIN_BYTES (256KB) and DRAIN_TIMEOUT_SECONDS (2s) to prevent indefinite blocking when grandchild processes hold the PTY slave fd open

Context

On macOS, PTY output delivery timing differs from Linux -- data can still be in the kernel buffer after the subprocess exits and the 0.2s grace period elapses. This caused test_exec_bash to fail on macOS CI runners because the mocked logger never received the expected output. The fix adds a bounded, non-blocking drain loop in the finally block of read_pty_output.

Test plan

  • Unit tests for _flush_lines helper (complete lines, empty buffer, no newlines, empty lines)
  • Drain integration test using pipe to inject data after stop flag is set
  • Drain byte limit test verifying MAX_DRAIN_BYTES is respected
  • Edge case: empty buffer after stop flag exits immediately
  • Edge case: OSError during drain exits gracefully
  • Edge case: output without trailing newline is captured
  • Constants sanity check test

Closes #560

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

Warning

Rate limit exceeded

@raballew has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 36 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 12 minutes and 36 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e85f3f2c-d89c-4834-9445-796f1e46f2cb

📥 Commits

Reviewing files that changed from the base of the PR and between ffaf152 and fffd66e.

📒 Files selected for processing (1)
  • python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py
📝 Walkthrough

Walkthrough

Adds bounded PTY drain logic and a _flush_lines() helper to centralize newline-delimited line extraction; introduces MAX_DRAIN_BYTES and DRAIN_TIMEOUT_SECONDS constants; updates read loop to call _flush_lines() and synchronously drains remaining PTY bytes (bounded by bytes and time) after the reader stops.

Changes

Cohort / File(s) Summary
PTY drain core
python/packages/jumpstarter/jumpstarter/exporter/hooks.py
Adds MAX_DRAIN_BYTES and DRAIN_TIMEOUT_SECONDS constants; introduces _flush_lines(buffer: bytes, output_lines: list[str]) -> bytes; replaces inline newline processing in read_pty_output() with _flush_lines(); adds bounded synchronous drain in finally to os.read() remaining PTY bytes until byte limit or timeout; retains final decode/log of any leftover buffer.
Tests for drain & parsing
python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py
Adds TestFlushLines unit tests for _flush_lines() (newline parsing, remainder buffering, empty-line skipping); introduces async integration tests exercising drain behavior (stop conditions, MAX_DRAIN_BYTES limit, EOF, os.read() errors, unterminated output) and asserts constant values.

Sequence Diagram

sequenceDiagram
    participant Reader as Reader Task
    participant PTY as PTY fd
    participant Finally as Finally/Drain
    participant Flush as _flush_lines()
    participant Logger as Logger

    Reader->>PTY: non-blocking reads in loop
    PTY-->>Reader: bytes
    Reader->>Flush: accumulate & flush complete lines
    Note over Reader: Reader task stops/cancelled
    Reader->>Finally: enter finally block
    Finally->>PTY: synchronous os.read() until bytes or timeout
    PTY-->>Finally: remaining bytes / EOF / OSError
    Finally->>Flush: pass accumulated bytes
    Flush->>Logger: decode and log complete non-empty lines
    Finally->>Logger: decode/log any remaining remainder bytes
    Finally-->>Reader: drain complete, close fd
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

Hop, hop, I nibble bytes at night, 🐰
I gather lines until the light,
A capped drain, no stray byte lost,
_flush_lines() counts at any cost,
Cheer — the logs are tidy and bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: drain remaining PTY data after reader stop on macOS' accurately describes the main purpose of the PR—adding PTY drain logic for macOS compatibility.
Description check ✅ Passed The description clearly explains the macOS PTY timing issue, the fix strategy with drain bounds, test coverage, and links to issue #560.
Linked Issues check ✅ Passed The PR fully addresses issue #560's objectives: drains remaining PTY data after reader cancellation, bounds the drain loop with MAX_DRAIN_BYTES and DRAIN_TIMEOUT_SECONDS, handles OSError gracefully, and provides comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are scoped to the PTY drain fix and its test coverage—new constants, _flush_lines helper, drain logic in read_pty_output, and corresponding unit and integration tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py (1)

500-548: Test may not exercise byte limit on typical systems.

The test fills the pipe buffer until BlockingIOError, which on most Linux systems is around 64KB (less than MAX_DRAIN_BYTES of 256KB). This means the drain will complete before hitting the byte limit, so the assertion drained <= MAX_DRAIN_BYTES passes trivially.

To actually test the byte limit enforcement, consider using fcntl.F_SETPIPE_SZ to increase the pipe buffer size beyond 256KB, or mocking the drain loop directly.

That said, the test still validates that the drain logic works correctly with bounded reads, which provides value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py` around lines
500 - 548, The current test (test_drain_respects_byte_limit) likely never
reaches MAX_DRAIN_BYTES because the OS pipe buffer is smaller; modify the test
to force the drain loop to hit the byte limit by enlarging the pipe buffer
before writing (use fcntl.F_SETPIPE_SZ when available to set the pipe size >
MAX_DRAIN_BYTES), falling back to an explicit mock of the drain loop if the
fcntl constant isn't present; keep references to MAX_DRAIN_BYTES,
DRAIN_TIMEOUT_SECONDS, and _flush_lines and ensure the rest of the test logic
(non-blocking fds, write loop, reading loop and assertions) remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py`:
- Around line 500-548: The current test (test_drain_respects_byte_limit) likely
never reaches MAX_DRAIN_BYTES because the OS pipe buffer is smaller; modify the
test to force the drain loop to hit the byte limit by enlarging the pipe buffer
before writing (use fcntl.F_SETPIPE_SZ when available to set the pipe size >
MAX_DRAIN_BYTES), falling back to an explicit mock of the drain loop if the
fcntl constant isn't present; keep references to MAX_DRAIN_BYTES,
DRAIN_TIMEOUT_SECONDS, and _flush_lines and ensure the rest of the test logic
(non-blocking fds, write loop, reading loop and assertions) remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 71bfa89b-3845-42ee-b2bd-aa69ad0f9d16

📥 Commits

Reviewing files that changed from the base of the PR and between 3e37577 and e09b6cf.

📒 Files selected for processing (2)
  • python/packages/jumpstarter/jumpstarter/exporter/hooks.py
  • python/packages/jumpstarter/jumpstarter/exporter/hooks_test.py

@raballew raballew requested a review from mangelajo April 16, 2026 05:14
@mangelajo mangelajo enabled auto-merge (squash) April 16, 2026 10:17
raballew and others added 5 commits April 16, 2026 14:20
…apture

The read_pty_output function exited immediately when the reader_stop flag
was set, without draining remaining data from the PTY kernel buffer. On
macOS, PTY output delivery timing differs from Linux, causing data to
still be in the kernel buffer after the subprocess exits and the 0.2s
grace period elapses. This resulted in test_exec_bash failing on macOS
CI runners because the mocked logger never received the expected output.

The fix adds a non-blocking drain loop in the finally block of
read_pty_output that reads and processes all remaining PTY data before
the function returns.

Generated-By: Forge/20260415_214923_3139216_59edcfbb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…logic

The line-processing pattern (split on newline, decode, rstrip, append,
log) was duplicated in three places within read_pty_output(). Extracting
it into a single _flush_lines() function ensures the contract is
maintained in one place and reduces the risk of inconsistent changes.

Generated-By: Forge/20260415_214923_3139216_59edcfbb
…ppression

Add MAX_DRAIN_BYTES (256KB) and DRAIN_TIMEOUT_SECONDS (2s) limits to
the PTY drain loop in read_pty_output(). Without bounds, a grandchild
process holding the PTY slave fd open could cause the drain to spin
indefinitely after the hook timeout fires, effectively bypassing the
configurable timeout. Also wrap drain + line-processing in try/except
to prevent suppressing the original exception from the finally block.

Generated-By: Forge/20260415_214923_3139216_59edcfbb
The previous test_pty_output_drained_after_process_exits used a fast
subprocess whose output was always captured by the main read loop
before the stop flag was set, so the drain path was never exercised.
Replace it with test_pty_output_drained_after_stop_flag_set which
uses a pipe to inject data, skips the main loop entirely, and
verifies the drain logic captures all lines.

Generated-By: Forge/20260415_214923_3139216_59edcfbb
…iling newline

Cover the three edge cases specified but not tested:
- Empty PTY buffer after stop flag: drain completes immediately
- OSError during drain (e.g. closed fd): exits gracefully
- Output without trailing newline: captured by the trailing buffer handler

Generated-By: Forge/20260415_214923_3139216_59edcfbb
@raballew raballew force-pushed the 046-fix-hook-pty-macos branch from e09b6cf to ffaf152 Compare April 16, 2026 12:20
Add two tests that exercise the drain code path inside
read_pty_output's finally block, raising diff-cover from 11% to 100%
on the changed lines:

- test_drain_reads_data_remaining_in_pty_buffer: patches os.read to
  inject data after EOF, simulating the macOS PTY buffering scenario
- test_drain_exception_is_suppressed: patches _flush_lines to raise
  during drain, verifying the except-Exception handler suppresses it

Generated-By: Forge/20260416_144213_369510_282d5eaf
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mangelajo mangelajo merged commit 74794de into jumpstarter-dev:main Apr 16, 2026
32 checks passed
raballew added a commit to raballew/jumpstarter that referenced this pull request Apr 16, 2026
Merge latest main to include the PTY drain fix (jumpstarter-dev#561) and other
recent changes.

Generated-By: Forge/20260416_202053_681470_c94294b7_i240

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test_exec_bash hook test fails on macOS due to PTY output not captured by mocked logger

2 participants