Skip to content

A paste the test delivers in two writes is not a paste - #414

Merged
blooop merged 1 commit into
mainfrom
fix/aid-pty-flake
Aug 25, 2026
Merged

blooop merged 1 commit into
mainfrom
fix/aid-pty-flake

Conversation

@blooop

@blooop blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes the diagnosis in #401. Map: #406.

What was actually wrong

Not a lost wakeup, not a dead reader thread, not a deadline that is too tight. send_line in rust/aid/tests/interactive.rs wrote the typed line and its Enter as two write_all calls on the pty master:

.write_all(line.as_bytes())
.and_then(|()| self.writer.write_all(b"\n"))

A write to a pty master is copied into the line discipline in one go, so two writes are two of those with a schedulable gap between them. In a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking the first write carries fix this\nand then that, which completes line one and nothing else, and that alone wakes aid's blocking read. read_terminal_submission (rust/dl/src/lib.rs:265) then drains what the terminal holds right now by design: its zero-timeout poll is what keeps somebody who typed one line from being waited on for a second. While the second write is still in flight the tail is not a completed line, canonical mode will not hand it over, and the prompt reaching the agent is fix this.

The test's own comment claimed "One write, as a terminal delivers a paste: both lines arrive together". The helper it called had never made that true.

How the mechanism was confirmed

Not by argument. Widening the gap between the two writes with a deliberate 20ms sleep moved the failure rate from 14 in 100 to 19 in 20 - the named mechanism and nothing else responds to that knob.

Numbers

Method: run the built interactive test binary back to back, 100 times, on 8 cores under a load average of 12 to 25 (other agents were running cargo test --workspace in sibling worktrees throughout, which is the loaded condition #401 describes).

green failures
before 86/100 14, every one interactive.rs:291 (paste truncated to line one)
20ms sleep inserted between the two writes 1/20 19, same assertion
after 100/100 0

An earlier 60-run baseline agreed: 52/60 green, all 8 failures the same assertion.

What was rejected

  • Raising the 60s deadline in wait_for. Left untouched. The symptom The aid pty tests flake on a loaded cargo test --workspace run #401 recorded, "press Enter" never appeared, did not reproduce once in 280 runs of this suite, nor in any of the 6 full-workspace runs measured. Raising a deadline for a mechanism nothing has confirmed buys only a slower failure.
  • Serialising the pty tests against each other. The suite alone runs in 0.43s and the failure is a race inside one test, not between tests. Serialising would have hidden it more often without fixing it.
  • Changing read_terminal_submission. Its drain-what-is-queued contract is deliberate and documented, and a real terminal delivering a paste writes it whole. The test was the thing that was wrong.

Gate

  • pixi run cargo test --workspace green
  • pixi run cargo clippy --locked --all-targets -- -D warnings clean
  • pixi run cargo fmt --check clean

Out of scope, seen while measuring

Three other tests failed intermittently in the full-workspace runs, none of them in this file, and none touched here:

  • flows::launch::tests::a_contended_up_of_a_running_workspace_runs_no_up_at_all (Ok(Started) where Ok(SkippedSiblingWon) was expected)
  • timing::tests::stages_closed_out_of_order_do_not_stay_on_the_clock (twice)
  • dl/tests/interrupt.rs's closing_the_terminal_mid_up_removes_the_token_file_and_kills_the_up

Also noted and not acted on: a_ctrl_c_at_the_editor_tears_the_whole_boot_down stakes its "the orphaned devpod up must have been killed" assertion on a wait_for whose subject is a sleep 30. A kill that never happened still satisfies it 30 seconds later, which is where the occasional 30s run of this suite comes from. It is a separate weakness and a separate ticket.

Summary by Sourcery

Fix the interactive paste test to send a line and its newline atomically, preventing multiline input from being truncated under load.

Bug Fixes:

  • Fix the flaky multiline paste interaction test by sending pasted terminal input as a single write so all completed lines arrive together.

Tests:

  • Make the pty test helper accurately model terminal paste delivery and preserve the test’s whole-paste assertion.

`aid/tests/interactive.rs` failed on a loaded `cargo test --workspace` about one
run in seven, and always on the same assertion: the pasted prompt reached the
agent cut at its first line. #401 offered three candidate mechanisms -- a lost
wakeup between the pty reader thread and `wait_for`, a reader thread that died
early, or 60s genuinely elapsing under contention. It is none of them, and the
deadline is left alone.

`send_line` wrote the line and its Enter as two `write_all` calls on the pty
master. A master write is copied into the line discipline in one go, so two
writes are two of those with a schedulable gap between them. In
`a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking` the first write
carries `fix this\nand then that`, which completes line one and nothing else,
and that alone is enough to wake `aid`'s read. `read_terminal_submission` then
drains what the terminal holds right now -- by design, since its zero-timeout
poll is what keeps somebody who typed one line from being waited on for a second
-- and while the second write is still in flight the tail is not a completed
line, so canonical mode will not hand it over, so the prompt is `fix this`. The
test's own comment claimed "one write, as a terminal delivers a paste"; the
helper it called had never made that true.

Confirmed rather than argued. Widening the gap to a deliberate 20ms sleep between
the two writes moved the failure from 14 runs in 100 to 19 in 20, which is the
mechanism named and nothing else. One write makes every completed line of the
paste readable at the same instant, which is also what a terminal delivering a
paste actually does.

Measured on 8 cores under a load average of 15 to 21, running the suite binary
back to back:

  before: 86 green in 100, 14 failures, every one this assertion
  after:  100 green in 100

The 60s deadline in `wait_for` stays. The symptom #401 recorded, `"press Enter"
never appeared`, did not reproduce once in 280 runs of the suite; raising a
deadline for a mechanism nothing confirmed would buy only a slower failure.

Three other flakes surfaced in the full-workspace runs and belong to their own
tickets, not this one:
`flows::launch::tests::a_contended_up_of_a_running_workspace_runs_no_up_at_all`,
`timing::tests::stages_closed_out_of_order_do_not_stay_on_the_clock`, and
`dl/tests/interrupt.rs`'s
`closing_the_terminal_mid_up_removes_the_token_file_and_kills_the_up`.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @blooop, you have reached your weekly rate limit of 250000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Fixes the flaky multi-line paste test by making the pty helper model a real paste: the complete input is copied into the terminal line discipline in one write, preventing aid from waking between separate writes and reading only the first line.

File-Level Changes

Change Details Files
Make simulated terminal input preserve paste atomicity by writing the line and newline in one pty operation.
  • Combine the typed content and Enter into a single write_all, then flush the writer.
  • Document why a single write is required to prevent a scheduling gap from truncating multi-line paste input.
  • Update the paste test comment to tie its premise to the helper’s single-write behavior.
rust/aid/tests/interactive.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Reviewed at merge-base 57955a3...5f9d40c. Every number below was measured in a scratch worktree in this devcontainer, idle apart from the run.

Preflight: ci, rust, e2e, prek, public-api, packaging, coverage green. review/gate are red only for the missing wf-review report, not for the code.

Standards

The mechanism argument is sound, and I reproduced it rather than agreeing with it.

Reading read_terminal_submission (rust/dl/src/lib.rs:265-295) confirms the shape the PR describes: bytes are read one at a time up to the first \n, and only then does if ended_with_newline && stdin_readable_now() drain the tail. stdin_readable_now is a zero-timeout poll(POLLIN), and in canonical mode a line discipline reports readable only for a completed line — so a first write that ends mid-line-two leaves the tail invisible to that poll, and the submission is line one. The mechanism is exactly as claimed.

Reproduced by knob, on this branch, restoring the two writes with a deliberate 20ms gap between them:

test a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking ... FAILED
  left: "... claude --dangerously-skip-permissions '\"'\"'fix this'\"'\"''"
 right: "... claude --dangerously-skip-permissions '\"'\"'fix this\nand then that'\"'\"''"

And the natural rate, on an otherwise idle machine (the PR measured under load average 12-25, so these are not comparable to its 86/100 — they are a floor):

helper runs failures
two writes, no sleep (base) 60 2, both the paste assertion
this PR's one write 40 0

The fix changes only test code. The diff is rust/aid/tests/interactive.rs and nothing else. Confirmed.

The most important question — does the test still test what it claims? Yes, CONFIRMED. The concern is that a test rewritten to match the implementation can go green because it stopped asking anything. It did not. I neutered the production drain on this branch — rust/dl/src/lib.rs:287, if ended_with_newline && ...if false && ended_with_newline && ... — and the test failed with precisely the truncation it asserts against:

thread 'a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking' panicked at aid/tests/interactive.rs:302:5
  left: "... '\"'\"'fix this'\"'\"''"
 right: "... '\"'\"'fix this\nand then that'\"'\"''"

So the production drain path — the zero-timeout poll, the re-pushed \n, the byte loop — is genuinely exercised, and a regression in it still fails this test. That is the whole of what the change had to preserve, and it does.

Findings, both non-blocking:

  1. SUSPECTED — the fix narrows the window rather than closing it by construction. The test still depends on the kernel pushing all 23 bytes through the line discipline before aid's poll runs. In practice a single small write(2) on a pty master does that under one atomic_write_lock (0/40 here, 0/100 in the PR), but it is an empirical property, not one the test asserts. I would not act on it.
  2. SUSPECTED — a coverage loss worth a line in #416, not a change here. The two-write helper was, by accident, the only thing in the suite exercising a paste that arrives split. read_terminal_submission's own doc says an unterminated tail "stays queued, and reaches the agent's session as typed-ahead input" — which is real user-visible behaviour for a paste larger than one write (N_TTY_BUF_SIZE, or a terminal that chunks). After this PR nothing covers it. The PR's reason for leaving read_terminal_submission alone is right for #401's scope; the gap is worth naming somewhere.

Nit, no action: format!("{line}\n") allocates per call. Irrelevant in a test, mentioned only so the next reader does not re-derive it.

Spec

Against #401.

  • "Worth establishing first whether the deadline is simply too tight under load, or whether there is a genuine lost-wakeup" — established as neither, with the alternative named and rejected on evidence rather than on preference. This is what the ticket asked for and the PR delivers it in the order the ticket set.
  • "Options in rough order of cost: raise the deadline, serialise the pty tests, or hand the reader thread a condvar" — all three declined, each with a stated reason. Declining a ticket's suggested options after finding the actual cause is the right outcome, not a deviation.
  • "The failure seen was a_pasted_multi_line_prompt_arrives_whole_rather_than_leaking (aid/tests/interactive.rs:315), with "press Enter" never appeared after the 60s deadline" — this is the one place the PR does not close the ticket as written. The test named is the one fixed; the symptom recorded is not the one reproduced or fixed, and the PR says so plainly and carries it to #416. That is the honest disposition, but it means #401 is only partly discharged here: if the recorded symptom was real, its cause is still unknown. Whoever closes #401 should close it against #414 and #416, not against #414 alone. Flagging for the human, not as a change to this diff.

Verdict

Approve. No blocking findings. The mechanism, the fix, and — the thing most worth checking — the test's continued grip on the production drain path are all confirmed by re-running rather than by reading. Two follow-ups suggested for #416: the split-paste coverage gap, and the disposition of #401's original symptom.

@blooop
blooop merged commit 738918b into main Aug 25, 2026
21 of 23 checks passed
@blooop
blooop deleted the fix/aid-pty-flake branch August 25, 2026 11:08
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.

1 participant