pi-messenger crashes pi with uncaughtException after session reload / newSession (stale extension ctx in status heartbeat)
Summary
pi-messenger@0.14.1 caches the extension context in a module-level variable (latestCtx) and drives a 15-second status heartbeat (setInterval) off it. When the session is replaced via ctx.newSession() / ctx.fork() / ctx.switchSession() / ctx.reload(), the pi runner invalidates the old context, but the heartbeat keeps firing on the now-stale latestCtx. The first property access (ctx.hasUI) calls runner.assertActive(), which throws — and because the throw happens inside a setInterval callback, it is uncaught and terminates the pi process.
Environment
pi-messenger: 0.14.1 (latest at time of writing)
@earendil-works/pi-coding-agent: (dist/core/extensions/runner.js)
- OS: macOS
- Node: (fill in with
node -v)
Stack trace
pi exiting due to uncaughtException:
Error: This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().
at ExtensionRunner.assertActive (.../pi-coding-agent/dist/core/extensions/runner.js:331:19)
at get hasUI (.../pi-coding-agent/dist/core/extensions/runner.js:424:24)
at updateStatus (.../pi-messenger/index.ts:231:14)
at Timeout._onTimeout (.../pi-messenger/index.ts:322:22)
at listOnTimeout (node:internal/timers:585:17)
at process.processTimers (node:internal/timers:521:7)
Repro
- Start pi with pi-messenger loaded in a project with peers (so
state.registered is true and the heartbeat is active).
- Trigger any session transition that invalidates the ctx:
/reload, ctx.newSession(), ctx.switchSession(), or ctx.fork().
- Wait up to 15 seconds (the
STATUS_HEARTBEAT_MS interval).
- pi exits with
uncaughtException as above.
The crash is timing-dependent: it only fires if the heartbeat ticks before the new session's session_start handler refreshes latestCtx (a race window of up to 15 s on every transition).
Root cause
index.ts:
// line 298
let latestCtx: ExtensionContext | null = null;
// lines 319-324
function startStatusHeartbeat(): void {
if (statusHeartbeatTimer) return;
statusHeartbeatTimer = setInterval(() => {
if (latestCtx) updateStatus(latestCtx); // ← throws on stale ctx
}, STATUS_HEARTBEAT_MS);
}
updateStatus() reads ctx.hasUI (line 231), and every ctx property getter is gated by runner.assertActive() (runner.js:423-476). After a session transition, runner.invalidate() (runner.js:323) sets staleMessage, so the next assertActive() throws.
Three contributing defects:
- No
try/catch around the heartbeat callback. A throw inside setInterval is uncaught → uncaughtException → process exit.
- The heartbeat is only stopped in
session_shutdown. Reload / newSession / fork / switchSession never stop it, so it keeps ticking on a dead ctx. There is a 15 s race window on every transition.
latestCtx has no invalidation hook. Nothing ties it to runner.invalidate(), so it holds a stale pointer until the next event handler (session_start, session_tree, turn_end, or tool execute) happens to overwrite it.
The same latent crash path exists in the onLiveWorkersChanged callback (index.ts:332-334) — same latestCtx, same pattern; it just isn't the path the stack trace caught.
Suggested fix
Minimal, root-cause: guard the timer callbacks and self-disarm on a stale ctx. The new session's session_start already calls startStatusHeartbeat(), so disarming on staleness is safe — it restarts on the fresh ctx.
function startStatusHeartbeat(): void {
if (statusHeartbeatTimer) return;
statusHeartbeatTimer = setInterval(() => {
- if (latestCtx) updateStatus(latestCtx);
+ if (!latestCtx) return;
+ try {
+ updateStatus(latestCtx);
+ } catch {
+ // ctx went stale after session reload/replacement.
+ // Stop ticking; session_start refreshes latestCtx + restarts us.
+ stopStatusHeartbeat();
+ }
}, STATUS_HEARTBEAT_MS);
}
And the same guard for the onLiveWorkersChanged callback:
onLiveWorkersChanged(() => {
- if (latestCtx) updateStatus(latestCtx);
+ if (!latestCtx) return;
+ try { updateStatus(latestCtx); } catch { stopStatusHeartbeat(); }
});
Alternative (more robust, framework-side)
If pi-coding-agent exposed an onInvalidate / onSessionReplaced lifecycle hook, pi-messenger could proactively clear latestCtx and stop timers. That would be the durable fix; the try/catch above is the minimal local defence.
Workaround
Until a fixed version ships, a local patch script re-applies the try/catch guard after every update:
fix-pi-messenger-stale-ctx.sh (attached / linked).
Why this matters
Any pi session transition (reload, new session, fork, switch) is a coin-flip on a hard process exit. For workflows that rely on /reload or session switching, this is a recurring crash, not a one-off.
Exact patch (unified diff, generated from patched install)
```diff
@@ -319,7 +319,14 @@
function startStatusHeartbeat(): void {
if (statusHeartbeatTimer) return;
statusHeartbeatTimer = setInterval(() => {
-
if (latestCtx) updateStatus(latestCtx);
-
-
-
-
-
// STALE-CTX-FIX v1: ctx went stale after session reload/replacement.
-
// Stop ticking; session_start refreshes latestCtx + restarts us.
-
-
}, STATUS_HEARTBEAT_MS);
}
@@ -330,7 +337,9 @@
}
onLiveWorkersChanged(() => {
- if (latestCtx) updateStatus(latestCtx);
- if (!latestCtx) return;
- try { updateStatus(latestCtx); } catch { stopStatusHeartbeat(); }
- // STALE-CTX-FIX v1
});
// ===========================================================================
```
pi-messenger crashes pi with
uncaughtExceptionafter session reload / newSession (stale extension ctx in status heartbeat)Summary
pi-messenger@0.14.1caches the extension context in a module-level variable (latestCtx) and drives a 15-second status heartbeat (setInterval) off it. When the session is replaced viactx.newSession()/ctx.fork()/ctx.switchSession()/ctx.reload(), the pi runner invalidates the old context, but the heartbeat keeps firing on the now-stalelatestCtx. The first property access (ctx.hasUI) callsrunner.assertActive(), which throws — and because the throw happens inside asetIntervalcallback, it is uncaught and terminates the pi process.Environment
pi-messenger: 0.14.1 (latest at time of writing)@earendil-works/pi-coding-agent: (dist/core/extensions/runner.js)node -v)Stack trace
Repro
state.registeredis true and the heartbeat is active)./reload,ctx.newSession(),ctx.switchSession(), orctx.fork().STATUS_HEARTBEAT_MSinterval).uncaughtExceptionas above.The crash is timing-dependent: it only fires if the heartbeat ticks before the new session's
session_starthandler refresheslatestCtx(a race window of up to 15 s on every transition).Root cause
index.ts:updateStatus()readsctx.hasUI(line 231), and every ctx property getter is gated byrunner.assertActive()(runner.js:423-476). After a session transition,runner.invalidate()(runner.js:323) setsstaleMessage, so the nextassertActive()throws.Three contributing defects:
try/catcharound the heartbeat callback. A throw insidesetIntervalis uncaught →uncaughtException→ process exit.session_shutdown. Reload / newSession / fork / switchSession never stop it, so it keeps ticking on a dead ctx. There is a 15 s race window on every transition.latestCtxhas no invalidation hook. Nothing ties it torunner.invalidate(), so it holds a stale pointer until the next event handler (session_start,session_tree,turn_end, or toolexecute) happens to overwrite it.The same latent crash path exists in the
onLiveWorkersChangedcallback (index.ts:332-334) — samelatestCtx, same pattern; it just isn't the path the stack trace caught.Suggested fix
Minimal, root-cause: guard the timer callbacks and self-disarm on a stale ctx. The new session's
session_startalready callsstartStatusHeartbeat(), so disarming on staleness is safe — it restarts on the fresh ctx.function startStatusHeartbeat(): void { if (statusHeartbeatTimer) return; statusHeartbeatTimer = setInterval(() => { - if (latestCtx) updateStatus(latestCtx); + if (!latestCtx) return; + try { + updateStatus(latestCtx); + } catch { + // ctx went stale after session reload/replacement. + // Stop ticking; session_start refreshes latestCtx + restarts us. + stopStatusHeartbeat(); + } }, STATUS_HEARTBEAT_MS); }And the same guard for the
onLiveWorkersChangedcallback:onLiveWorkersChanged(() => { - if (latestCtx) updateStatus(latestCtx); + if (!latestCtx) return; + try { updateStatus(latestCtx); } catch { stopStatusHeartbeat(); } });Alternative (more robust, framework-side)
If pi-coding-agent exposed an
onInvalidate/onSessionReplacedlifecycle hook, pi-messenger could proactively clearlatestCtxand stop timers. That would be the durable fix; thetry/catchabove is the minimal local defence.Workaround
Until a fixed version ships, a local patch script re-applies the
try/catchguard after every update:fix-pi-messenger-stale-ctx.sh(attached / linked).Why this matters
Any pi session transition (reload, new session, fork, switch) is a coin-flip on a hard process exit. For workflows that rely on
/reloador session switching, this is a recurring crash, not a one-off.Exact patch (unified diff, generated from patched install)
```diff
@@ -319,7 +319,14 @@
function startStatusHeartbeat(): void {
if (statusHeartbeatTimer) return;
statusHeartbeatTimer = setInterval(() => {
}
@@ -330,7 +337,9 @@
}
onLiveWorkersChanged(() => {
});
// ===========================================================================
```