From fc2bafdfa162b24c5a55f7462548867662b2c785 Mon Sep 17 00:00:00 2001 From: Firehose Bot Date: Thu, 15 Oct 2026 14:00:38 +0100 Subject: [PATCH] fix(pi): abort session-start make test on session replacement to avoid stale ctx The fire-and-forget run captured its session's pi/ctx; after /new, fork, switch, or reload pi invalidates those, so the orphaned run's notify/ sendMessage threw 'This extension ctx is stale after session replacement or reload'. Now session_shutdown aborts the in-flight run before invalidation (pi.exec supports AbortSignal), and stale errors are swallowed as a race guard. Also show a 'running tests...' footer status widget while the run is in flight, cleared on completion. --- .pi/extensions/run_make_test.ts | 62 ++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/.pi/extensions/run_make_test.ts b/.pi/extensions/run_make_test.ts index ee0cdf9..a739af1 100644 --- a/.pi/extensions/run_make_test.ts +++ b/.pi/extensions/run_make_test.ts @@ -1,18 +1,48 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +/** + * Fires `make test` in the background on session start. + * + * The run is tied to the session that started it: + * - a footer status widget shows "running tests…" while it runs + * - on completion it records a test-balls ball, notifies, and posts the output + * - `session_shutdown` (quit/reload/new/resume/fork) aborts an in-flight run: + * pi invalidates captured pi/ctx objects on session replacement, so any + * post-run work from an orphaned run would throw "This extension ctx is + * stale after session replacement or reload". The replacement session + * starts its own run, so an aborted run is abandoned silently. + */ +const STALE_CTX_PREFIX = "This extension ctx is stale after session replacement or reload."; + +const isStaleCtxError = (error: unknown): boolean => + error instanceof Error && error.message.startsWith(STALE_CTX_PREFIX); + export default function (pi: ExtensionAPI) { + let inFlight: AbortController | undefined; + pi.on("session_start", async (_event, ctx) => { - // Fire-and-forget: do not block session start on the test run, and let the - // "test-balls:record" event land after every extension's session_start handler - // has run (project extensions load before global ones, so emitting synchronously - // from this handler would race test-balls' runs reset). - void runMakeTest(pi, ctx).catch((error) => { + // Defensive: if this instance ever starts twice, only the newest run lives. + inFlight?.abort(); + inFlight = new AbortController(); + const signal = inFlight.signal; + // Fire-and-forget: do not block session start on the test run. + void runMakeTest(pi, ctx, signal).catch((error) => { + if (isStaleCtxError(error)) return; // session replaced mid-finish: nothing left to report to console.error("run_make_test:", error); }); }); + + pi.on("session_shutdown", () => { + // Runs before the runner is invalidated: kill the child process so an + // orphaned run can never touch the stale pi/ctx that spawned it. + inFlight?.abort(); + }); } -async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise { +async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal): Promise { + const showStatus = ctx.mode === "tui"; + if (showStatus) ctx.ui.setStatus("make-test", "running tests…"); + // Record exactly one ball per run, even if UI reporting below fails. let recorded = false; const record = (pass: boolean) => { @@ -22,10 +52,13 @@ async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise