fix: stop/cancel/preempt robustness aligned with Python reference + docs#3
Conversation
…r leak Two fixes for the fire-and-forget stop path: - session.ts + server.ts: wrap 'register self in pendingTurns + preempt others' in a per-zcodeSid promise lock (withPreemptLock). Without it two near-simultaneous prompts could both scan pendingTurns before either registers, miss each other, and run concurrently (listener overwrite, differ corruption). Registering INSIDE the lock guarantees the next prompt entering its section sees this turn and cancels it. - backend/client.ts: close()'s 3s exit-wait timer was never cleared, keeping the event loop alive after a clean shutdown. Capture the timer and clear it on exit.
Three robustness fixes found by diffing against zcode-open-bridge: - backend/client.ts: add send() that carries an id but does not register a pending response (fire-and-forget), mirroring Python's _cancel_backend_turn. Some backends route by id presence, so session/stop via send() is more reliable than a bare notify(). stopBackendTurn now uses send(). - backend/client.ts: sendReply/sendError now mark readerDead on stdin write failure (broken pipe), matching request()'s behavior. Without this, a half-closed stdin would silently drop replies and zcode reannounces would spin the dedup cache forever. - translators/event-translator.ts: translateTurnDone's used fallback chain now uses || instead of ?? to match Python's 'or' semantics — a falsy totalTokens (0/undefined) falls back to tokenCount. Also fixes a stray closing brace.
…Y.md - README (en + zh-CN): add a Related Projects section linking to tizerluo/zcode-open-bridge (the Python reference this server borrows from), an Acknowledgements section crediting ACP / ZCode / the reference implementation, and a non-affiliation disclaimer. - SECURITY.md: document the private vulnerability reporting process and the bridge's credential-handling scope, since this server reads ZCode creds from ~/.zcode/v2/config.json.
This server is a stdio protocol translator; it doesn't modify ZCode, expose a network surface, or run its own auth. Credential handling is just passing ZCode's own config to its own subprocess, which is ZCode's responsibility. A SECURITY.md would be over-documentation.
…qlite access The bridge reads the ZCode provider's baseURL and apiKey from ~/.zcode/v2/config.json (forwarded to the subprocess as ANTHROPIC_API_KEY, never logged) and writes rows to ~/.zcode/v2/tasks-index.sqlite so that ACP-created sessions appear in the ZCode app UI. SECURITY.md now documents exactly what is read/written, what is explicitly not done, and how to report issues privately.
There was a problem hiding this comment.
Code Review
This pull request introduces a per-session preempt lock to serialize prompt registration and cancellation, replaces polling-based turn stopping with a fire-and-forget mechanism, adds a watchdog timer cleanup, and updates documentation including a new security policy. The reviewer feedback highlights two critical issues: first, withPreemptLock's finally chain lacks a .catch() handler, which could trigger an UnhandledPromiseRejection upon rejection; second, the synchronous try/catch blocks around stdin.write are ineffective for catching asynchronous stream errors (like EPIPE) in Node.js, and should be replaced with an 'error' event listener on the stream.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| next.finally(() => { | ||
| if (server.preemptLocks.get(zcodeSid) === next) { | ||
| server.preemptLocks.delete(zcodeSid); | ||
| } | ||
| // Grace window expired without ever seeing the lock: turn never started | ||
| // (stop caught it in time) or already ended. Safe to treat as released. | ||
| log(` [stop] no lock observed within grace window, treating as released`); | ||
| return true; | ||
| } | ||
| log(` [stop] lock wait timed out (30s), lock may still be held`); | ||
| return false; | ||
| }); |
There was a problem hiding this comment.
In withPreemptLock, next.finally(...) is called to clean up the lock map once the promise settles. However, next.finally(...) returns a new promise that propagates the rejection if next is rejected. Since this returned promise is neither returned nor caught, it will trigger an UnhandledPromiseRejection in Node.js if the body of the lock (e.g., preemptInFlightTurn) rejects.
To prevent this, append .catch(() => {}) to the finally call to safely consume any propagated rejection.
| next.finally(() => { | |
| if (server.preemptLocks.get(zcodeSid) === next) { | |
| server.preemptLocks.delete(zcodeSid); | |
| } | |
| // Grace window expired without ever seeing the lock: turn never started | |
| // (stop caught it in time) or already ended. Safe to treat as released. | |
| log(` [stop] no lock observed within grace window, treating as released`); | |
| return true; | |
| } | |
| log(` [stop] lock wait timed out (30s), lock may still be held`); | |
| return false; | |
| }); | |
| next.finally(() => { | |
| if (server.preemptLocks.get(zcodeSid) === next) { | |
| server.preemptLocks.delete(zcodeSid); | |
| } | |
| }).catch(() => {}); |
| try { | ||
| stdin.write(JSON.stringify({ id, result }) + "\n"); | ||
| } catch (e) { | ||
| // Broken pipe: backend is gone. Mark reader dead so the rest of the | ||
| // bridge stops trying to talk to it; otherwise zcode reannounces would | ||
| // keep hitting a dead pipe and the dedup cache would spin forever. | ||
| this.readerDead = true; | ||
| log( | ||
| `backend: sendReply write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
In Node.js, stream.write() is asynchronous and does not throw synchronous exceptions for write errors like EPIPE (broken pipe). Instead, these errors are emitted as 'error' events on the stream (stdin).
The synchronous try/catch block here will not catch these errors, and if there is no 'error' event listener registered on this.proc.stdin, the entire Node.js process will crash with an unhandled 'error' event.
To fix this robustly:
- Register an
'error'event listener onthis.proc.stdin(e.g., in the constructor ofZcodeBackendor during initialization) to handle write errors and mark the reader dead:this.proc.stdin?.on('error', (err) => { this.readerDead = true; log(`backend: stdin error: ${err.message}`); });
- Remove the redundant and ineffective
try/catchblocks aroundstdin.writecalls insendReplyandsendError.
stdin.write(JSON.stringify({ id, result }) + "\n");| try { | ||
| stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); | ||
| } catch (e) { | ||
| this.readerDead = true; | ||
| log( | ||
| `backend: sendError write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
In Node.js, stream.write() is asynchronous and does not throw synchronous exceptions for write errors like EPIPE (broken pipe). Instead, these errors are emitted as 'error' events on the stream (stdin).
The synchronous try/catch block here will not catch these errors, and if there is no 'error' event listener registered on this.proc.stdin, the entire Node.js process will crash with an unhandled 'error' event.
To fix this robustly:
- Register an
'error'event listener onthis.proc.stdin(e.g., in the constructor ofZcodeBackendor during initialization) to handle write errors and mark the reader dead:this.proc.stdin?.on('error', (err) => { this.readerDead = true; log(`backend: stdin error: ${err.message}`); });
- Remove the redundant and ineffective
try/catchblocks aroundstdin.writecalls insendReplyandsendError.
stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n");
Summary
Three rounds of fixes to the stop/cancel/preempt path, found by diffing against the Python reference (
zcode-open-bridge), plus documentation updates (acknowledgements, related projects, SECURITY.md).Stop / cancel / preempt fixes
1. Replace 30s lock-probing with fire-and-forget stop
The earlier
ensureTurnStoppedsentsession/stopthen probedsession/goal showfor up to 30s. Logs showed it timed out repeatedly (90s+ stalls) because the probe returns inconsistent values after stop. Replaced withstopBackendTurn— a fire-and-forget stop that mirrors Python's_cancel_backend_turn. The backend's ownturn.completed(cancelled)event is the reliable release signal.All six bare
session/stopnotify sites plus thesession/senderror path were rewritten.ensureTurnStopped(dead code) was deleted.2. Serialize prompt preempt under a per-session lock
Two near-simultaneous prompts could both scan
pendingTurnsbefore either registers, miss each other, and run concurrently (listener overwrite, differ corruption). AddedwithPreemptLock— a per-zcodeSid promise chain that wraps "register self + preempt others" so concurrent prompts execute strictly one after another.Also fixes
close()'s 3s exit-wait timer leak (the timer was never cleared, keeping the event loop alive).3. Align stop/reply/usage with Python
session/stopnow carries an id via a newsend()method (some backends route by id presence); Python's_cancel_backend_turnalways carried one.sendReply/sendErrormarkreaderDeadon broken pipe, matchingrequest()— without this, a half-closed stdin silently dropped replies and zcode reannounces spun the dedup cache forever.translateTurnDone'susedfallback chain uses||instead of??to match Python'sorsemantics (a falsytotalTokensfalls back totokenCount).Verification