mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 11:54:55 +00:00
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.
546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import fs from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { Deferred, Effect, Exit, PlatformError, Stream } from "effect"
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const live = LayerNode.compile(CrossSpawnSpawner.node)
|
|
const fx = testEffect(live)
|
|
|
|
function js(code: string, opts?: ChildProcess.CommandOptions) {
|
|
return ChildProcess.make("node", ["-e", code], opts)
|
|
}
|
|
|
|
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
|
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
|
|
}
|
|
|
|
function alive(pid: number) {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function tmpdir() {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-test-"))
|
|
return {
|
|
path: dir,
|
|
async [Symbol.asyncDispose]() {
|
|
await fs.rm(dir, { recursive: true, force: true })
|
|
},
|
|
}
|
|
}
|
|
|
|
async function gone(pid: number, timeout = 5_000) {
|
|
const end = Date.now() + timeout
|
|
while (Date.now() < end) {
|
|
if (!alive(pid)) return true
|
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
}
|
|
return !alive(pid)
|
|
}
|
|
|
|
describe("cross-spawn spawner", () => {
|
|
describe("basic spawning", () => {
|
|
fx.effect(
|
|
"captures stdout",
|
|
Effect.gen(function* () {
|
|
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
|
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
|
|
)
|
|
expect(out).toBe("ok")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"captures multiple lines",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
expect(out).toBe("line1\nline2\nline3")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"returns exit code",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js("process.exit(0)")
|
|
const code = yield* handle.exitCode
|
|
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"returns non-zero exit code",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js("process.exit(42)")
|
|
const code = yield* handle.exitCode
|
|
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("cwd option", () => {
|
|
fx.effect(
|
|
"uses cwd when spawning commands",
|
|
Effect.gen(function* () {
|
|
const tmp = yield* Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
)
|
|
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
|
svc.string(
|
|
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
|
|
),
|
|
)
|
|
expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path)))
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"fails for invalid cwd",
|
|
Effect.gen(function* () {
|
|
const exit = yield* Effect.exit(
|
|
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
|
svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })),
|
|
),
|
|
)
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("env option", () => {
|
|
fx.effect(
|
|
"passes environment variables with extendEnv",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
|
|
env: { TEST_VAR: "test_value" },
|
|
extendEnv: true,
|
|
})
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
expect(out).toBe("test_value")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"passes multiple environment variables",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js(
|
|
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
|
|
{
|
|
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
|
|
extendEnv: true,
|
|
},
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
expect(out).toBe("one-two-three")
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("stderr", () => {
|
|
fx.effect(
|
|
"captures stderr output",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stderr.write("error message")')
|
|
const err = yield* decodeByteStream(handle.stderr)
|
|
expect(err).toBe("error message")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"captures both stdout and stderr",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js(
|
|
[
|
|
"let pending = 2",
|
|
"const done = () => {",
|
|
" pending -= 1",
|
|
" if (pending === 0) setTimeout(() => process.exit(0), 0)",
|
|
"}",
|
|
'process.stdout.write("stdout\\n", done)',
|
|
'process.stderr.write("stderr\\n", done)',
|
|
].join("\n"),
|
|
)
|
|
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
|
|
concurrency: 2,
|
|
})
|
|
expect(stdout).toBe("stdout")
|
|
expect(stderr).toBe("stderr")
|
|
}),
|
|
)
|
|
})
|
|
|
|
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* () {
|
|
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
|
|
const all = yield* decodeByteStream(handle.all)
|
|
expect(all).toBe("hello from stdout")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"captures stderr via .all when no stdout",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stderr.write("hello from stderr")')
|
|
const all = yield* decodeByteStream(handle.all)
|
|
expect(all).toBe("hello from stderr")
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("stdin", () => {
|
|
fx.effect(
|
|
"allows providing standard input to a command",
|
|
Effect.gen(function* () {
|
|
const input = "a b c"
|
|
const stdin = Stream.make(Buffer.from(input, "utf-8"))
|
|
const handle = yield* js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
|
{ stdin },
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
yield* handle.exitCode
|
|
expect(out).toBe("a b c")
|
|
}),
|
|
)
|
|
})
|
|
|
|
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(
|
|
`finishes capture after ${mode} while a grandchild holds stdio`,
|
|
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,
|
|
),
|
|
)
|
|
const ready = yield* Deferred.make<void>()
|
|
// The grandchild keeps both pipes open independently of the foreground process.
|
|
const handle = yield* ChildProcess.make(
|
|
"node",
|
|
[path.join(import.meta.dir, "../fixture/held-stdio.cjs"), mode, pidFile],
|
|
{ stdin: "ignore", forceKillAfter: 100 },
|
|
)
|
|
const [exit, stdout, stderr] = yield* Effect.all(
|
|
[
|
|
Effect.exit(handle.exitCode),
|
|
decodeByteStream(handle.stdout),
|
|
decodeByteStream(handle.stderr.pipe(Stream.tap(() => Deferred.succeed(ready, undefined)))),
|
|
mode === "SIGKILL"
|
|
? Deferred.await(ready).pipe(Effect.andThen(handle.kill({ killSignal: "SIGKILL" })))
|
|
: Effect.void,
|
|
],
|
|
{ concurrency: "unbounded" },
|
|
).pipe(Effect.timeout("3 seconds"))
|
|
|
|
// Completion must retain foreground output without waiting for the inherited pipes to close.
|
|
expect(stdout).toBe("foreground-out")
|
|
expect(stderr).toBe("foreground-err")
|
|
expect(Exit.isSuccess(exit)).toBe(mode === "exit")
|
|
if (Exit.isSuccess(exit)) expect(exit.value).toBe(ChildProcessSpawner.ExitCode(0))
|
|
expect(yield* handle.isRunning).toBe(false)
|
|
expect(alive(Number(yield* Effect.promise(() => fs.readFile(pidFile, "utf8"))))).toBe(true)
|
|
}),
|
|
10_000,
|
|
)
|
|
}
|
|
|
|
fx.effect(
|
|
"kills a running process",
|
|
Effect.gen(function* () {
|
|
const exit = yield* Effect.exit(
|
|
Effect.gen(function* () {
|
|
const handle = yield* js("setTimeout(() => {}, 10_000)")
|
|
yield* handle.kill()
|
|
return yield* handle.exitCode
|
|
}),
|
|
)
|
|
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"uses the configured kill signal when scope exits",
|
|
Effect.gen(function* () {
|
|
const pid = yield* Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const handle = yield* js("setInterval(() => {}, 10_000)", { killSignal: "SIGKILL" })
|
|
return Number(handle.pid)
|
|
}),
|
|
)
|
|
const done = yield* Effect.promise(() => gone(pid))
|
|
expect(done).toBe(true)
|
|
}),
|
|
)
|
|
|
|
fx.live(
|
|
"forceKillAfter escalates for stubborn processes",
|
|
Effect.gen(function* () {
|
|
if (process.platform === "win32") return
|
|
|
|
const exit = yield* Effect.exit(
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
|
|
yield* handle.kill({ forceKillAfter: 100 })
|
|
return yield* handle.exitCode
|
|
}),
|
|
)
|
|
|
|
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"isRunning reflects process state",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stdout.write("done")')
|
|
yield* handle.exitCode
|
|
const running = yield* handle.isRunning
|
|
expect(running).toBe(false)
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("error handling", () => {
|
|
fx.effect(
|
|
"fails for invalid command",
|
|
Effect.gen(function* () {
|
|
const exit = yield* Effect.exit(
|
|
Effect.gen(function* () {
|
|
const handle = yield* ChildProcess.make("nonexistent-command-12345")
|
|
return yield* handle.exitCode
|
|
}),
|
|
)
|
|
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("pipeline", () => {
|
|
fx.effect(
|
|
"pipes stdout of one command to stdin of another",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
|
ChildProcess.pipeTo(
|
|
js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
|
),
|
|
),
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
yield* handle.exitCode
|
|
expect(out).toBe("HELLO WORLD")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"three-stage pipeline",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
|
ChildProcess.pipeTo(
|
|
js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
|
),
|
|
),
|
|
ChildProcess.pipeTo(
|
|
js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
|
|
),
|
|
),
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
yield* handle.exitCode
|
|
expect(out).toBe("HELLO-WORLD")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"pipes stderr with { from: 'stderr' }",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stderr.write("error")').pipe(
|
|
ChildProcess.pipeTo(
|
|
js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
|
),
|
|
{ from: "stderr" },
|
|
),
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
yield* handle.exitCode
|
|
expect(out).toBe("error")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"pipes combined output with { from: 'all' }",
|
|
Effect.gen(function* () {
|
|
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
|
|
ChildProcess.pipeTo(
|
|
js(
|
|
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
|
),
|
|
{ from: "all" },
|
|
),
|
|
)
|
|
const out = yield* decodeByteStream(handle.stdout)
|
|
yield* handle.exitCode
|
|
expect(out).toContain("stdout")
|
|
expect(out).toContain("stderr")
|
|
}),
|
|
)
|
|
})
|
|
|
|
describe("Windows-specific", () => {
|
|
fx.effect(
|
|
"uses shell routing on Windows",
|
|
Effect.gen(function* () {
|
|
if (process.platform !== "win32") return
|
|
|
|
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
|
svc.string(
|
|
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
|
|
shell: true,
|
|
extendEnv: true,
|
|
env: { OPENCODE_TEST_SHELL: "ok" },
|
|
}),
|
|
),
|
|
)
|
|
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
|
|
}),
|
|
)
|
|
|
|
fx.effect(
|
|
"runs cmd scripts with spaces on Windows without shell",
|
|
Effect.gen(function* () {
|
|
if (process.platform !== "win32") return
|
|
|
|
const tmp = yield* Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
)
|
|
const dir = path.join(tmp.path, "with space")
|
|
const file = path.join(dir, "echo cmd.cmd")
|
|
|
|
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
|
yield* Effect.promise(() => fs.writeFile(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
|
|
|
|
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
|
svc.exitCode(
|
|
ChildProcess.make(file, ["--stdio"], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
}),
|
|
),
|
|
)
|
|
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
|
}),
|
|
)
|
|
})
|
|
})
|