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
Dax Raad 5df9cecf03 fix(tui): remove plugin current marker 2026-08-31 14:31:03 -04:00
Dax Raad a68fe8a97d fix(tui): toggle plugin on dialog submit 2026-08-31 14:28:19 -04:00
Dax Raad c17c104827 fix(tui): toggle internal plugin controls 2026-08-31 14:25:06 -04:00
Dax Raad 5d4cc4a804 feat(tui): hide internal plugins by default 2026-08-31 14:25:06 -04:00
Kit Langton 1f04baa684 test: migrate fixture layer replacements (#46458)
Update the Core compile options and Server replacement values to the current LayerNode API. Preserve test expectations, replacement targets, and layer lifetimes.
2026-08-31 14:16:40 -04:00
8 changed files with 215 additions and 49 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(
+3 -1
View File
@@ -1374,7 +1374,9 @@ function buildExecution(
Layer.provide(Layer.succeed(Job.Service, jobs)),
// Do not reuse the outer harness's selector with its already-captured Location map.
Layer.provide(
LayerNode.compile(Instance.byLocationNode, [[LocationServiceMap.node, locations]]).pipe(Layer.fresh),
LayerNode.compile(Instance.byLocationNode, {
replacements: [LocationServiceMap.node.replace(locations)],
}).pipe(Layer.fresh),
),
),
scope,
+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",
+14 -13
View File
@@ -52,18 +52,19 @@ it.live(
const cell = PluginRuntime.makeCell()
// Host and private instances must reuse the same global layer identities.
const replacements: LayerNode.Replacements = [
[Global.node, tempGlobalLayer],
[Database.node, Database.node],
[Bus.node, Bus.node],
[App.node, App.node],
[ModelsDev.node, ModelsDev.configured({ fetch: false })],
[Watcher.node, Watcher.configured({ enabled: false })],
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
[llmClient, Layer.succeed(LLMClient.Service, llm)],
[SessionRunnerModel.node, Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) })],
[
Instance.byLocationNode,
Global.node.replace(tempGlobalLayer),
Database.node.replace(Database.node),
Bus.node.replace(Bus.node),
App.node.replace(App.node),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)),
PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(cell)),
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
),
Instance.byLocationNode.replace(
Layer.effect(
Instance.Service,
Effect.gen(function* () {
@@ -151,7 +152,7 @@ it.live(
})
}),
),
],
),
]
const context = yield* Layer.build(
createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)),
+1 -1
View File
@@ -268,7 +268,7 @@ export const Definitions = {
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
+1 -1
View File
@@ -230,7 +230,7 @@ export const Definitions = {
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
@@ -9,10 +9,11 @@ import { useDialog } from "../../ui/dialog"
const id = "opencode.plugins"
type Entry =
| { readonly key: string; readonly runtime: "server"; readonly plugin: PluginInfo }
| { readonly key: string; readonly runtime: "server"; readonly internal: boolean; readonly plugin: PluginInfo }
| {
readonly key: string
readonly runtime: "tui"
readonly internal: boolean
readonly id?: string
readonly target: string
readonly status: "active" | "inactive" | "failed"
@@ -28,7 +29,7 @@ export function PluginsDialog(props: {
const [locked, setLocked] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [initial, setInitial] = createSignal<string>()
const [showInternal, setShowInternal] = createSignal(false)
const [server] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
@@ -41,6 +42,7 @@ export function PluginsDialog(props: {
.map((plugin) => ({
key: `tui:${plugin.id}`,
runtime: "tui" as const,
internal: true,
id: plugin.id,
target: plugin.id,
status: plugin.active ? ("active" as const) : ("inactive" as const),
@@ -51,6 +53,7 @@ export function PluginsDialog(props: {
.map((plugin) => ({
key: `tui:${plugin.id ?? plugin.target}`,
runtime: "tui" as const,
internal: false,
id: plugin.id,
target: plugin.target,
status: plugin.status,
@@ -59,6 +62,7 @@ export function PluginsDialog(props: {
const serverEntries: Entry[] = (props.server?.() ?? server() ?? []).map((plugin) => ({
key: `server:${plugin.id ?? source(plugin, props.context)}`,
runtime: "server" as const,
internal: plugin.source.type === "builtin",
plugin,
}))
return [
@@ -66,16 +70,15 @@ export function PluginsDialog(props: {
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
]
})
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
createEffect(() => {
if (initial()) return
const first = entries().find((entry) => entry.runtime === "tui")
if (!first) return
setInitial(first.key)
setFocused(first.key)
if (visibleEntries().some((entry) => entry.key === focused())) return
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
setFocused(first?.key)
})
const options = createMemo(() =>
entries().map(
visibleEntries().map(
(entry): DialogSelectOption<string> => ({
title: label(entry, props.context),
value: entry.key,
@@ -133,12 +136,28 @@ export function PluginsDialog(props: {
<DialogSelect
title="Plugins"
options={options()}
current={initial()}
locked={locked()}
preserveSelection={true}
bindings={[
{
bind: "ctrl+a",
title: "Toggle internal plugins",
group: "Plugins",
run: () => {
setShowInternal((value) => !value)
},
},
]}
footerHints={[{ title: "ctrl+a", label: `${showInternal() ? "hide" : "show"} internal` }]}
onMove={(option) => setFocused(option.value)}
onSelect={(option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (
entry?.runtime === "tui" &&
entry.id &&
props.plugins.registered().some((plugin) => plugin.id === entry.id)
)
return toggle(entry)
if (pluginError(entry)) setDetail(entry)
}}
actions={
+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(