End all sessions when the opencode process is killed#462
End all sessions when the opencode process is killed#462gtrrz-victor wants to merge 1 commit intosoph/opencode-refactorfrom
Conversation
…killed so we can end up the sessions Entire-Checkpoint: d4c504e45799
PR SummaryMedium Risk Overview The plugin now spawns a background "cleanup watcher" child process and tracks active session IDs; when the parent exits and the stdin pipe closes, the watcher invokes Written by Cursor Bugbot for commit 570bc11. Configure here. |
There was a problem hiding this comment.
Pull request overview
This PR adds a cleanup mechanism to ensure OpenCode sessions are properly ended when the OpenCode process is killed unexpectedly. The solution spawns a child Node.js process that monitors the parent via stdin pipe - when the parent exits (and the pipe closes), the child calls entire hooks opencode session-end for all tracked sessions.
Changes:
- Implements a process-based cleanup watcher using Bun.spawn and a child Node.js process
- Tracks active sessions via IPC messages over stdin/stdout
- Automatically cleans up orphaned sessions when the parent process exits
| let buf = ""; | ||
| process.stdin.setEncoding("utf8"); | ||
| process.stdin.on("data", (chunk) => { | ||
| buf += chunk; | ||
| let i; | ||
| while ((i = buf.indexOf("\\n")) !== -1) { | ||
| try { | ||
| const msg = JSON.parse(buf.slice(0, i)); | ||
| if (msg.type === "track") sessions.add(msg.id); | ||
| else if (msg.type === "untrack") sessions.delete(msg.id); | ||
| } catch {} | ||
| buf = buf.slice(i + 1); | ||
| } |
There was a problem hiding this comment.
The child process's stdin handlers accumulate data in buf without any size limit. A malicious or buggy parent could send an unbounded amount of data before closing stdin, causing the child process to consume excessive memory. Consider adding a maximum buffer size and discarding or rejecting data once the limit is reached.
| @@ -99,6 +153,7 @@ export const EntirePlugin: Plugin = async ({ $, directory }) => { | |||
| await callHook("session-end", { | |||
| session_id: session.id, | |||
| }) | |||
| watcher.untrack(session.id) | |||
There was a problem hiding this comment.
There is a potential race condition between session-start/session-end events and the cleanup watcher. If a session-end event occurs normally (via session.deleted) and untrack() is called at line 156, but the parent process is killed before the untrack message is processed by the child, the child may still attempt to clean up the already-ended session. While the try-catch at line 31 will suppress errors, this could result in unnecessary processing or duplicate session-end calls. Consider adding idempotency checks on the Go side to handle duplicate session-end calls gracefully.
| // Track current session ID for message events (which don't include sessionID) | ||
| let currentSessionID: string | null = null | ||
| // In-memory store for message metadata (role, tokens, etc.) | ||
| const messageStore = new Map<string, any>() |
There was a problem hiding this comment.
If OpenCode is restarted while sessions are still active, the cleanup watcher is spawned fresh without knowledge of pre-existing sessions. If the OpenCode process is then killed, those pre-existing sessions will not be cleaned up because they were never tracked by the watcher. Consider implementing a mechanism to discover and track existing sessions when the plugin initializes, or document this limitation.
| const messageStore = new Map<string, any>() | |
| const messageStore = new Map<string, any>() | |
| // NOTE: The cleanup watcher only tracks sessions reported during the lifetime | |
| // of this plugin instance. If OpenCode is restarted while sessions are still | |
| // active, those pre-existing sessions will not be known to this watcher and | |
| // may not receive automatic session-end cleanup if the process then exits. |
| stderr: "ignore", | ||
| }); |
There was a problem hiding this comment.
The child process spawned by Bun.spawn will become a zombie process when the parent exits, as there is no mechanism to reap it. The child process will terminate when stdin closes, but it will remain as a zombie until a parent process reaps it (calls wait). Consider using the detached: true option in Bun.spawn and calling child.unref() to allow the child to run independently and avoid zombie processes.
| stderr: "ignore", | |
| }); | |
| stderr: "ignore", | |
| detached: true, | |
| }); | |
| // Allow the watcher process to run independently and avoid zombie processes. | |
| child.unref(); |
| try { child.stdin.write(JSON.stringify({ type, id }) + "\n"); child.stdin.flush(); } catch { } | ||
| }; | ||
| return { | ||
| track: (id: string) => send("track", id), | ||
| untrack: (id: string) => send("untrack", id), | ||
| }; | ||
| } catch { |
There was a problem hiding this comment.
The write to child.stdin at line 48 could fail if the child process has already exited or if the pipe is broken. While the try-catch suppresses the error, this means that track/untrack calls could silently fail, leading to sessions not being cleaned up. Consider logging these failures or implementing a retry mechanism for critical operations like tracking new sessions.
| try { child.stdin.write(JSON.stringify({ type, id }) + "\n"); child.stdin.flush(); } catch { } | |
| }; | |
| return { | |
| track: (id: string) => send("track", id), | |
| untrack: (id: string) => send("untrack", id), | |
| }; | |
| } catch { | |
| try { | |
| child.stdin.write(JSON.stringify({ type, id }) + "\n"); | |
| child.stdin.flush(); | |
| } catch (err) { | |
| // Log (but do not rethrow) so failed tracking doesn't remain silent | |
| console.error("[entire-plugin] failed to send session tracking message to cleanup watcher:", err); | |
| } | |
| }; | |
| return { | |
| track: (id: string) => send("track", id), | |
| untrack: (id: string) => send("untrack", id), | |
| }; | |
| } catch (err) { | |
| // If the watcher process cannot be started, log and fall back to no-op tracking | |
| console.error("[entire-plugin] failed to spawn cleanup watcher process:", err); |
| // Spawns a child process connected to the parent via stdin pipe. | ||
| // When the parent exits (including SIGKILL), the pipe closes and the child | ||
| // calls `entire hooks opencode session-end` for every tracked session. |
There was a problem hiding this comment.
The comment states that the child process handles SIGKILL, but this is incorrect. SIGKILL cannot be caught or handled by any process - it immediately terminates the process without allowing any cleanup. What actually happens is that when the parent process is terminated (by any signal including SIGKILL), the pipe closes, which triggers the 'end' event on stdin. The comment should be corrected to accurately describe this mechanism.
| try { | ||
| const child = Bun.spawn(["node", "-e", script], { | ||
| stdin: "pipe", | ||
| stdout: "ignore", | ||
| stderr: "ignore", | ||
| }); | ||
| const send = (type: string, id: string) => { | ||
| try { child.stdin.write(JSON.stringify({ type, id }) + "\n"); child.stdin.flush(); } catch { } | ||
| }; | ||
| return { | ||
| track: (id: string) => send("track", id), | ||
| untrack: (id: string) => send("untrack", id), | ||
| }; | ||
| } catch { | ||
| return { track: () => { }, untrack: () => { } }; | ||
| } |
There was a problem hiding this comment.
The code assumes that the 'node' command is available in the system PATH, but there is no validation or graceful degradation if Node.js is not installed. While the outer try-catch at line 41 will catch spawn failures and return no-op functions, this could lead to silent failures where sessions are never cleaned up. Consider adding a comment explaining this fallback behavior, or implementing a one-time check/warning to notify users if node is not available.
| currentSessionID = session.id | ||
| watcher.track(session.id) |
There was a problem hiding this comment.
The watcher tracks sessions when session.created fires, but if the track() call fails silently (e.g., child process already exited), and then the OpenCode process is killed before a normal session-end event, the session will be orphaned and not cleaned up. Consider adding validation or logging to detect when the watcher fails to initialize or track sessions, so users are aware of potential cleanup issues.
Entire-Checkpoint: d4c504e45799