From 256e73f81e53480f2dfe275e03dbd35a8c61d191 Mon Sep 17 00:00:00 2001 From: chengda Date: Sat, 2 May 2026 19:31:31 +0800 Subject: [PATCH 1/3] fix: prevent hang in SIGKILL escalation when process close event never fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orElse path in Effect.timeoutOrElse (used for forceKillAfter escalation from SIGTERM to SIGKILL) was awaiting the exit Deferred after sending SIGKILL. If the Node.js close event never fires (e.g., orphaned children from pkill holding pipe FDs), this Deferred.await hangs indefinitely with no timeout protection — even after SIGKILL reaches the process. Remove the Deferred.await from the orElse path since kill(SIGKILL) is synchronous on Linux — the kernel has already freed the PID and closed FDs before the syscall returns, making the wait redundant. --- packages/core/src/cross-spawn-spawner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index ad8d4126d454..6cb9cc4a43c0 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -393,7 +393,7 @@ export const make = Effect.gen(function* () { const escalated = command.options.forceKillAfter ? Effect.timeoutOrElse(attempt, { duration: command.options.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + orElse: () => send("SIGKILL"), }) : attempt return yield* Effect.ignore(escalated) @@ -430,7 +430,7 @@ export const make = Effect.gen(function* () { if (!opts?.forceKillAfter) return attempt return Effect.timeoutOrElse(attempt, { duration: opts.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + orElse: () => send("SIGKILL"), }) }, unref: Effect.sync(() => { From 08f1ee7b9ab39a617d873079ee3175b7df38fa05 Mon Sep 17 00:00:00 2001 From: chengda Date: Mon, 4 May 2026 09:59:53 +0800 Subject: [PATCH 2/3] fix: resolve Deferred on exit event instead of close to prevent pkill hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit-signal Deferred was resolved only on the Node.js 'close' event, which requires all pipe file descriptors to be closed. When pkill -f pattern matches the shell process itself (e.g., pkill -f vim where the shell command line contains 'vim'), the shell is killed but orphaned children (from shell init files or the pkill process itself) may still hold pipe FDs, preventing 'close' from ever firing. Resolve the Deferred on 'exit' instead — the exit event fires as soon as the process terminates regardless of pipe state. The 'close' handler is kept as a safety fallback. Output consumption via handle.all is independent and runs in a separate fiber, so resolving earlier does not cause data loss. --- packages/core/src/cross-spawn-spawner.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 6cb9cc4a43c0..db745a0d5055 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -273,6 +273,10 @@ export const make = Effect.gen(function* () { }) proc.on("exit", (...args) => { exit = args + if (!end) { + end = true + Deferred.doneUnsafe(signal, Exit.succeed(args)) + } }) proc.on("close", (...args) => { if (end) return From a226426380ff963a051bba9e42c6b071479b6cad Mon Sep 17 00:00:00 2001 From: chengda Date: Mon, 4 May 2026 11:43:20 +0800 Subject: [PATCH 3/3] fix: catch exitCode Error in bash tool to prevent Effect.raceAll hang --- packages/opencode/src/tool/bash.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index bf0008250592..a307af47115e 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -506,7 +506,10 @@ export const BashTool = Tool.define( const timeout = Effect.sleep(`${input.timeout + 100} millis`) const exit = yield* Effect.raceAll([ - handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))), + handle.exitCode.pipe( + Effect.map((code) => ({ kind: "exit" as const, code })), + Effect.catch(() => Effect.succeed({ kind: "exit" as const, code: null })), + ), abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))), timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))), ])