Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 61e2071a744642661d57cddb9b15e5ae6178d2a5
parent 133b5a9965761e5cd19ccba97cc571f5989d81c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 22 Jun 2026 16:01:30 -0400

Fix paused (force-killed) transcription wrongly recorded as failed

"Stop & keep progress" sends a partial-capable engine (parakeet) a
graceful SIGTERM via partialSignal. If the engine doesn't exit within
execa's 5s forceKillAfterDelay — which a parakeet window routinely
outlasts — execa force-SIGKILLs it and `await child` REJECTS instead of
resolving. That rejection skipped the pauseRequested branch and was
classed as a transcription failure, so the video got written to the
channel's failed-transcriptions file permanently (skipped as "failed"
on every later run despite its windows being cached for resume).

transcribeOneVideo now treats a force-killed *requested pause* as a
pause: it returns "paused" (a skip), so nothing lands in
failed-transcriptions and the next run resumes from the cached windows.
Hard Cancel and Drain were never affected (their abort signal already
classifies the kill as a skip).

Adds a "hangterm" mode to the fake parakeet wrapper (ignores SIGTERM ->
execa force-kill) and an e2e regression test that fails without the fix
(the resumed transcript never appears because the video is blacklisted)
and passes with it.

The CHANGELOG entry for this fix was committed separately in 133b5a9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/controller/transcribeOne.ts | 18+++++++++++++++++-
Meditor/e2e/fixtures/bin/fake-parakeet-stitch.mjs | 40+++++++++++++++++++++++++++++-----------
Meditor/e2e/parakeet-partial.spec.ts | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 98 insertions(+), 13 deletions(-)

diff --git a/common/controller/transcribeOne.ts b/common/controller/transcribeOne.ts @@ -167,7 +167,23 @@ export async function transcribeOneVideo( if (opts.partialSignal.aborted) stop(); else opts.partialSignal.addEventListener("abort", stop, { once: true }); } - await child; + try { + await child; + } catch (err) { + // A requested pause (SIGTERM via partialSignal) that doesn't exit within + // execa's forceKillAfterDelay gets force-SIGKILLed, so `await child` + // REJECTS instead of resolving. That's still a pause — no transcript was + // produced and the engine cached its completed windows for resume — NOT a + // failure. Return "paused" so the batch skips it (retries/resumes next + // run) rather than blacklisting the video in failed-transcriptions. + if (pauseRequested) { + log( + `Transcribe ${opts.videoId} paused (stopped before exit); completed windows cached — re-run to resume.`, + ); + return "paused"; + } + throw err; + } const producedPath = path.join(opts.videoDir, build.outputFile); if (!(await pathExists(producedPath))) { if (pauseRequested) { diff --git a/editor/e2e/fixtures/bin/fake-parakeet-stitch.mjs b/editor/e2e/fixtures/bin/fake-parakeet-stitch.mjs @@ -12,11 +12,16 @@ // removed); re-running resumes from cached windows. On SIGTERM it stops without // producing output (paused) — completed windows stay cached. // -// Two modes by video dir (cwd): +// Three modes by video dir (cwd): // * default: do both windows instantly and stitch. // * "slowop" dirs: window 0 is instant; window 1 waits — on SIGTERM it pauses // (no output, window 0 cached). A *resume* run (window 0 already cached) // finishes window 1 immediately and stitches. +// * "hangterm" dirs: like slowop, but window 1 IGNORES SIGTERM, so the run +// outlives execa's forceKillAfterDelay and is force-SIGKILLed (mimics a +// parakeet window that takes longer than the 5s grace). `await child` then +// REJECTS — which used to be mis-recorded as a failed transcription. Window 0 +// is still cached before the wait, so a resume run completes as for slowop. import { mkdir, writeFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; @@ -36,7 +41,10 @@ if (!out) { } if (device) process.stderr.write(`parakeet-stitch: device ${device}\n`); -const slow = process.cwd().toLowerCase().includes("slowop"); +const cwd = process.cwd().toLowerCase(); +const slow = cwd.includes("slowop"); +const hang = cwd.includes("hangterm"); +const interruptible = slow || hang; const workDir = path.resolve(`.${path.basename(audio)}.parakeet`); const win = (i) => path.join(workDir, `win-${String(i).padStart(4, "0")}.json`); const cue = (i) => ({ @@ -76,21 +84,31 @@ if (existsSync(win(0))) { if (existsSync(win(1))) { process.stderr.write(`parakeet-stitch: segment 2/2 @5s — cached\n`); await stitchAndFinish(); -} else if (!slow || resuming) { +} else if (!interruptible || resuming) { // Fresh-and-fast, or resuming a paused run: finish window 1 now. process.stderr.write(`parakeet-stitch: segment 2/2 @5s — transcribing\n`); await writeFile(win(1), JSON.stringify({ start: 5, cue: cue(1) })); await stitchAndFinish(); } else { - // Slow first run: window 1 is interruptible. On SIGTERM, pause (no output) — - // window 0 stays cached for the resume run. Backstop completes if never stopped. + // Interruptible first run: window 1 waits (window 0 already cached above). + // Backstop completes if never stopped. process.stderr.write(`parakeet-stitch: segment 2/2 @5s — transcribing\n`); - const onPause = () => { - process.stderr.write(`parakeet-stitch: paused after 1/2 window(s) — re-run to resume\n`); - process.exit(0); - }; - process.on("SIGTERM", onPause); - process.on("SIGINT", onPause); + if (hang) { + // Swallow the graceful stop so execa must force-kill us after its delay — + // `await child` rejects rather than resolving (the regression scenario). + const ignore = () => + process.stderr.write(`parakeet-stitch: stop ignored — still working (will be force-killed)\n`); + process.on("SIGTERM", ignore); + process.on("SIGINT", ignore); + } else { + // Slow: on SIGTERM pause (no output) — window 0 stays cached for the resume run. + const onPause = () => { + process.stderr.write(`parakeet-stitch: paused after 1/2 window(s) — re-run to resume\n`); + process.exit(0); + }; + process.on("SIGTERM", onPause); + process.on("SIGINT", onPause); + } setTimeout(async () => { await writeFile(win(1), JSON.stringify({ start: 5, cue: cue(1) })); await stitchAndFinish(); diff --git a/editor/e2e/parakeet-partial.spec.ts b/editor/e2e/parakeet-partial.spec.ts @@ -3,7 +3,7 @@ // (fixtures/bin/fake-parakeet-stitch.mjs) stays alive for "slowop" ids and writes // a partial (partial:true, one window) on SIGTERM. -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { pathExists, readJson, resetData, resolvePath, writeSettings } from "./helpers"; @@ -74,6 +74,57 @@ test("Stop & keep progress pauses a parakeet run, caches the window, and resumes expect(await pathExists(cachedWindow)).toBe(false); }); +test("Stop & keep progress on a force-killed parakeet run is NOT recorded as a failed transcription", async ({ + page, +}) => { + test.setTimeout(60_000); + await writeSettings({ + workers: [ + { id: "gpu", name: "GPU parakeet", kind: "local", enabled: true, priority: 0, appId: "parakeet", config: {} }, + ], + }); + // "hangterm" id: the fake parakeet ignores SIGTERM, so the partial stop's + // graceful kill outlives execa's 5s force-kill window and the engine is + // force-SIGKILLed — `await child` REJECTS. The bug recorded that rejection in + // failed-transcriptions (blacklisting the video forever); it must be a skip. + await makeTranscribeChannel("hangterm-chan", ["hangtermpk1"]); + const dir = "test-transcripts/channels/hangterm-chan/data/hangtermpk1"; + const transcript = `${dir}/transcript.json`; + const failedFile = "test-transcripts/channels/hangterm-chan/failed-transcriptions"; + + await page.goto("/channels/hangterm-chan"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + + await page.goto("/workers"); + const gpu = page.getByRole("listitem").filter({ hasText: "GPU parakeet" }); + const stop = gpu.getByRole("button", { name: /stop GPU parakeet keep progress/i }); + await expect(stop).toBeVisible({ timeout: 15_000 }); + await stop.click(); + + // The worker drains → disabled once the (force-killed) unit ends; Enable appears. + const enable = gpu.getByRole("button", { name: /enable GPU parakeet/i }); + await expect(enable).toBeVisible({ timeout: 20_000 }); + + // The force-killed pause must NOT have been recorded as a failure, and no + // transcript was produced (it paused before finishing). + if (await pathExists(failedFile)) { + const raw = await readFile(resolvePath(failedFile), "utf8"); + expect(raw).not.toContain("hangtermpk1"); + } + expect(await pathExists(transcript)).toBe(false); + + // Decisive end-to-end check: because the video was NOT blacklisted, re-enabling + // and re-running resumes it to completion from the cached window. Under the bug + // it would sit in failed-transcriptions and be skipped forever, so the + // transcript would never appear. + await enable.click(); + await page.goto("/channels/hangterm-chan"); + await page.getByRole("button", { name: "Transcribe missing" }).click(); + await expect.poll(() => pathExists(transcript), { timeout: 30_000 }).toBe(true); + const doc = await readJson<{ chunk_data?: unknown[] }>(transcript); + expect(doc.chunk_data?.length).toBe(2); +}); + test("a parakeet worker's device is configurable and persists", async ({ page, }) => {