Compare commits

...
Author SHA1 Message Date
Kit Langton 583103f2dd test(core): limit process group survival check to POSIX
Node assigns non-detached Windows children to a kill-on-parent-exit job, so the held-stdio mcp fixture cannot assert POSIX process-group survival there. Keep detached descendant, capture deadline, and success-policy coverage enabled on Windows.
2026-08-31 15:35:32 -04:00
Kit Langton acaca2cc01 fix(util): separate process exit from capture completion
Report exit and running state from the child exit signal, independently of buffered output. On scope release, discard abandoned capture and retain the existing pipe-close or capture-deadline wait before applying process-group cleanup policy.

Cover unread output after confirmed process exit, successful descendant survival, and the approved policy that a parent exiting successfully before its invocation timeout retains success while the bounded capture grace finishes.
2026-08-31 15:12:01 -04:00
Kit Langton ef8b8c1b8d fix(util): preserve process output for late readers
Buffer child stdout and stderr before Effect consumers attach, retaining stream backpressure and scoped cleanup. Detach capture buffers before the existing post-exit discard deadline drains inherited pipes.

Cover post-exit readers, output larger than the buffers, and teardown with unread stdout through the real process spawner.
2026-08-31 14:55:04 -04:00
3 changed files with 168 additions and 24 deletions
@@ -180,6 +180,37 @@ describe("cross-spawn spawner", () => {
})
describe("combined output (all)", () => {
for (const output of ["stdout", "stderr", "all"] as const) {
fx.live(
`captures ${output} when reading starts after process exit`,
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
// Let exit callbacks finish before attaching a reader; the handle scope remains open.
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)))
expect((yield* decodeByteStream(handle[output])).split("\n").toSorted()).toEqual(
output === "all" ? ["stderr", "stdout"] : [output],
)
}).pipe(Effect.timeout("3 seconds")),
)
}
fx.live(
"drains output larger than the capture buffers",
Effect.gen(function* () {
const text = "x".repeat(1024 * 1024)
const handle = yield* js(
`const text = "x".repeat(${text.length}); process.stdout.write(text); process.stderr.write(text)`,
)
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: 2,
})
expect(stdout).toBe(text)
expect(stderr).toBe(text)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}).pipe(Effect.timeout("3 seconds")),
)
fx.effect(
"captures stdout via .all when no stderr",
Effect.gen(function* () {
@@ -217,6 +248,63 @@ describe("cross-spawn spawner", () => {
})
describe("process control", () => {
fx.live(
"reports exit without waiting for unread stdout",
Effect.gen(function* () {
const handle = yield* js("process.stdout.write(Buffer.alloc(1024 * 1024)); process.exit(0)")
expect(yield* Effect.promise(() => gone(Number(handle.pid)))).toBe(true)
expect(yield* handle.exitCode.pipe(Effect.timeout("500 millis"))).toBe(ChildProcessSpawner.ExitCode(0))
expect(yield* handle.isRunning).toBe(false)
}),
)
fx.live(
"releases a process with unread buffered stdout",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("x".repeat(1024 * 1024)); process.stderr.write("ready"); setInterval(() => {}, 10_000)',
{ forceKillAfter: 100 },
)
expect(yield* decodeByteStream(handle.stderr.pipe(Stream.take(1)))).toBe("ready")
return Number(handle.pid)
}),
)
expect(yield* Effect.promise(() => gone(pid))).toBe(true)
}).pipe(Effect.timeout("3 seconds")),
)
// Node puts non-detached Windows children in a kill-on-parent-exit job; this guards POSIX group cleanup.
const groupTest = process.platform === "win32" ? fx.live.skip : fx.live
groupTest(
"preserves successful descendants when an exit-only scope closes",
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const pidFile = path.join(tmp.path, "child.pid")
yield* Effect.addFinalizer(() =>
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
Effect.ignore,
),
)
yield* Effect.scoped(
Effect.gen(function* () {
// This fixture's child shares the process group and holds stdio after the parent exits on stdin EOF.
const handle = yield* ChildProcess.make(
"node",
[path.join(import.meta.dir, "../fixture/held-stdio.cjs"), "mcp", pidFile],
{ stdin: "ignore", forceKillAfter: 100 },
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true)
}).pipe(Effect.timeout("3 seconds")),
)
for (const mode of ["exit", "SIGKILL"] as const) {
const test = mode === "SIGKILL" && process.platform === "win32" ? fx.live.skip : fx.live
test(
+40
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
@@ -272,6 +273,45 @@ describe("Session.shell", () => {
)
}
it.effect("keeps success when the invocation timeout expires during post-exit capture", () =>
Effect.gen(function* () {
const fixture = yield* setup
const pidFile = path.join(fixture.tmp.path, "child.pid")
yield* Effect.addFinalizer(() =>
Effect.tryPromise(async () => process.kill(Number(await fs.readFile(pidFile, "utf8")), "SIGKILL")).pipe(
Effect.ignore,
),
)
const info = yield* fixture.shell.create({
command: `node "${path.join(import.meta.dir, "fixture/held-stdio.cjs")}" exit "${pidFile}"`,
timeout: 500,
})
const completion = yield* fixture.shell.wait(info.id).pipe(Effect.forkScoped)
// Wait for the real process without advancing its invocation timeout or capture deadline.
yield* fixture.shell
.get(info.id)
.pipe(
Effect.repeat({ until: (info) => info.status === "exited", schedule: Schedule.spaced("10 millis") }),
Effect.timeout("3 seconds"),
TestClock.withLive,
)
yield* TestClock.adjust("500 millis")
expect(yield* fixture.shell.get(info.id)).toMatchObject({ status: "exited", exit: 0 })
expect(completion.pollUnsafe()).toBeUndefined()
yield* TestClock.adjust("500 millis")
expect(yield* Fiber.join(completion).pipe(Effect.timeout("3 seconds"), TestClock.withLive)).toMatchObject({
status: "exited",
exit: 0,
})
const result = yield* fixture.shell.result(info)
expect(result.capture?.output).toContain("foreground-out")
expect(result.capture?.output).toContain("foreground-err")
const pid = Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8")))
expect(() => process.kill(pid, 0)).not.toThrow()
}),
)
for (const outcome of [
{
status: "killed",
+40 -24
View File
@@ -231,38 +231,56 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
return Effect.succeed(sink)
})
const setupOutput = (
const setupOutput = Effect.fnUntraced(function* (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
out: ChildProcess.StdoutConfig,
err: ChildProcess.StderrConfig,
stopOutput: Deferred.Deferred<void>,
) => {
const capture = (readable: NodeChildProcess.ChildProcess["stdout"], name: string) => {
) {
const capture = Effect.fnUntraced(function* (readable: NodeChildProcess.ChildProcess["stdout"], name: string) {
if (!readable) return Stream.empty
return NodeStream.fromReadable({
evaluate: () => readable,
onError: (cause) => toPlatformError(`fromReadable(${name})`, toError(cause), command),
closeOnDone: false,
}).pipe(
// Buffer before the child exits: Node may drain unread stdio before an Effect reader starts.
const tap = new PassThrough()
const onError = (cause: Error) => tap.destroy(cause)
readable.on("error", onError)
// Errors before subscription remain observable through tap.errored.
tap.on("error", () => {})
readable.pipe(tap)
const release = Effect.sync(() => {
readable.unpipe(tap)
readable.off("error", onError)
tap.destroy()
})
yield* Effect.addFinalizer(() => release)
return Stream.suspend(() =>
tap.errored
? Stream.fail(toPlatformError(`fromReadable(${name})`, tap.errored, command))
: NodeStream.fromReadable({
evaluate: () => tap,
onError: (cause) => toPlatformError(`fromReadable(${name})`, toError(cause), command),
closeOnDone: false,
}),
).pipe(
Stream.interruptWhen(Deferred.await(stopOutput)),
Stream.ensuring(
Effect.gen(function* () {
yield* release
// Only the capture deadline transfers the reader back to the process scope.
if (yield* Deferred.isDone(stopOutput)) return
readable.destroy()
}),
),
)
}
})
let stdout = capture(proc.stdout, "stdout")
let stderr = capture(proc.stderr, "stderr")
let stdout = yield* capture(proc.stdout, "stdout")
let stderr = yield* capture(proc.stderr, "stderr")
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}
})
const launchProcess = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<Spawned, PlatformError.PlatformError>((resume) => {
@@ -318,7 +336,8 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
const discard = (readable: NodeChildProcess.ChildProcess["stdout"]) => {
if (!readable || readable.destroyed) return
// read() also drains while a backpressured Effect adapter still has a readable listener.
// Capture has ended; discard inherited output without filling the bounded buffer.
readable.unpipe()
const drain = () => {
while (readable.read() !== null) {}
}
@@ -423,8 +442,11 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
}),
Effect.fnUntraced(
function* ([proc, closed, exited, stopOutput]) {
const done = (yield* Deferred.isDone(closed)) || (yield* Deferred.isDone(stopOutput))
if (done) {
discard(proc.stdout)
discard(proc.stderr)
if (yield* Deferred.isDone(exited)) {
// Reporting exit must not shorten the inherited-pipe grace period on scope release.
yield* Effect.raceFirst(Deferred.await(closed), Deferred.await(stopOutput))
const [code] = yield* Deferred.await(exited)
if (process.platform === "win32") return
if (code === 0 || Predicate.isNull(code)) return
@@ -447,12 +469,8 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
),
)
const completion = Effect.raceFirst(
Deferred.await(closed),
Deferred.await(stopOutput).pipe(Effect.andThen(Deferred.await(exited))),
)
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr, stopOutput)
const out = yield* setupOutput(command, proc, sout, serr, stopOutput)
let ref = true
return makeHandle({
pid: ProcessId(proc.pid!),
@@ -462,10 +480,8 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
all: out.all,
getInputFd: fd.getInputFd,
getOutputFd: fd.getOutputFd,
isRunning: Effect.gen(function* () {
return !(yield* Deferred.isDone(closed)) && !(yield* Deferred.isDone(stopOutput))
}),
exitCode: Effect.flatMap(completion, ([code, signal]) => {
isRunning: Effect.map(Deferred.isDone(exited), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(exited), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
return Effect.fail(
toPlatformError(