diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..c306986dc7ed 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 = "5 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..c3c05ae6292a 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: `${bin} -e 'process.on("SIGTERM", () => {}); console.log("ready"); setTimeout(() => {}, 20000)'`, + }, + { + ...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) + }), + ), + 15_000, + ) + it.live( "preserves output when aborted", () =>