fix(cloud): keep the link client's last stderr lines in its exit error

Merged
AU
17d ago
+98-7

When a Cloud link client exits before it names its socket, the connect error now carries the client's full stderr. CloudMachineLink.connect built LinkError.exited from stderrTail as soon as the client exited. A separate task appends stderr lines, so the last ones could still be in flight, and the error then had no output. That is how CmuxTuiSurfaceProviderTests.linkClientExitingBeforeItsSocketLineReportsTheExitNotATimeout failed in the #15488 validation run: status 2 was right, but output was "", not the client's unknown option line (shard 4 of run 36726041181).

Change

  • drainStderr returns its reader task.
  • Both exit paths of connect wait for that task to reach EOF before they read stderrTail: stdout closing before a socket line, and the client gone after it. The wait is bounded at 1 s, because a child the client started can keep the pipe open.
  • The wait lives in CloudMachineLink+StderrDrain.swift, so CloudMachineLink.swift stays within its line budget.

linkProcessDidExit, which records lastError after a connected client exits, reads stderrTail the same way. It doesn't have the reader task, and nothing reported that path, so it is unchanged.

Verification

CloudMachineLinkExitDiagnosticsTests reproduces the race deterministically. The fake client exits at once, and a child it started closes stdout and writes the last stderr line 0.3 s later. The test is committed before the fix:

  • Red, test commit 13d4a4c92d0: this PR's package tests failed with CloudMachineLinkExitDiagnosticsTests.swift:33:13: Expectation failed: (output → "").contains("route refused"). The status check (== 2) passed, and the other 221 tests passed.
  • Green, with the fix (810d2f5afc5): this PR's package tests passed, all 222 tests, including the new one in 0.30 s. CmuxTuiSurfaceProviderTests runs in the #15488 full-suite validation.

Both tests' children wait until the client has been reaped, then close stdout and write the late line 0.1 s later. That leaves 0.9 s of the 1 s bound as margin. The wait for the reap matters. When stdout's EOF reaches connect before Foundation reaps the exited client, terminateAndWait still sees it running and calls terminate(). NSTask puts the client in its own process group and signals the whole group, so a writer started by the client dies before its line; checked here with a background child of a terminated NSTask. The first version of test 1 hit exactly that on cmux7s: output "" after 0.51 s, with the fix in place. The red run used that first version, which closed stdout at once and wrote after 0.3 s. Without the fix, connect builds the error within milliseconds of stdout closing, so the current version fails the same way.

exitAfterSocketLineWaitsForStderrToClose covers the second exit path, a client that is gone once its socket line arrives.

Changelog

  • Fixed: when a Cloud machine's link client exits during connect, the error includes the client's full error output.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Connection failures now include available error output from the client process, even when it exits before completing socket setup.
    • Error diagnostics also capture output when the process exits after reporting a socket, making failures such as route refusals easier to identify.
    • Error output is given a brief opportunity to finish arriving before connection failures are reported.