Containerized control sessions could hang with no output ("Working…" forever): the agent's exec
stdio over the vminitd vsock channel intermittently failed to carry bytes, so claude ran and
reached the approval server but its stdin/stdout never connected — it idled in interactive
stream-json mode and never exited. Forensics (nucleic.sqlite + per-session claude-home MCP logs)
showed every stdio layer byte-identical to a working state, i.e. a flaky framework race, not a
regression in our code. Make the failure recoverable and visible instead of an eternal spinner:
- ClaudeCodeBackend: a 60s spawn watchdog on containerized runs — no first stdout → emit a
recoverable error (with guest stderr + container probe), SIGKILL the wedged process, and finish
the run errored, instead of awaiting stdoutLines forever.
- Stop reliability: ProcessHandle.forceCloseStreams() (ContainerizedProcessHandle finishes its
line streams host-side; default no-op for the host pipe handle), wired into every kill
escalation (terminate/interruptThenKill/killGroupAfter) so a force-killed run always settles
even when the guest wait/stdio RPC wedges — the real cause of "Stop is inconsistent".
- Cap MCP_TIMEOUT (connection) to 60s so an unreachable approval server can't wedge startup for
~24.8 days; MCP_TOOL_TIMEOUT stays unbounded for human-answered approvals.
- LinuxProcess.setupIO logs which stdio stream fails to connect (os.Logger, com.nucleic /
container-io) so a stall pinpoints the failing stream. Vendored patch #3.
- Tests: stop-escalation force-close, responsive-process no-op, MCP timeout asymmetry.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
109 lines
5.1 KiB
Swift
109 lines
5.1 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import NucleicCore
|
|
|
|
@Suite struct ProcessHostTests {
|
|
/// A stdout line far larger than the OS pipe buffer must arrive intact, and the
|
|
/// line after it must still be delivered. This is the regression behind a big
|
|
/// tool-result truncating mid-line — which dropped everything the agent streamed
|
|
/// afterward (its final answer + the result event), so the turn "never returned".
|
|
@Test(.timeLimit(.minutes(1))) func largeLineIsNotTruncatedAndStreamContinues() async throws {
|
|
let bigCount = 300_000 // well past a 64KB pipe buffer
|
|
let script = "head -c \(bigCount) /dev/zero | tr '\\000' 'A'; printf '\\n'; printf 'done\\n'"
|
|
let host = ProcessHost()
|
|
let handle = try await host.launch(
|
|
ProcessSpec(
|
|
executable: "/bin/sh", args: ["-c", script],
|
|
cwd: FileManager.default.temporaryDirectory.path, stdinMode: .closed))
|
|
|
|
var lines: [Data] = []
|
|
for try await line in handle.stdoutLines { lines.append(line) }
|
|
|
|
#expect(lines.count == 2)
|
|
#expect(lines.first?.count == bigCount)
|
|
#expect(lines.first?.allSatisfy { $0 == UInt8(ascii: "A") } == true)
|
|
#expect(lines.last.map { String(decoding: $0, as: UTF8.self) } == "done")
|
|
}
|
|
|
|
/// Stop must always settle, even when the process's `wait()` never resolves — the case behind the
|
|
/// "inconsistent Stop" on containerized sessions, where the guest wait/stdio RPC can wedge so the
|
|
/// line streams never EOF and the decode loop hangs. `terminate` escalates SIGTERM → SIGKILL and,
|
|
/// when the kill still doesn't resolve `wait()`, force-closes the streams so a consumer blocked on
|
|
/// `stdoutLines` is released.
|
|
@Test(.timeLimit(.minutes(1))) func terminateForceClosesStreamsWhenWaitNeverResolves() async {
|
|
let handle = StallingHandle()
|
|
let grace: UInt64 = 50_000_000 // 50ms
|
|
await handle.terminate(graceNanos: grace)
|
|
|
|
#expect(handle.didForceClose)
|
|
#expect(handle.deliveredSignals.contains(SIGTERM))
|
|
#expect(handle.deliveredSignals.contains(SIGKILL))
|
|
// The streams must be finished — a consumer iterating them now completes instead of hanging.
|
|
var produced = 0
|
|
do { for try await _ in handle.stdoutLines { produced += 1 } } catch {}
|
|
#expect(produced == 0)
|
|
}
|
|
|
|
/// A responsive process that exits on the first signal must NOT be force-killed or have its
|
|
/// streams force-closed — the escalation is only for a wedged wait.
|
|
@Test(.timeLimit(.minutes(1))) func terminateLeavesAResponsiveProcessAlone() async {
|
|
let handle = StallingHandle(exitsAfterFirstSignal: true)
|
|
await handle.terminate(graceNanos: 1_000_000_000)
|
|
#expect(handle.deliveredSignals == [SIGTERM]) // exited on SIGTERM; no SIGKILL escalation
|
|
#expect(!handle.didForceClose)
|
|
}
|
|
|
|
/// The MCP *connection* timeout is bounded (so an unreachable approval server can't wedge startup
|
|
/// for ~24.8 days) while the *tool* timeout stays effectively unbounded (human-answered approvals
|
|
/// may take many minutes). Locks in the asymmetry against an accidental re-pin of both to max.
|
|
@Test func mcpConnectionTimeoutIsBoundedButToolTimeoutIsNot() {
|
|
#expect(ClaudeCodeBackend.mcpTimeoutEnv["MCP_TIMEOUT"] == "60000")
|
|
#expect(ClaudeCodeBackend.mcpTimeoutEnv["MCP_TOOL_TIMEOUT"] == "2147483647")
|
|
}
|
|
}
|
|
|
|
/// A `ProcessHandle` whose `wait()` never resolves (unless told to exit on the first signal), used to
|
|
/// drive the Stop-escalation paths deterministically. Records the signals it received and whether its
|
|
/// streams were force-closed.
|
|
private final class StallingHandle: ProcessHandle, @unchecked Sendable {
|
|
let stdoutLines: AsyncThrowingStream<Data, Error>
|
|
let stderrLines: AsyncThrowingStream<Data, Error>
|
|
private let outCont: AsyncThrowingStream<Data, Error>.Continuation
|
|
private let errCont: AsyncThrowingStream<Data, Error>.Continuation
|
|
private let lock = NSLock()
|
|
private var signals: [Int32] = []
|
|
private var forceClosed = false
|
|
private let exitsAfterFirstSignal: Bool
|
|
|
|
init(exitsAfterFirstSignal: Bool = false) {
|
|
self.exitsAfterFirstSignal = exitsAfterFirstSignal
|
|
(stdoutLines, outCont) = AsyncThrowingStream.makeStream()
|
|
(stderrLines, errCont) = AsyncThrowingStream.makeStream()
|
|
}
|
|
|
|
var deliveredSignals: [Int32] { lock.withLock { signals } }
|
|
var didForceClose: Bool { lock.withLock { forceClosed } }
|
|
|
|
var processID: Int32 { 4242 }
|
|
func writeLine(_ data: Data) throws {}
|
|
func closeStdin() {}
|
|
func sendSignal(_ sig: Int32) { lock.withLock { signals.append(sig) } }
|
|
|
|
func wait() async -> Int32 {
|
|
if exitsAfterFirstSignal {
|
|
while lock.withLock({ signals.isEmpty }) {
|
|
try? await Task.sleep(nanoseconds: 5_000_000)
|
|
}
|
|
return 0
|
|
}
|
|
while true { try? await Task.sleep(nanoseconds: 50_000_000) } // never exits
|
|
}
|
|
|
|
func forceCloseStreams() {
|
|
lock.withLock { forceClosed = true }
|
|
outCont.finish()
|
|
errCont.finish()
|
|
}
|
|
}
|