From 34f76444aead2ecc2eb1eba636ce875ea6f03b80 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Thu, 17 Sep 2026 12:55:23 -0700 Subject: [PATCH 1/3] fix(opencode): bound shell cleanup on interruption --- packages/opencode/src/tool/shell.ts | 8 ++++-- packages/opencode/test/tool/shell.test.ts | 31 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..91b286e72875 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -25,6 +25,7 @@ import { BashArity } from "@/permission/arity" export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 +const FORCE_KILL_AFTER = "3 seconds" const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, @@ -297,6 +298,7 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv env, stdin: "ignore", detached: false, + forceKillAfter: FORCE_KILL_AFTER, }) } @@ -306,6 +308,8 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv env, stdin: "ignore", detached: process.platform !== "win32", + // Scope interruption also terminates the process, outside the timeout/abort handlers. + forceKillAfter: FORCE_KILL_AFTER, }) } const parser = lazy(async () => { @@ -547,11 +551,11 @@ export const ShellTool = Tool.define( if (exit.kind === "abort") { aborted = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + yield* handle.kill({ forceKillAfter: FORCE_KILL_AFTER }).pipe(Effect.orDie) } if (exit.kind === "timeout") { expired = true - yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie) + yield* handle.kill({ forceKillAfter: FORCE_KILL_AFTER }).pipe(Effect.orDie) } return exit.kind === "exit" ? exit.code : null diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index a970f85d468f..70e40273e8e0 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,7 +1,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Cause, Effect, Exit, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import type * as Scope from "effect/Scope" import os from "os" import path from "path" @@ -1007,6 +1007,35 @@ describe("tool.shell permissions", () => { }) describe("tool.shell abort", () => { + it.live( + "bounds cleanup when an interrupted command ignores SIGTERM", + () => + runIn( + projectRoot, + Effect.gen(function* () { + if (process.platform === "win32") return + const ready = yield* Deferred.make() + const fiber = yield* run( + { + command: `node -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 15000)'`, + }, + { + ...ctx, + metadata: (input) => + String(input.metadata?.output).includes("ready") + ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) + : Effect.void, + }, + ).pipe(Effect.forkScoped) + yield* Deferred.await(ready) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + }), + ), + 10_000, + ) + it.live( "preserves output when aborted", () => From 651061678e7be16026b9edde9b2f7b60a759b654 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Thu, 17 Sep 2026 14:55:52 -0700 Subject: [PATCH 2/3] test(opencode): use current runtime for interruption regression --- packages/opencode/test/tool/shell.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 70e40273e8e0..f589fd57b7f8 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1017,7 +1017,7 @@ describe("tool.shell abort", () => { const ready = yield* Deferred.make() const fiber = yield* run( { - command: `node -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 15000)'`, + command: `${bin} -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 15000)'`, }, { ...ctx, From 87a447a347798b941f429645c8acd194d2faea0a Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Thu, 17 Sep 2026 15:08:25 -0700 Subject: [PATCH 3/3] fix(opencode): allow five seconds for graceful shell shutdown --- packages/opencode/src/tool/shell.ts | 2 +- packages/opencode/test/tool/shell.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 91b286e72875..c306986dc7ed 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -25,7 +25,7 @@ import { BashArity } from "@/permission/arity" export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 -const FORCE_KILL_AFTER = "3 seconds" +const FORCE_KILL_AFTER = "5 seconds" const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index f589fd57b7f8..c3c05ae6292a 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1017,7 +1017,7 @@ describe("tool.shell abort", () => { const ready = yield* Deferred.make() const fiber = yield* run( { - command: `${bin} -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 15000)'`, + command: `${bin} -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 20000)'`, }, { ...ctx, @@ -1033,7 +1033,7 @@ describe("tool.shell abort", () => { expect(Exit.isFailure(exit)).toBe(true) }), ), - 10_000, + 15_000, ) it.live(