Skip to content

Commit a7b3033

Browse files
K2GO-451 fix(dashboard): heal a crashed rebuild with no pid recorded; unit-test the decision
Review follow-up. The status read and the new-rebuild guard now both go through readRebuildStatus(), which also heals a 'running' that has outlived a short grace with no pid ever recorded (the script died before, or without, writing one), not just a dead recorded pid. The heal decision moves to the pure, unit-tested resolveRebuildState. A running within the grace (just started) is left untouched.
1 parent 7c3b10e commit a7b3033

5 files changed

Lines changed: 106 additions & 17 deletions

File tree

‎static/dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
44
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
55
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
66

7-
- **1.4.0** - Rebuild status self-heals a crashed run (K2GO-451). `GET /rebuild/status` now checks whether the recorded rebuild pid is still alive; a `running` status left behind by a script that was killed mid-build (box restart / power loss) is healed to `error` and the lock/phase/pid files are cleared, so the app sees a normal failure instead of polling a phantom `running` forever and a new rebuild is no longer refused with 409. The dead-pid check and state reset are shared with the cancel handler (one source). A `running` with no pid yet (a just started run) is left untouched. Localhost-only. (K2GO-451)
7+
- **1.4.0** - Rebuild status self-heals a crashed run (K2GO-451). A `running` status left behind by a script that was killed mid-build (box restart / power loss) is healed to `error` and the lock/phase/pid files are cleared, so the app sees a normal failure instead of polling a phantom `running` forever and a new rebuild is no longer refused with 409. `GET /rebuild/status` and the new-rebuild guard both read through `readRebuildStatus()`, which heals when the recorded pid is dead, or when a `running` has outlived a short grace with no pid ever recorded (the script died before, or without, writing one). A `running` with a live pid, or one still within the grace (a just started run), is left untouched. The dead-pid check and state reset are shared with the cancel handler (one source); the heal decision is the pure, unit-tested `resolveRebuildState`. Localhost-only. (K2GO-451)
88
- **1.3.13** - Forgejo repo refresh on the durable job engine (K2GO-443). `forgejo` is now a job type: `POST /forgejo/download` plus `GET /forgejo/jobs/:id` (structured `{phase, percent, detail}`) and retry/cancel over the generic `/:type/*` surface. The runner (`sockets/forgejo.exec.ts`) wraps the existing box orchestration (`static/forgejo/orchestration` -> `refresh_forgejo`) and reports per-repo progress (repo N of M + the current repo name) parsed from a new `K2GO_PROGRESS` marker the orchestration emits. Forgejo is a git operation (fetch + fast-forward/merge + authenticated push per seeded example repo), not a file download, so there is NO aria2 and NO pause/resume; retry re-runs the idempotent refresh. The seed (install) path is unchanged. The older `POST /forgejo/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
99
- **1.3.12** - Add-ons gallery download on the durable job engine (K2GO-443). `code-addons` is now a job type: `POST /code-addons/download` plus `GET /code-addons/jobs/:id` (structured `{phase, percent, speed, detail}`) and pause/resume/retry/cancel over the generic `/:type/*` surface, like build-assets. The runner (`sockets/code_addons.exec.ts`) downloads only the heavy add-on binaries (.cgp + source tarballs) with aria2c (resilient: `--continue` resume, survives a network change) using the shared `downloadWithAria2` helper; the mirror stages the small files (shell, catalog, icons, pages) with its Cloudflare clean + catalog base rewrite and prints the aria2 input for the heavy ones (`mirror_addons.py --print-aria2-input`), then verifies them (`--finalize-only`), and the runner swaps the staged tree in atomically. The older `POST /addons/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
1010
- **1.3.11** - Build-assets download on the durable job engine (K2GO-443). `code-assets` is now a job type: `POST /code-assets/download` plus `GET /code-assets/jobs/:id` (structured `{phase, percent, speed, detail}`) and pause/resume/retry/cancel over the generic `/:type/*` surface, like kiwix/maps. The runner (`sockets/code_assets.exec.ts`) downloads the build assets with aria2c (resilient: `--continue` resume, survives a full interface loss via the outer retry loop) using the shared `downloadWithAria2` helper, then the mirror verifies each file against its published `.md5` and writes the browse page (`mirror_code_assets.py --finalize-only`), and the runner swaps the staged tree in atomically. The older `POST /code-assets/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)

‎static/dashboard/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "",
55
"main": "index.js",
66
"scripts": {
7-
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts sockets/rolling-log.test.ts sockets/kolibri.session.test.ts sockets/credentials.test.ts sockets/net-retry.test.ts sockets/services.test.ts sockets/log-rotate.test.ts",
7+
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts sockets/rolling-log.test.ts sockets/kolibri.session.test.ts sockets/credentials.test.ts sockets/net-retry.test.ts sockets/services.test.ts sockets/log-rotate.test.ts sockets/rebuild-status.test.ts",
88
"test:db": "node --require ts-node/register --test sockets/jobs.test.ts",
99
"typecheck": "tsc --noEmit",
1010
"build": "tsc",

‎static/dashboard/routes.ts‎

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from './sockets/credentials';
2525
import { isRestartableService, restartService } from './sockets/services';
2626
import { getFirehoseState } from './sockets/log-rotate';
27+
import { resolveRebuildState } from './sockets/rebuild-status';
2728

2829
// ADFA-4879: FQR helpers reached from the app (in-app region download/delete instead of the
2930
// copy-paste-into-a-terminal flow). tile-extract.py is installed on the box by the upstream maps
@@ -319,19 +320,34 @@ function clearRebuildState(status: 'idle' | 'error'): void {
319320
try { fs.unlinkSync(REBUILD_PID_FILE); } catch { /* maybe already gone */ }
320321
}
321322

322-
// Current rebuild state: idle | running | done | error (read from the status file the script writes).
323-
apiRouter.get('/system/dashboard/rebuild/status', (_req: Request, res: Response): void => {
324-
let state = 'idle';
325-
try { state = (fs.readFileSync(REBUILD_STATUS_FILE, 'utf8').trim() || 'idle'); } catch { /* no file yet */ }
326-
// K2GO-451: a rebuild whose script was killed mid-run (box restart / power loss) cannot write a
327-
// terminal status, so this file stays 'running' with a dead pid and nothing clears it. Heal it to
328-
// 'error' on read, so the app sees a normal failure instead of polling a phantom forever (and a new
329-
// rebuild is no longer refused with 409). A 'running' with no pid yet is a just-started run: leave it.
330-
if (state === 'running' && rebuildPidLiveness().state === 'dead') {
331-
clearRebuildState('error');
332-
state = 'error';
323+
// K2GO-451: a live rebuild records its pid within ~1s of taking the lock, so a 'running' with no pid
324+
// recorded past this grace means the script died before (or without) writing one (or never started).
325+
const REBUILD_PID_GRACE_MS = 15_000;
326+
327+
// K2GO-451: read the status file and self-heal a 'running' that no live rebuild backs (the script was
328+
// killed mid-run by a box restart / power loss, so it never wrote a terminal status). Shared by the
329+
// status endpoint and the new-rebuild guard, so a crashed run neither reports a phantom 'running' nor
330+
// blocks a fresh rebuild with 409. The decision is the pure resolveRebuildState; this only does the IO.
331+
function readRebuildStatus(): string {
332+
let fileState = 'idle';
333+
try { fileState = fs.readFileSync(REBUILD_STATUS_FILE, 'utf8').trim() || 'idle'; } catch { return 'idle'; }
334+
if (fileState !== 'running') return fileState;
335+
const live = rebuildPidLiveness();
336+
let ageMs = 0;
337+
if (live.state === 'none') {
338+
// Age the 'running' from the status file's mtime (written at run start); only needed to tell a
339+
// just-started run (no pid yet) from one whose script died before recording a pid.
340+
try { ageMs = Date.now() - fs.statSync(REBUILD_STATUS_FILE).mtimeMs; } catch { ageMs = 0; }
333341
}
334-
res.json({ state });
342+
const r = resolveRebuildState(fileState, live.state, ageMs, REBUILD_PID_GRACE_MS);
343+
if (r.heal) clearRebuildState('error');
344+
return r.state;
345+
}
346+
347+
// Current rebuild state: idle | running | done | error (read from the status file the script writes;
348+
// a stale 'running' left by a crashed run is healed to 'error' here).
349+
apiRouter.get('/system/dashboard/rebuild/status', (_req: Request, res: Response): void => {
350+
res.json({ state: readRebuildStatus() });
335351
});
336352

337353
// ADFA-5339: read-only tail of the rebuild log, for the card's expandable Details. Returns the last
@@ -637,9 +653,9 @@ apiRouter.post('/code-assets/refresh/cancel', (_req: Request, res: Response): vo
637653
// so it matches the new source. It never touches the reported version; a site failure is logged and
638654
// does NOT fail the (already-verified) core update. See ADFA-5339 §semantics.
639655
apiRouter.post('/system/dashboard/rebuild', (req: Request, res: Response): void => {
640-
let running = false;
641-
try { running = fs.readFileSync(REBUILD_STATUS_FILE, 'utf8').trim() === 'running'; } catch { /* none */ }
642-
if (running) { res.status(409).json({ error: 'a rebuild is already running' }); return; }
656+
// K2GO-451: readRebuildStatus heals a crashed run's stale 'running' first, so a dead rebuild does not
657+
// block a fresh one with a 409 the user cannot clear.
658+
if (readRebuildStatus() === 'running') { res.status(409).json({ error: 'a rebuild is already running' }); return; }
643659
if (!fs.existsSync(REBUILD_SCRIPT)) { res.status(500).json({ error: 'rebuild script not found' }); return; }
644660
const updateSite = (req.body as { site?: unknown })?.site === true;
645661
try {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/// <reference types="node" />
2+
import test from 'node:test';
3+
import assert from 'node:assert/strict';
4+
import { resolveRebuildState } from './rebuild-status';
5+
6+
const GRACE = 15_000;
7+
8+
// --- resolveRebuildState: the pure heal decision for the rebuild status (K2GO-451) -------------
9+
10+
test('resolveRebuildState: non-running states pass through untouched, no heal', () => {
11+
for (const s of ['idle', 'done', 'error']) {
12+
assert.deepEqual(resolveRebuildState(s, 'none', 0, GRACE), { state: s, heal: false });
13+
// the pid liveness and age are irrelevant once the file is not 'running'
14+
assert.deepEqual(resolveRebuildState(s, 'dead', 1_000_000, GRACE), { state: s, heal: false });
15+
}
16+
});
17+
18+
test('resolveRebuildState: running with a dead recorded pid heals to error', () => {
19+
assert.deepEqual(resolveRebuildState('running', 'dead', 0, GRACE), { state: 'error', heal: true });
20+
});
21+
22+
test('resolveRebuildState: running with an alive pid stays running, no heal', () => {
23+
// a real rebuild, or a recycled unrelated pid: never healed on the read path
24+
assert.deepEqual(resolveRebuildState('running', 'alive', 1_000_000, GRACE), { state: 'running', heal: false });
25+
});
26+
27+
test('resolveRebuildState: running with no pid yet, within the grace, stays running', () => {
28+
// a just-started run that has not written its pid: must not be false-healed
29+
assert.deepEqual(resolveRebuildState('running', 'none', 0, GRACE), { state: 'running', heal: false });
30+
assert.deepEqual(resolveRebuildState('running', 'none', GRACE, GRACE), { state: 'running', heal: false }); // boundary: not > grace
31+
});
32+
33+
test('resolveRebuildState: running with no pid past the grace heals to error', () => {
34+
// the script died before (or without) recording a pid, or never started
35+
assert.deepEqual(resolveRebuildState('running', 'none', GRACE + 1, GRACE), { state: 'error', heal: true });
36+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// K2GO-451: the pure decision for what a reader of the dash-node rebuild status should report, and
2+
// whether it must heal a stale "running". Kept free of fs / process so it is unit-tested off-box; the
3+
// thin wrapper in routes.ts feeds it the status file's value, the recorded pid's liveness, and how long
4+
// "running" has been set. A "running" status is only trustworthy while a live rebuild backs it:
5+
// - pid "dead" : the script was killed (box restart / power loss) -> heal to "error".
6+
// - pid "none" past the grace : the script died before (or without) recording its pid -> heal to "error".
7+
// - pid "none" within the grace : a just started run that has not written its pid yet -> leave "running".
8+
// - pid "alive" : a real rebuild (or a recycled pid) -> leave "running".
9+
// Any non-"running" file value passes through untouched. Healing targets "error" (a crash is a failure,
10+
// and the app's poller treats "error" as terminal; "idle" would read as still running and loop).
11+
12+
export type PidState = 'none' | 'dead' | 'alive';
13+
14+
export interface RebuildResolution {
15+
/** The effective status a reader should report. */
16+
state: string;
17+
/** True when the caller must clear the stale run's state files (status -> "error", drop lock/phase/pid). */
18+
heal: boolean;
19+
}
20+
21+
export function resolveRebuildState(
22+
fileState: string,
23+
pidState: PidState,
24+
runningAgeMs: number,
25+
graceMs: number,
26+
): RebuildResolution {
27+
if (fileState !== 'running') {
28+
return { state: fileState, heal: false };
29+
}
30+
if (pidState === 'dead') {
31+
return { state: 'error', heal: true };
32+
}
33+
if (pidState === 'none' && runningAgeMs > graceMs) {
34+
return { state: 'error', heal: true };
35+
}
36+
return { state: 'running', heal: false };
37+
}

0 commit comments

Comments
 (0)