diff --git a/src/backend/client.ts b/src/backend/client.ts index 1ec3c8b..34bb270 100644 --- a/src/backend/client.ts +++ b/src/backend/client.ts @@ -65,6 +65,14 @@ export class ZcodeBackend { env, detached: true, // own process group → kill(-pid) reaps the whole tree }); + // Node stream write errors (EPIPE on a closed stdin) are emitted as async + // 'error' events, NOT thrown synchronously — without a listener the process + // crashes with an unhandled 'error' event. Catch them here and mark the + // reader dead so the rest of the bridge stops talking to a gone backend. + this.proc.stdin?.on("error", (err) => { + this.readerDead = true; + log(`backend: stdin error: ${err.message}`); + }); this.startReader(); this.startWatchdog(); log(`backend: started zcode app-server (pid=${this.proc.pid})`); @@ -216,17 +224,10 @@ export class ZcodeBackend { log("backend: sendReply dropped (stdin closed)"); return; } - 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)}`, - ); - } + // 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"); } /** Reply to a zcode server→client request with an error. */ @@ -236,14 +237,7 @@ export class ZcodeBackend { log("backend: sendError dropped (stdin closed)"); return; } - 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)}`, - ); - } + stdin.write(JSON.stringify({ id, error: { code, message } }) + "\n"); } // ---------- send / request ---------- diff --git a/src/handlers/extensions.ts b/src/handlers/extensions.ts index abd9541..33c8888 100644 --- a/src/handlers/extensions.ts +++ b/src/handlers/extensions.ts @@ -109,7 +109,9 @@ export async function goal(server: ZcodeAcpServer, params: ExtensionParams): Pro if (resp.error) throw new Error(`goal failed: ${resp.error.message}`); // set/replace start an internal AI turn → wait for the prompt lock to release. if (action === "set" || action === "replace") { - const released = await waitForTurnIdle(server, zcodeSid, 60, "session/goal", false); + // timeout is in MILLISECONDS here (Date.now()-based), not seconds — Python's + // timeout=60 becomes 60000. A bare 60 would expire on the first probe. + const released = await waitForTurnIdle(server, zcodeSid, 60_000, "session/goal", false); log( `session/goal action=${action} → ok (${released ? "lock released" : "⚠ lock wait timeout"})`, ); @@ -133,7 +135,10 @@ export async function compact( if (resp.error) throw new Error(`compact failed: ${resp.error.message}`); // compact's internal AI turn (read history → LLM compress → write back) can // take minutes; expectLock=true avoids the startup-delay false-success window. - const released = await waitForTurnIdle(server, zcodeSid, 300, "session/goal", true); + // timeout is in MILLISECONDS (Date.now()-based), not seconds — Python's + // timeout=300 becomes 300000. A bare 300 expires on the first probe sleep, + // making compact return "✓" while the internal turn lock is still held. + const released = await waitForTurnIdle(server, zcodeSid, 300_000, "session/goal", true); log(`session/compact → ok (${released ? "lock released" : "⚠ lock wait timeout"})`); if (released) { // Refresh usage so the UI reflects the reduced contextUsed post-compact. @@ -146,7 +151,10 @@ export async function compact( } await emitInitialUsage(server, cx, acpSid, zcodeSid, differ); } - return (resp.result ?? {}) as Result; + // Surface the lock-timeout to the slash-command path so it can warn the user + // (the ACP method path ignores this non-standard flag). Mirrors Python's + // "⚠ 压缩超时" branch in _handle_slash_command. + return { ...((resp.result ?? {}) as Result), __lockTimeout: !released }; } /** session/steer → zcode session/steer: append instructions to a running turn. */ @@ -294,7 +302,13 @@ async function waitForTurnIdle( const backend = server.ensureBackend(); const t0 = Date.now(); let lockSeen = !expectLock; + let probeCount = 0; + log( + ` [probe] start waitForTurnIdle timeout=${Math.round(timeoutMs / 1000)}s expectLock=${expectLock} probeMethod=${probeMethod}`, + ); while (Date.now() - t0 < timeoutMs) { + probeCount++; + const elapsed = Math.round((Date.now() - t0) / 100) / 10; const resp = await backend.request( server.nextId(), probeMethod, @@ -303,26 +317,38 @@ async function waitForTurnIdle( ); const errMsg = resp.error?.message ?? ""; if (errMsg.includes("prompt is running")) { + log( + ` [probe] #${probeCount} @${elapsed}s: LOCK HELD ("${errMsg.slice(0, 60)}") → lockSeen=true`, + ); lockSeen = true; await sleep(2000); continue; } if (errMsg.toLowerCase().includes("timeout")) { + log(` [probe] #${probeCount} @${elapsed}s: probe timeout, continue`); await sleep(2000); continue; } if (resp.error) { if (lockSeen) { - log(` [probe] lock released (non-lock error treated as released: ${errMsg.slice(0, 40)})`); + log( + ` [probe] #${probeCount} @${elapsed}s: NON-LOCK error after lock → released (err="${errMsg.slice(0, 50)}")`, + ); return true; } + log( + ` [probe] #${probeCount} @${elapsed}s: NON-LOCK error, lockSeen=false → wait for lock (err="${errMsg.slice(0, 50)}")`, + ); await sleep(500); continue; } if (lockSeen) { - log(` [probe] lock released (elapsed ${Math.round((Date.now() - t0) / 1000)}s)`); + log(` [probe] #${probeCount} @${elapsed}s: probe success after lock → released`); return true; } + log( + ` [probe] #${probeCount} @${elapsed}s: probe success, lockSeen=false → wait for lock (still in startup window)`, + ); await sleep(500); } log(` [probe] wait timed out (${Math.round(timeoutMs / 1000)}s), lock may still be held`); diff --git a/src/handlers/session.ts b/src/handlers/session.ts index c9508cf..c6e45c3 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -452,12 +452,18 @@ function withPreemptLock( server.preemptLocks.set(zcodeSid, next); // Clean up the entry once settled so a later idle session doesn't retain a // dangling promise. Only delete if still ours (a newer section may have - // chained on top of us). - next.finally(() => { - if (server.preemptLocks.get(zcodeSid) === next) { - server.preemptLocks.delete(zcodeSid); - } - }); + // chained on top of us). The `.catch` swallows any rejection propagated by + // `finally` (it returns a new promise that rejects if `next` rejected) — + // otherwise Node would raise an UnhandledPromiseRejection and crash. + next + .finally(() => { + if (server.preemptLocks.get(zcodeSid) === next) { + server.preemptLocks.delete(zcodeSid); + } + }) + .catch(() => { + /* body rejection already surfaced by the returned `next`; swallow here */ + }); return next; } diff --git a/src/handlers/slash.ts b/src/handlers/slash.ts index 5813b98..fcca22f 100644 --- a/src/handlers/slash.ts +++ b/src/handlers/slash.ts @@ -50,7 +50,16 @@ export async function handleSlashCommand( try { switch (cmd) { case "compact": { - await compact(server, { sessionId: acpSid }, cx); + const result = (await compact(server, { sessionId: acpSid }, cx)) as { + __lockTimeout?: boolean; + }; + if (result.__lockTimeout) { + // 300s elapsed but the lock never released — the backend may still be + // compacting; the next prompt will hit "a prompt is already running". + return ok( + "⚠ compact timed out (300s), backend may still be processing — wait a bit before sending", + ); + } return ok("✓ compacted conversation context"); } case "goal": {