Skip to content

fix: stop/cancel/preempt robustness aligned with Python reference + docs#3

Merged
william0wang merged 6 commits into
mainfrom
chore/bug-fix
Jul 5, 2026
Merged

fix: stop/cancel/preempt robustness aligned with Python reference + docs#3
william0wang merged 6 commits into
mainfrom
chore/bug-fix

Conversation

@william0wang

Copy link
Copy Markdown
Owner

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 ensureTurnStopped sent session/stop then probed session/goal show for up to 30s. Logs showed it timed out repeatedly (90s+ stalls) because the probe returns inconsistent values after stop. Replaced with stopBackendTurn — a fire-and-forget stop that mirrors Python's _cancel_backend_turn. The backend's own turn.completed(cancelled) event is the reliable release signal.

All six bare session/stop notify sites plus the session/send error path were rewritten. ensureTurnStopped (dead code) was deleted.

2. Serialize prompt preempt under a per-session lock

Two near-simultaneous prompts could both scan pendingTurns before either registers, miss each other, and run concurrently (listener overwrite, differ corruption). Added withPreemptLock — 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/stop now carries an id via a new send() method (some backends route by id presence); Python's _cancel_backend_turn always carried one.
  • sendReply/sendError mark readerDead on broken pipe, matching request() — without this, a half-closed stdin silently dropped replies and zcode reannounces spun the dedup cache forever.
  • translateTurnDone's used fallback chain uses || instead of ?? to match Python's or semantics (a falsy totalTokens falls back to tokenCount).

Verification

  • Logs confirm the full preempt chain works: cancel detected

…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.
@william0wang william0wang merged commit 3cec5e1 into main Jul 5, 2026
1 check passed
@william0wang william0wang deleted the chore/bug-fix branch July 5, 2026 09:26

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/handlers/session.ts
Comment on lines +456 to +460
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;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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(() => {});

Comment thread src/backend/client.ts
Comment on lines +219 to +229
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)}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Register an 'error' event listener on this.proc.stdin (e.g., in the constructor of ZcodeBackend or 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}`);
    });
  2. Remove the redundant and ineffective try/catch blocks around stdin.write calls in sendReply and sendError.
    stdin.write(JSON.stringify({ id, result }) + "\n");

Comment thread src/backend/client.ts
Comment on lines +239 to +246
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)}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Register an 'error' event listener on this.proc.stdin (e.g., in the constructor of ZcodeBackend or 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}`);
    });
  2. Remove the redundant and ineffective try/catch blocks around stdin.write calls in sendReply and sendError.
    stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant