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.
This commit is contained in:
@@ -1,18 +1,48 @@
|
|||||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
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) {
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let inFlight: AbortController | undefined;
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
// Fire-and-forget: do not block session start on the test run, and let the
|
// Defensive: if this instance ever starts twice, only the newest run lives.
|
||||||
// "test-balls:record" event land after every extension's session_start handler
|
inFlight?.abort();
|
||||||
// has run (project extensions load before global ones, so emitting synchronously
|
inFlight = new AbortController();
|
||||||
// from this handler would race test-balls' runs reset).
|
const signal = inFlight.signal;
|
||||||
void runMakeTest(pi, ctx).catch((error) => {
|
// 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);
|
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<void> {
|
async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal): Promise<void> {
|
||||||
|
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.
|
// Record exactly one ball per run, even if UI reporting below fails.
|
||||||
let recorded = false;
|
let recorded = false;
|
||||||
const record = (pass: boolean) => {
|
const record = (pass: boolean) => {
|
||||||
@@ -22,10 +52,13 @@ async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise<voi
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr, code } = await pi.exec("make", ["test"]);
|
const { stdout, stderr, code } = await pi.exec("make", ["test"], { signal });
|
||||||
record(code === 0);
|
if (signal.aborted) return; // session replaced: abandon silently
|
||||||
const output = stdout + stderr;
|
|
||||||
|
|
||||||
|
record(code === 0);
|
||||||
|
if (showStatus) ctx.ui.setStatus("make-test", undefined);
|
||||||
|
|
||||||
|
const output = stdout + stderr;
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
ctx.ui.notify("make test passed", "info");
|
ctx.ui.notify("make test passed", "info");
|
||||||
} else {
|
} else {
|
||||||
@@ -44,14 +77,11 @@ async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise<voi
|
|||||||
display: true,
|
display: true,
|
||||||
}, { deliverAs: "followUp" });
|
}, { deliverAs: "followUp" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (signal.aborted || isStaleCtxError(error)) return; // session replaced: abandon silently
|
||||||
|
if (showStatus) ctx.ui.setStatus("make-test", undefined);
|
||||||
record(false);
|
record(false);
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
pi.sendMessage({
|
ctx.ui.notify(`run_make_test failed: ${message}`, "error");
|
||||||
customType: "run-make-test",
|
throw error; // the session_start catch logs it
|
||||||
content: [
|
|
||||||
{ type: "text", text: `Error running make test: ${message}` },
|
|
||||||
],
|
|
||||||
display: true,
|
|
||||||
}, { deliverAs: "followUp" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user