Files
nucleic/Tests/NucleicCoreTests/ProcessHostTests.swift
T
NucleicandClaude Opus 4.8 3d02a0a2f7 Nucleic Control: spawn watchdog + reliable Stop + stdio diagnostics
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]>
2026-06-25 17:37:46 -07:00

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()
}
}