From 48b9d1db1bc49175144e73bcdb1c78c473599b90 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 5 Apr 2026 21:20:43 -0600 Subject: [PATCH] answer: Escape XML and guard abort race Escape <, >, and & in question text, user answers, and notes before embedding them in pseudo-XML tags. Without this, literal angle brackets in content could confuse model parsing of the block. Wrap the done() callback in a finished-flag guard so only the first completion takes effect. The loader.onAbort handler and the in-flight extraction promise could both call done() on user cancellation, producing a double-completion race. --- packages/answer/src/index.ts | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/answer/src/index.ts b/packages/answer/src/index.ts index 12d61cdb46ef9f3d96e0aafe512999200fed850d..96a524de0c77d4cab003f401dc26f9b0e1d5d240 100644 --- a/packages/answer/src/index.ts +++ b/packages/answer/src/index.ts @@ -31,6 +31,11 @@ import { } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; +/** Escape characters that would break pseudo-XML tag boundaries. */ +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + // Structured output format for question extraction interface ExtractedQuestion { question: string; @@ -319,12 +324,12 @@ class QnAComponent implements Component { for (let i = 0; i < this.questions.length; i++) { const q = this.questions[i]; const a = this.answers[i]?.trim() || "(no answer)"; - parts.push(`${q.question}`); - parts.push(`${a}`); + parts.push(`${escapeXml(q.question)}`); + parts.push(`${escapeXml(a)}`); } parts.push(``); if (this.notesText) { - parts.push(`\n${this.notesText}`); + parts.push(`\n${escapeXml(this.notesText)}`); } this.onDone(parts.join("\n").trim()); @@ -625,7 +630,18 @@ export default function (pi: ExtensionAPI) { const outcome = await ctx.ui.custom((tui, theme, _kb, done) => { const extractionModel = resolveExtractionModel(ctx, sessionModel); const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`); - loader.onAbort = () => done({ kind: "cancelled" }); + + // Guard against double-completion: loader.onAbort fires on user + // cancel, but the in-flight promise may also resolve/reject after + // the abort. Only the first call to finish() takes effect. + let finished = false; + const finish = (result: ExtractionOutcome) => { + if (finished) return; + finished = true; + done(result); + }; + + loader.onAbort = () => finish({ kind: "cancelled" }); const tryExtract = async (model: ReturnType) => { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); @@ -687,21 +703,21 @@ export default function (pi: ExtensionAPI) { switch (result.kind) { case "ok": - return done({ kind: "ok", result: result.result }); + return finish({ kind: "ok", result: result.result }); case "cancelled": - return done({ kind: "cancelled" }); + return finish({ kind: "cancelled" }); case "model_error": - return done({ + return finish({ kind: "error", message: `${result.model.name} returned an error with no content`, }); case "parse_error": - return done({ kind: "error", message: result.message }); + return finish({ kind: "error", message: result.message }); } }; doExtract().catch((err) => - done({ + finish({ kind: "error", message: `${err?.message ?? err}`, }),