refactor(test/cli): migrate serve/acp builders to AppProcess.spawn

Slice 2 of the CLI harness Effect migration. Drops the last raw
Bun.spawn call sites in withCliFixture.

- `serve` and `acp` both move from `Effect.acquireRelease(Bun.spawn(...))`
  to `appProc.spawn(ChildProcess.make(...))`. The spawner's built-in
  acquireRelease finalizer handles SIGTERM on scope close — no manual
  wiring needed.

- `handle.stdout` / `handle.stderr` are already Effect Streams, so the
  `fromBunStream` helper is gone (Stream.fromReadableStream + the
  per-pipe error-tag boilerplate it wrapped).

- acp's stdin moves from imperative `proc.stdin.write` + `proc.stdin.end`
  to a Queue<Uint8Array> fed into the spawner's stdin Sink via
  Stream.fromQueue. `send` is `Queue.offer`, `close` is `Queue.shutdown` —
  shutdown propagates as stdin EOF, which is ACP's graceful-exit signal.

- ServeHandle/AcpHandle public shape: `kill`/`close` become
  Effect<void> and `exited` becomes Effect<number> (was () => void and
  Promise<number>). The platform error that cross-spawn-spawner raises
  on signal-kill is collapsed to exit code -1 so `exited` stays a clean
  Effect<number> — matches the test contract (just needs proof of exit).

Two consuming tests updated to yield the Effect instead of awaiting
the Promise.
This commit is contained in:
Kit Langton
2026-05-19 16:18:38 -04:00
parent 80e5fb11c2
commit 75c507f769
3 changed files with 73 additions and 98 deletions
@@ -51,19 +51,21 @@ describe("opencode acp (subprocess)", () => {
"exits cleanly when stdin is closed (scope close)",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
const exited = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp()
// Capture the Promise — scope-close fires the finalizer which
// ends stdin, and ACP should exit gracefully.
// Capture the Effect — scope-close shuts down stdinQueue, which
// propagates as stdin EOF; ACP exits gracefully. The exitCode
// Effect itself has no Scope requirement so yielding it after
// scope close is safe.
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
// Bun returns a number for normal exit. Anything goes for SIGTERM,
// but we still require resolution within the test timeout.
expect(typeof code === "number" || code === null).toBe(true)
const code = yield* exited
// Signal-killed processes surface as -1; clean EOF gives 0. Either
// way we just need a number — proves the process exited.
expect(typeof code).toBe("number")
}),
60_000,
)
@@ -41,20 +41,20 @@ describe("opencode serve (subprocess)", () => {
({ opencode }) =>
Effect.gen(function* () {
// Inner scope so we can observe `.exited` resolving after it closes.
const exitedPromise = yield* Effect.scoped(
const exited = yield* Effect.scoped(
Effect.gen(function* () {
const server = yield* opencode.serve()
// Capture the Promise, not the resolved value — scope closes after
// this gen returns, at which point the finalizer kills the child.
// Capture the Effect, not its result — scope closes after this
// gen returns, at which point the finalizer kills the child.
// handle.exitCode itself has no Scope requirement, so yielding
// it after scope close is fine.
return server.exited
}),
)
// After scope close: finalizer fired, process must have exited.
const code = yield* Effect.promise(() => exitedPromise)
// Bun reports the exit code; SIGTERM-killed processes return non-null
// (typically 143 on POSIX). We just require resolution within a sane
// window — anything else means the kill didn't take.
expect(typeof code === "number" || code === null).toBe(true)
// Signal-killed processes surface as -1 (see ServeHandle.exited).
const code = yield* exited
expect(typeof code).toBe("number")
}),
60_000,
)