Skip to content

pi exits with uncaughtException after session reload/newSession — stale extension ctx in status heartbeat #25

Description

@MartinMayday

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

  1. Start pi with pi-messenger loaded in a project with peers (so state.registered is true and the heartbeat is active).
  2. Trigger any session transition that invalidates the ctx: /reload, ctx.newSession(), ctx.switchSession(), or ctx.fork().
  3. Wait up to 15 seconds (the STATUS_HEARTBEAT_MS interval).
  4. 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:

  1. No try/catch around the heartbeat callback. A throw inside setInterval is uncaught → uncaughtException → process exit.
  2. 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.
  3. 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);
    
  •  if (!latestCtx) return;
    
  •  try {
    
  •    updateStatus(latestCtx);
    
  •  } catch {
    
  •    // STALE-CTX-FIX v1: ctx went stale after session reload/replacement.
    
  •    // Stop ticking; session_start refreshes latestCtx + restarts us.
    
  •    stopStatusHeartbeat();
    
  •  }
    
    }, STATUS_HEARTBEAT_MS);
    }

@@ -330,7 +337,9 @@
}

onLiveWorkersChanged(() => {

  • if (latestCtx) updateStatus(latestCtx);
  • if (!latestCtx) return;
  • try { updateStatus(latestCtx); } catch { stopStatusHeartbeat(); }
  • // STALE-CTX-FIX v1
    });

// ===========================================================================
```

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions