mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 06:26:24 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
583103f2dd | ||
|
|
acaca2cc01 | ||
|
|
ef8b8c1b8d | ||
|
|
5df9cecf03 | ||
|
|
a68fe8a97d | ||
|
|
c17c104827 | ||
|
|
5d4cc4a804 | ||
|
|
1f04baa684 |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user