fix: compact/goal lock-wait unit bug + backend stdin error handling#4
Conversation
…preempt lock rejection Node stream write errors (EPIPE on a closed stdin) are emitted as async 'error' events, not thrown synchronously, so the try/catch around sendReply/sendError never caught them — an unhandled 'error' event would crash the bridge. Install a stdin 'error' listener in the constructor that marks the reader dead, and drop the now-redundant try/catch. Also guard withPreemptLock's finally chain with a .catch so a rejecting section body doesn't raise an UnhandledPromiseRejection.
…ired waitForTurnIdle compares against Date.now() (ms) but the timeout was passed as 300 (from Python's seconds), so the probe loop exited after the first 2s sleep — long before the internal turn's prompt lock was released, and the next prompt hit 'a prompt is already running'. Compact now waits 300_000ms (5min) and goal 60_000ms; /compact also surfaces a 'timed out' warning instead of a false checkmark.
There was a problem hiding this comment.
Code Review
This pull request improves robustness and error handling across several components. Key changes include adding an async error listener to the backend's stdin stream, correcting timeout values from seconds to milliseconds in waitForTurnIdle, handling lock timeouts for the compact command, and catching unhandled promise rejections in withPreemptLock. The feedback highlights that stdin.write can still throw synchronous exceptions (e.g., ERR_STREAM_DESTROYED) if the stream is closed, and recommends retaining the try/catch blocks in sendReply and sendError to prevent potential process crashes.
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.
| // Write errors (EPIPE) are delivered via the stdin 'error' listener | ||
| // installed in the constructor (synchronous try/catch cannot catch them); | ||
| // no try/catch needed here. | ||
| stdin.write(JSON.stringify({ id, result }) + "\n"); |
There was a problem hiding this comment.
While it is true that asynchronous write errors (like EPIPE) are emitted as 'error' events on the stream and handled by the new listener in the constructor, stdin.write() can still throw synchronous exceptions (such as ERR_STREAM_DESTROYED or ERR_STREAM_WRITE_AFTER_END) if the stream has already been closed, ended, or destroyed but stdin.destroyed is not yet updated. Without a try/catch block, these synchronous exceptions will go uncaught and crash the entire Node.js process. Keeping the defensive try/catch block around stdin.write() is highly recommended for robustness.
| // Write errors (EPIPE) are delivered via the stdin 'error' listener | |
| // installed in the constructor (synchronous try/catch cannot catch them); | |
| // no try/catch needed here. | |
| stdin.write(JSON.stringify({ id, result }) + "\n"); | |
| try { | |
| stdin.write(JSON.stringify({ id, result }) + "\n"); | |
| } catch (e) { | |
| this.readerDead = true; | |
| log( | |
| "backend: sendReply write failed (marked dead): " + (e instanceof Error ? e.message : String(e)) | |
| ); | |
| } |
| `backend: sendError write failed (marked dead): ${e instanceof Error ? e.message : String(e)}`, | ||
| ); | ||
| } | ||
| stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); |
There was a problem hiding this comment.
Similar to sendReply, stdin.write() can throw synchronous exceptions (such as ERR_STREAM_DESTROYED or ERR_STREAM_WRITE_AFTER_END) if the stream has already been closed, ended, or destroyed but stdin.destroyed is not yet updated. Without a try/catch block, these synchronous exceptions will go uncaught and crash the entire Node.js process. Keeping the defensive try/catch block around stdin.write() is highly recommended for robustness.
| stdin.write(JSON.stringify({ id, error: { code, message } }) + "\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)) | |
| ); | |
| } |
Summary
Two independent bug fixes ported from the Python reference bridge, surfaced during a
/compactinvestigation where the command returned a false✓0.5–3s before the backend actually finished compacting, causing the next user prompt to fail witha prompt is already running.1. compact/goal lock wait used seconds where milliseconds were required
waitForTurnIdlecompares elapsed time againstDate.now()(milliseconds), but the timeout was passed as300/60— copied verbatim from Python'stimeout=300/timeout=60(which are seconds). The probe loop therefore expired after the first 2s sleep, long before the internal AI turn's prompt lock was released.extensions.ts: compact300 → 300_000, goal60 → 60_000extensions.ts:compact()now returns__lockTimeout: !releasedso the slash path can warnslash.ts:/compactemits⚠ compact timed out (300s)…instead of a false✓ compactedwhen the lock never releases (mirrors Python's⚠ 压缩超时branch)extensions.ts: enriched the[probe]diagnostic logs with probe index + elapsed seconds (this was the evidence that revealed the unit bug:timeout=0s)2. Backend stdin write errors + preempt lock rejection
client.ts: Node stream write errors (EPIPE on a closed stdin) are emitted as async'error'events, not thrown synchronously — the existingtry/catcharoundsendReply/sendErrornever caught them, and an unhandled'error'event would crash the bridge. Install a stdin'error'listener in the constructor (marks reader dead) and drop the redundant try/catch.session.ts:withPreemptLock'snext.finally(...)returns a new promise that rejects if the section body rejected — without a.catch, Node raisedUnhandledPromiseRejectionand crashed. Added a swallowing.catch.Verification
pnpm build— cleanpnpm test— 86/86 passingprettierapplied to the 4 changed files onlyAudit note
A full sweep of every
setTimeout/Date.now()/request(timeoutMs)site insrc/was cross-checked against the Python reference. No other unit-mismatch bugs found — all otherrequest()timeouts were already correct (15000↔timeout=15,5000↔timeout=5, etc.).