Skip to content

fix(#597): harden supervisor timer callbacks against stale extension ctx - #606

Merged
HenryLach merged 2 commits into
mainfrom
fix/597-supervisor-stale-ctx
Jun 19, 2026
Merged

fix(#597): harden supervisor timer callbacks against stale extension ctx#606
HenryLach merged 2 commits into
mainfrom
fix/597-supervisor-stale-ctx

Conversation

@HenryLach

Copy link
Copy Markdown
Owner

Closes #597 — reported by @beettlle. Process-crashing bug during normal batch startup.

Background

Pi throws 'This extension ctx is stale after session replacement or reload' from its assertActive guard when an extension uses a captured pi: ExtensionAPI handle after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload().

Two background timers in supervisor.ts capture pi in their closures and call pi.sendMessage() at arbitrary times:

  1. startHeartbeat — 30s interval, calls pi.sendMessage in the takeover-detection branch
  2. startEventTailer.notify — fires per event, calls pi.sendMessage for each notification

When the captured pi went stale between ticks, the throw became a process-fatal uncaughtException that killed the entire Pi process. The reporter caught this on macOS with Pi 0.77.0; the bug is still present on main (Pi 0.79.6).

What this PR does

1. New isStaleExtensionCtx(err) helper

Recognizes Pi's distinctive stale-ctx error by message-substring match. The error class is not exported by @earendil-works/pi-coding-agent, so substring match on the documented message text is the stable contract. Defensive against non-Error throws (null, undefined, strings) — only the specific Pi case is matched.

2. New safeSendMessageFromTimer(pi, msg, opts) wrapper

The core never-throw invariant:

  • true on success
  • false on stale-ctx (caller stops its own interval)
  • true on other unexpected errors (logged via console.error, NOT propagated) — transient sendMessage failures are unlikely to repeat; only Pi specifically telling us 'you're dead, stop' (stale-ctx) gets the timer-stop treatment

3. Rewires three sendMessage call sites

Call site Why protected
startHeartbeat takeover-detection branch Original crash site #1
startEventTailer.notify callback Original crash site #2 — also stops the tailer on stale-ctx return
presentBatchSummary (added in Sage review followup) Reachable from startHeartbeat → deactivateSupervisor → presentBatchSummary when state.pendingSummaryDeps is set. Caught by Sage's review as a blocking finding — the original PR addressed only the direct call sites and missed this indirect path

4. Defensive teardown at top of activateSupervisor

stopEventTailer(state.eventTailer) + clearInterval(state.heartbeatTimer) + state.heartbeatTimer = null before installing new timers. Closes the re-activation gap where session churn could leave orphan timers holding stale pi references.

Tests — 9 new (8.14–8.22)

# Asserts
8.14 isStaleExtensionCtx identifies Pi's exact error message text
8.15 isStaleExtensionCtx rejects unrelated errors and non-Error values (null, undefined, strings, numbers, objects); accepts plain-string throws carrying the marker phrase
8.16 safeSendMessageFromTimer returns true and forwards args unchanged on success
8.17 safeSendMessageFromTimer NEVER throws on stale-ctx + returns false
8.18 safeSendMessageFromTimer logs via console.error but does NOT throw on unrelated errors; returns true
8.19 startHeartbeat takeover branch uses the safe wrapper; zero remaining bare pi.sendMessage() calls
8.20 startEventTailer.notify uses the safe wrapper + calls stopEventTailer on stale-ctx return
8.21 activateSupervisor calls teardown BEFORE startHeartbeat, uses clearInterval + state.heartbeatTimer = null (not just reassignment)
8.22 Behavioral: stale pi + pendingSummaryDeps set + deactivateSupervisor call does NOT throw. Mutation-verified to fail when the presentBatchSummary wrap is reverted. (Sage's behavioral-test request.)

Sage review

Sage flagged a blocking finding on the original PR: the heartbeat takeover branch's call chain reaches presentBatchSummary via deactivateSupervisor when state.pendingSummaryDeps is set, and presentBatchSummary had an unwrapped pi.sendMessage. Sage verified the issue with a direct runtime repro. Fixed in commit 3812491 with the wrapped presentBatchSummary + behavioral regression test 8.22. Sage's tests-as-source-substring-assertions critique was also addressed by 8.22.

Sage's two non-blocking notes were retained as deliberate decisions:

  • withSession as Pi's alternative pattern — Sage agreed it's not a direct replacement for passive background timers; try/catch hardening is appropriate
  • console.error logging surface — Sage marked as out-of-scope follow-up

Validation

Gate Result
npm run typecheck ✅ pass
npm run lint ✅ 286 warnings / 671 infos — identical to main
npm run format:check ✅ pass
Full test suite ✅ 3,723 / 3,724 pass, 1 skipped — zero new failures
Supervisor tests ✅ 123 / 123 pass (9 new for #597)
Mutation-test of 8.22 ✅ confirmed 8.22 fails when presentBatchSummary fix is reverted

Recommendation

Patch release v0.30.4 after merge. This is a real process-crashing bug for supervised batches under realistic session-churn conditions.

Pi throws 'This extension ctx is stale after session replacement or
reload' from assertActive when an extension uses a captured 'pi' handle
after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload().

Background timers in supervisor.ts (startHeartbeat, startEventTailer)
capture 'pi' in a closure and call pi.sendMessage() at arbitrary times.
When the captured handle goes stale between timer ticks, an uncaught
throw from pi.sendMessage() became a process-fatal uncaughtException
that killed the entire Pi process during normal batch startup.

This implements the three-part fix outlined in the issue.

1. New isStaleExtensionCtx(err) helper

   Recognizes Pi's distinctive stale-ctx error by message-substring
   match ('This extension ctx is stale'). Defensive against
   non-Error throws (null, undefined, strings, numbers) which are not
   stale-ctx errors and should propagate to the caller's normal error
   path. The error class is not exported by @earendil-works/pi-coding-
   agent so substring match on the documented message text is the
   stable contract.

2. New safeSendMessageFromTimer(pi, msg, opts) wrapper

   Replaces direct pi.sendMessage(...) at timer call sites. Returns:
   * true on success
   * false when the call was swallowed due to stale ctx (caller
     should clear its own interval)
   * true on other unexpected errors (logged via console.error so
     they're diagnosable, but not propagated; a background timer
     in a long-running process should keep ticking rather than
     crashing the host on a transient failure)

   Importantly: the wrapper NEVER throws. That's the entire point.

3. Rewires the two known-affected call sites

   a) startHeartbeat takeover branch (was line 3736, now 3781):
      the pi.sendMessage call that emits 'supervisor-yield' on
      sessionId mismatch.

   b) startEventTailer's notify() callback (was line 4447, now
      4486): the pi.sendMessage call that surfaces every
      supervisor-event notification.

   In (b), when the wrapper returns false (stale ctx detected), the
   notify callback also calls stopEventTailer() so the next tick
   doesn't repeat the failed call. Heartbeat already cleared its
   interval explicitly in the takeover branch before the
   sendMessage call, so the wrapper's false return is effectively a
   no-op there.

4. Defensive timer teardown at top of activateSupervisor

   The supervisor state is a mutable singleton that persists across
   activate/deactivate cycles. In re-activation paths (a previous
   activation that didn't go through deactivateSupervisor cleanly,
   or session churn after takeover), state.heartbeatTimer and
   state.eventTailer may still reference running timers that
   captured a now-stale pi handle. Just reassigning
   state.heartbeatTimer = startHeartbeat(...) would orphan the
   previous timer (still ticking, still holding the stale pi).

   Now activateSupervisor explicitly calls stopEventTailer() +
   clearInterval(state.heartbeatTimer) at the top before installing
   the new timers. stopEventTailer is already idempotent so this is
   safe on fresh state.

Tests

   8.14 — isStaleExtensionCtx identifies Pi's exact message text
   8.15 — isStaleExtensionCtx rejects unrelated errors and non-Error
          values (null, undefined, strings, numbers, plain objects)
          and the string-throw edge case (covers if Pi ever throws a
          plain string carrying the marker phrase)
   8.16 — safeSendMessageFromTimer returns true and forwards args
          unchanged on success
   8.17 — safeSendMessageFromTimer NEVER throws on stale-ctx + returns
          false (the core invariant; if this regresses, pi process
          crashes return)
   8.18 — safeSendMessageFromTimer logs via console.error but does NOT
          throw on unrelated errors; returns true (don't stop the
          timer for unrelated errors)
   8.19 — startHeartbeat takeover branch uses safeSendMessageFromTimer
          and has zero remaining bare pi.sendMessage() calls
   8.20 — startEventTailer notify callback uses the safe wrapper +
          calls stopEventTailer when wrapper returns false
   8.21 — activateSupervisor calls the teardown BEFORE startHeartbeat,
          uses clearInterval + state.heartbeatTimer = null (not just
          reassignment)

Validation

   npm run typecheck             pass
   npm run lint                  286 warnings / 671 infos (identical to main)
   npm run format:check          pass (1 biome auto-format on test file)
   Full test suite               3722/3723 pass, 1 skipped, zero new failures
   Supervisor tests              122/122 pass (8 new for #597)

Closes #597
…followup

Sage's review of the initial #597 fix caught a blocking finding: the
heartbeat takeover branch calls deactivateSupervisor(pi, state), and
deactivateSupervisor itself calls presentBatchSummary(pi, ...) when
state.pendingSummaryDeps is set. presentBatchSummary had an UNWRAPPED
pi.sendMessage at line 2023 \u2014 the exact uncaughtException pattern #597
was meant to prevent, just one frame deeper.

Quote from Sage:

  'startHeartbeat now wraps its direct pi.sendMessage, but then calls
   deactivateSupervisor(pi, state) (timer context). deactivateSupervisor
   can call presentBatchSummary when state.pendingSummaryDeps is set,
   and presentBatchSummary does a raw pi.sendMessage(...). If pi is
   stale, this throws and can still terminate the process. I verified
   this behavior with a direct runtime repro.'

Sage was right. Confirmed.

Fix

  presentBatchSummary's sole pi.sendMessage now goes through the
  safeSendMessageFromTimer wrapper introduced in the original PR. The
  function is a terminal best-effort presentation operation \u2014 if pi
  has gone stale, the message can't be delivered anyway; silently
  drop it rather than crashing the host. This is a strict
  generalization of the original fix and changes no behavior on the
  happy path. Function hoisting handles the forward reference (the
  wrapper is defined ~1700 lines later, but ES module function
  declarations hoist).

Behavioral regression test (8.22)

  Sage's second finding: 'Call-site rewiring tests are source-
  substring assertions, not behavioral timer-path tests. They won't
  catch regressions in runtime behavior.'

  Adds an actual behavioral test that wires up the exact failure
  scenario:

    1. Construct a SupervisorState in the active-with-pending-summary
       configuration (mirrors what the heartbeat takeover branch
       reaches in production)
    2. Provide a stale-pi mock whose sendMessage throws Pi's
       distinctive 'This extension ctx is stale ...' error
    3. Call deactivateSupervisor(stalePi, state) directly
    4. Assert no exception escapes
    5. Assert deactivation still completes its bookkeeping (state.active
       false, pendingSummaryDeps cleared) \u2014 confirms we didn't just
       swallow the error mid-flight

  I verified the test correctly catches the regression by temporarily
  reverting the presentBatchSummary fix: 8.22 fails on the unwrapped
  version, passes on the wrapped version.

Findings explicitly NOT addressed

  Sage flagged 'standardize timer-error logging surface with existing
  extension diagnostics style' as out-of-scope follow-up. Agreed \u2014
  console.error is functional for this PR; switching to a structured
  log channel is its own polish pass.

  Sage's 'withSession' question \u2014 'withSession is not a direct
  replacement for these passive background timers. The try/catch
  hardening pattern is appropriate here.' Sage explicitly endorsed
  the chosen approach. Confirmed not pursued.

Validation

  npm run typecheck             pass
  npm run lint                  286 warnings / 671 infos (identical to main)
  npm run format:check          pass (1 biome auto-format on test file)
  Full test suite               3723/3724 pass, 1 skipped, zero new failures
  Supervisor tests              123/123 pass (1 new for #597 \u2014 behavioral)
  Mutation-test of 8.22         confirmed 8.22 fails when fix is reverted
@HenryLach
HenryLach enabled auto-merge June 19, 2026 20:52
@HenryLach
HenryLach merged commit 6605ed3 into main Jun 19, 2026
1 check passed
@HenryLach
HenryLach deleted the fix/597-supervisor-stale-ctx branch June 19, 2026 20:53
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.

Supervisor heartbeat timer crashes pi with stale extension ctx (uncaughtException in startHeartbeat sendMessage)

1 participant