Skip to content

Fix flaky passthrough PTY test: spawn with posix_spawn instead of forkpty#35

Merged
xvrh merged 1 commit into
masterfrom
claude/relaxed-lalande-03b36d
May 16, 2026
Merged

Fix flaky passthrough PTY test: spawn with posix_spawn instead of forkpty#35
xvrh merged 1 commit into
masterfrom
claude/relaxed-lalande-03b36d

Conversation

@xvrh

@xvrh xvrh commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

The CI-flaky pty_test.dart test SIGINT death gives exit 130 (30s TimeoutException) was a symptom of a real bug in the passthrough PTY implementation, not a test problem.

spawnPty used forkpty(), which returns into the caller in both the parent and the child. The child branch then ran Dart code before execing. In a forked child of the multi-threaded Dart VM, only the forking thread survives — if a GC/safepoint was in progress at fork() time, the child deadlocks waiting on VM threads that no longer exist, never execs the target, and keeps the PTY slave open, hanging the reader's read() on the master fd forever.

Reproduced deterministically with a stress loop (hung ~1 in 27 spawns); sample confirmed the stuck "child" was still a dartvm process that never became bash.

Fix

Replaced forkpty with posix_spawn, which does the fork+exec entirely inside libc — no Dart code runs in the child, eliminating the deadlock class.

  • libc_bindings.dart — swapped the forkpty/execvp/chdir/_exit bindings for posix_openpt, grantpt, unlockpt, ptsname, open, and the posix_spawn family.
  • pty_impl.dartspawn() opens a PTY master and spawns via posix_spawnp with POSIX_SPAWN_SETSID + file actions (open slave → dup onto stdio, close master). A posix_spawn failure maps to exit 127, preserving the old execvp-failure contract.

Two subtleties handled:

  • Controlling terminal: SETSID + the child opening the slave without O_NOCTTY makes the PTY its controlling terminal (verified by the SIGWINCH resize() test).
  • Initial window size: the slave's first open resets the PTY winsize to 0×0, so the parent opens the slave once itself to absorb the reset before setting the size.

Test plan

  • 12 consecutive full-file runs of pty_test.dart pass (previously flaked ~2/6)
  • 400-round stress loop (2000 spawns of the previously-deadlocking scenario) clean — old code hung at round 27
  • All 20 test/passthrough/ tests pass
  • flutter analyze clean for lib/src/passthrough/ and test/passthrough/
  • dart tool/prepare_submit.dart formatter applied
  • CI green on Linux (beta channel) — posix_spawn path is portable but only exercised locally on macOS

🤖 Generated with Claude Code

forkpty() returns into the caller in both the parent and the child, so
the child ran Dart code before exec'ing. In a forked child of the
multi-threaded Dart VM only the forking thread survives; if a GC or
safepoint was in progress at fork() time, the child deadlocks and never
exec's the target, leaving the PTY slave open and hanging the master
read() — surfacing as a rare 30s timeout in the pty_test SIGINT case.

posix_spawn performs the fork+exec entirely inside libc, so no Dart runs
in the child and the deadlock class is gone. The child still gets the
PTY as its controlling terminal via POSIX_SPAWN_SETSID plus opening the
slave without O_NOCTTY. The parent opens the slave once before spawning
to absorb the first-open winsize reset so the initial size sticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xvrh
xvrh force-pushed the claude/relaxed-lalande-03b36d branch from 43c2ad8 to ad27639 Compare May 16, 2026 06:05
@xvrh
xvrh merged commit d9aca71 into master May 16, 2026
1 check passed
@xvrh
xvrh deleted the claude/relaxed-lalande-03b36d branch May 16, 2026 06:19
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