Archilyzer · Source

archilyzer

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

commit b8b2cab4b370f637ac99c9af0a9820a1712fb4f4
parent 367a9af95c46af326b2e8635bce0ad7c400f2d36
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 09:45:48 -0400

publish: deploy all skips only private and project-less sites, fails on every other refusal; an interrupted install is restored first; built.json goes before the swap; a second signal kills the child groups (review MEDIUM 3 + LOWs)

publish deploy all: a private site and (to Pages) a site with no project are
passed over with one line; never built, a bundle problem, a non-main build or
builtAfter fail the run (exit 1) after the rest are tried. A present out.prev
with no out is renamed back before a build or install touches the target.
The old built.json is removed before the install and the new one written last.
A second Ctrl-C / SIGTERM (CLI and stage child) SIGKILLs the detached groups
before exiting. The stage row's usage names --allow-missing-media.

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

Diffstat:
Mcommon/bin/archilyzer.ts | 2+-
Mcommon/bin/publish.ts | 37+++++++++++++++++++++++++++++--------
Mcommon/jobs/runChild.test.ts | 30+++++++++++++++++++++++++++++-
Mcommon/jobs/runChild.ts | 22++++++++++++++++++++++
Mcommon/publish/build.ts | 32+++++++++++++++++++++++++++++++-
Mcommon/publish/bundle.test.ts | 37+++++++++++++++++++++++++++++++++++++
Mcommon/publish/stageRun.test.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/stageRun.ts | 12+++++++++---
8 files changed, 200 insertions(+), 14 deletions(-)

diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -161,7 +161,7 @@ export const COMMANDS: Command[] = [ { path: ["stage"], usage: - "<kind> <target> --run-id <id> [--preview <b>] [--to local] [--runner docker] [--force] [--skip-archives] [--index-after <ms>] [--built-after <ms>] INTERNAL: one publish stage, as the editor's job runs it (exit 0 ran/no-op, 1 failed, 2 usage, 3 precondition not met, 130 cancelled)", + "<kind> <target> --run-id <id> [--preview <b>] [--to local] [--runner docker] [--force] [--skip-archives] [--allow-missing-media] [--index-after <ms>] [--built-after <ms>] INTERNAL: one publish stage, as the editor's job runs it (exit 0 ran/no-op, 1 failed, 2 usage, 3 precondition not met, 130 cancelled)", // publish/stages.ts STAGE_FLAGS, spelled out so this table stays free of // imports (_cli.test.ts holds the two equal). flags: { diff --git a/common/bin/publish.ts b/common/bin/publish.ts @@ -15,8 +15,9 @@ // index and stats builds want its 8 GB heap); the rest run in this process. // `publish status` and `publish now` are release 18 S3's (publishState.ts). -import { runChildIntoLog, setKillChildTrees } from "../jobs/runChild"; +import { killChildTreesNow, runChildIntoLog, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; +import { siteDeployProblem } from "../lib/builtExport"; import { listSites, listSiteIds } from "../lib/site"; import { newStampId } from "../publish/stamps"; import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun"; @@ -34,11 +35,22 @@ export function cliRunId(): string { return `cli-${newStampId()}`; } -// Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does. +// Ctrl-C / SIGTERM cancel the stage in flight, as the editor's Cancel does. A +// SECOND one does not wait for the unwind: it kills the detached process +// groups this process started (`next build`'s, wrangler's) and exits 130 — +// never leaving an orphan builder writing export/out. Idempotent: one handler. function interrupted(): AbortSignal { const ac = new AbortController(); - process.once("SIGINT", () => ac.abort()); - process.once("SIGTERM", () => ac.abort()); + const onSignal = () => { + if (!ac.signal.aborted) { + ac.abort(); + return; + } + killChildTreesNow("SIGKILL"); + process.exit(STAGE_EXIT.cancelled); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); return ac.signal; } @@ -149,7 +161,18 @@ export async function publishDeploy(a: DeployArgs, out: Out = console): Promise< if (!all && !knownSite(a.target, "publish deploy", out, a.paths)) return STAGE_EXIT.usage; const signal = a.signal ?? interrupted(); const runId = a.runId ?? cliRunId(); - const ids = all ? listSites(a.paths).map((s) => s.siteId) : [a.target]; + // `all` passes over — quietly, one line — only the sites that are never + // deployable by their configuration: a private site, and (to Pages) a site + // with no Pages project. Every other refusal (never built, a bundle + // problem, a build not of main, a build this run has not made) is a + // FAILURE: the rest are still tried, and the run exits 1. + const ids: string[] = []; + for (const site of all ? listSites(a.paths) : []) { + const never = siteDeployProblem(site) ?? (a.to === "local" || site.cloudflareProject?.trim() ? null : "no Cloudflare Pages project"); + if (never) out.log(`[publish] ${site.siteId}: skipped — ${never}`); + else ids.push(site.siteId); + } + if (!all) ids.push(a.target); let worst = 0; for (const id of ids) { if (signal.aborted) return STAGE_EXIT.cancelled; @@ -166,9 +189,7 @@ export async function publishDeploy(a: DeployArgs, out: Out = console): Promise< a.paths, ); if (code === STAGE_EXIT.cancelled) return code; - // `all` deploys every site it can; a site that cannot be deployed (private, - // no project, never built) is said and skipped, not a failure of the rest. - if (code !== 0 && !(all && code === STAGE_EXIT.precondition)) worst = worst || code; + if (code !== 0) worst = all ? STAGE_EXIT.failed : worst || code; } return worst; } diff --git a/common/jobs/runChild.test.ts b/common/jobs/runChild.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { killsChildTrees, runChildIntoLog, setKillChildTrees } from "./runChild"; +import { killChildTreesNow, killsChildTrees, runChildIntoLog, setKillChildTrees } from "./runChild"; // Run with: // pnpm --filter yt-dlp-transcript-common test @@ -68,3 +68,31 @@ test("with tree-kill on, a cancel takes the grandchildren too", { skip: process. assert.notEqual(code, 0); assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`); }); + +test("killChildTreesNow (a second Ctrl-C) takes every live group down at once, no cancel needed", { skip: process.platform === "win32" }, async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "run-child-now-")); + const pidFile = path.join(dir, "gc.pid"); + setKillChildTrees(true); + try { + const running = runChildIntoLog(() => {}, new AbortController().signal, { + command: "sh", + args: ["-c", `sleep 30 & echo $! > '${pidFile}'; wait`], + cwd: dir, + }); + let grandchild = 0; + await waitFor(() => { + try { + grandchild = Number(readFileSync(pidFile, "utf8").trim()); + return grandchild > 0; + } catch { + return false; + } + }); + killChildTreesNow("SIGKILL"); + assert.notEqual(await running, 0); + assert.ok(await waitFor(() => !alive(grandchild)), `grandchild ${grandchild} survived`); + } finally { + setKillChildTrees(false); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/common/jobs/runChild.ts b/common/jobs/runChild.ts @@ -28,6 +28,26 @@ export function killsChildTrees(): boolean { return (globalThis as TreeKillGlobal)[TREE_KILL_KEY] === true; } +// The process groups this process leads right now (tree-kill mode), so a +// second Ctrl-C / SIGTERM — which exits at once, without waiting for the +// cancel to unwind — can still take them down first (`killChildTreesNow`). +const LIVE_GROUPS_KEY = Symbol.for("archilyzer.runChild.liveGroups"); +function liveGroups(): Set<number> { + const g = globalThis as { [LIVE_GROUPS_KEY]?: Set<number> }; + return (g[LIVE_GROUPS_KEY] ??= new Set()); +} + +/** Signal every live child process group this process started (tree-kill mode). */ +export function killChildTreesNow(sig: NodeJS.Signals = "SIGKILL"): void { + for (const pgid of liveGroups()) { + try { + process.kill(-pgid, sig); + } catch { + // Already gone. + } + } +} + // Spawn a child process and stream its combined stdout/stderr into `onLog`, // resolving with the exit code. Used inside a managed function's `fn` to run a // sub-command as part of a larger job (build-then-deploy, multi-phase builds) @@ -52,6 +72,7 @@ export async function runChildIntoLog( reject: false, ...(tree ? { detached: true } : {}), }); + if (tree && child.pid) liveGroups().add(child.pid); // Signal the child — or, with tree-kill, its whole process group. const signalChild = (sig: NodeJS.Signals) => { if (tree && child.pid) { @@ -108,6 +129,7 @@ export async function runChildIntoLog( if (killTimer) clearTimeout(killTimer); // The leader is gone; whatever of its group a cancel left is not wanted. if (tree && signal.aborted) signalChild("SIGKILL"); + if (tree && child.pid) liveGroups().delete(child.pid); signal.removeEventListener("abort", onAbort); } } diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -34,6 +34,7 @@ import { import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { getSite, listSites, type Site } from "../lib/site"; +import { builtStampPath } from "./stamps"; // Where the basic (host) build writes the static bundle to deploy: the fixed // export/out, composed one site at a time. The docker fan-out writes per-site @@ -1272,6 +1273,22 @@ async function moveDir(src: string, dest: string, fsOps: BundleFs): Promise<"ren } /** + * A crash between installBundle's two renames leaves `out.prev` and no `out`: + * the last bundle is whole, under the wrong name. Put it back. Asked first by + * every build and every install, before anything else touches the target. + * Answers whether it restored one. + */ +export async function recoverInterruptedInstall( + destOut: string, + fsOps: BundleFs = realBundleFs, +): Promise<boolean> { + const prev = `${destOut}.prev`; + if ((await lexists(destOut)) || !(await lexists(prev))) return false; + await fsOps.rename(prev, destOut); + return true; +} + +/** * Install the build at `src` as the bundle `destOut` (see the section header): * `src` is gone afterwards and `destOut` holds it. Answers how it moved. */ @@ -1282,6 +1299,7 @@ export async function installBundle( ): Promise<"rename" | "copy"> { const next = `${destOut}.next`; const prev = `${destOut}.prev`; + await recoverInterruptedInstall(destOut, fsOps); await fsOps.mkdir(path.dirname(destOut), { recursive: true }); await fsOps.rm(next, { recursive: true, force: true }); const how = await moveDir(src, next, fsOps); @@ -1384,7 +1402,12 @@ export async function buildSiteBundle( opts: PublishOpts & { skipArchives?: boolean; allowMissingMedia?: boolean; inPlace?: boolean } = {}, ): Promise<{ code: number; archivesStaged: number }> { const { paths, onLog, signal } = resolved(opts); - if (!opts.inPlace) await unlinkExportOut(paths); + if (!opts.inPlace) { + if (await recoverInterruptedInstall(bundleDir(paths, siteId))) { + onLog(`[build] ${siteId}: restored the bundle an interrupted install left as out.prev\n`); + } + await unlinkExportOut(paths); + } const code = await runBuildPhase(onLog, signal, siteId, paths, { skipData: true, skipArchives: opts.skipArchives, @@ -1398,6 +1421,9 @@ export async function buildSiteBundle( return { code: 1, archivesStaged: 0 }; } if (opts.inPlace) return { code: 0, archivesStaged: 0 }; + // The old stamp must never describe the new bundle: it goes first, and the + // stage writes the new one as its LAST step, once the bundle is in place. + await rm(builtStampPath(paths, siteId), { force: true }); const how = await installBundle(out, bundleDir(paths, siteId)); const archivesStaged = await stageSiteArchives(paths, siteId); await pointExportOutAt(paths, bundleDir(paths, siteId)); @@ -1415,6 +1441,9 @@ export async function buildSiteBundle( */ export async function buildHubBundle(opts: PublishOpts = {}): Promise<number> { const { paths, onLog, signal } = resolved(opts); + if (await recoverInterruptedInstall(bundleDir(paths, "_hub"))) { + onLog("[build] hub: restored the bundle an interrupted install left as out.prev\n"); + } await unlinkExportOut(paths); const code = await buildHub({ paths, onLog, signal }); if (code !== 0 || signal.aborted) return code || 1; @@ -1425,6 +1454,7 @@ export async function buildHubBundle(opts: PublishOpts = {}): Promise<number> { return 1; } const dest = bundleDir(paths, "_hub"); + await rm(builtStampPath(paths, "_hub"), { force: true }); const how = await installBundle(out, dest); await pointExportOutAt(paths, dest); onLog(`[build] hub: bundle installed at ${dest} (${how === "rename" ? "moved" : "copied across filesystems"})\n`); diff --git a/common/publish/bundle.test.ts b/common/publish/bundle.test.ts @@ -23,6 +23,7 @@ import { exportOutPath, installBundle, pointExportOutAt, + recoverInterruptedInstall, stageSiteArchives, unlinkExportOut, type BundleFs, @@ -218,3 +219,39 @@ test("bundleCounts and corpusGeneratedAtIn read the bundle; links are not follow rmSync(root, { recursive: true, force: true }); } }); + +test("a crash between the two renames leaves out.prev and no out; the next build or install restores it first", async () => { + const { root, paths } = tmp(); + try { + const dest = bundleDir(paths, "jer"); + writeBuild(dest, "old"); + const out = exportOutPath(paths); + writeBuild(out, "new"); + // The process dies on the second rename (out.next -> out). + const dying: BundleFs = { + rename: (async (a: string, b: string) => { + if (String(a).endsWith("out.next") && String(b).endsWith(path.join("jer", "out"))) throw new Error("killed"); + return rename(a, b); + }) as typeof rename, + cp, + rm, + mkdir, + }; + await assert.rejects(installBundle(out, dest, dying), /killed/); + assert.ok(!existsSync(dest), "no out"); + assert.ok(existsSync(`${dest}.prev`), "the last bundle, under the wrong name"); + // Nothing to do when out is there; the old bundle back when it is not. + assert.equal(await recoverInterruptedInstall(dest), true); + assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "old"); + assert.equal(await recoverInterruptedInstall(dest), false); + // And an install over the same crash recovers before it swaps. + rmSync(`${dest}.next`, { recursive: true, force: true }); + await rename(dest, `${dest}.prev`); + writeBuild(out, "newer"); + await installBundle(out, dest); + assert.equal(readFileSync(path.join(dest, "index.html"), "utf8"), "newer"); + assert.ok(!existsSync(`${dest}.prev`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts @@ -262,3 +262,45 @@ test("stamps' commit/branch: ARCHILYZER_COMMIT / ARCHILYZER_BRANCH win over git, branch: "main", }); }); + +test("publish deploy all: only a private site and a site with no Pages project are skipped; any other refusal fails the run (exit 1) after the rest are tried", async () => { + const { publishDeploy } = await import("../bin/publish"); + writeSite("mine", { audience: "private", cloudflareProject: "w3c-never-real" }); + writeSite("noproj"); + writeSite("unbuilt", { cloudflareProject: "w3c-never-real" }); + const siteOut = path.join(ROOT, "builds", "site-all"); + process.env.ARCHILYZER_SITE_OUT = siteOut; + const built = (await stamps.readBuiltStamp(paths, "jer"))!; + await stamps.writeBuiltStamp(paths, { ...built, branch: "main" }); + try { + const lines: string[] = []; + const out = { log: (l: string) => lines.push(l), error: (l: string) => lines.push(l) }; + // Pages: private and no-project are skipped quietly; jer and unbuilt are + // TRIED (jer: never deployed there, w3c-never-real is no real project — + // its deploy is refused at wrangler or before; unbuilt: exit 3). + const local = await publishDeploy({ target: "all", to: "local", force: true, paths, signal: new AbortController().signal }, out); + assert.equal(local, 1, lines.join("\n")); + assert.ok(lines.includes("[publish] mine: skipped — " + 'Site "mine" is private (audience: private): it is built for reading on this machine and is never deployed. Build it without deploying, or set its audience to public on its Settings tab')); + assert.ok(!lines.some((l) => l.startsWith("[publish] noproj: skipped")), "--to local needs no project"); + assert.ok(existsSync(path.join(siteOut, "index.html")), "jer was still deployed"); + const local2 = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "unbuilt"), "local"); + assert.equal(local2, null, "unbuilt was refused"); + lines.length = 0; + // Only never-deployable sites skipped: an all of nothing but those is exit 0. + rmSync(path.join(PINNED.SITES_DIR, "unbuilt"), { recursive: true }); + rmSync(path.join(PINNED.SITES_DIR, "noproj"), { recursive: true }); + const onlyOk = await publishDeploy({ target: "all", to: "local", paths, signal: new AbortController().signal }, out); + assert.equal(onlyOk, 0, lines.join("\n")); + // To Pages: a site with no project is skipped too. (jer's build is taken + // away first, so its refusal comes before any wrangler.) + writeSite("noproj"); + rmSync(stamps.builtStampPath(paths, "jer")); + lines.length = 0; + const pages = await publishDeploy({ target: "all", preview: "r18", paths, signal: new AbortController().signal }, out); + assert.equal(pages, 1, "jer, never built, is a failure"); + assert.ok(lines.includes("[publish] noproj: skipped — no Cloudflare Pages project"), lines.join("\n")); + } finally { + delete process.env.ARCHILYZER_SITE_OUT; + for (const id of ["mine", "noproj", "unbuilt"]) rmSync(path.join(PINNED.SITES_DIR, id), { recursive: true, force: true }); + } +}); diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts @@ -15,7 +15,7 @@ // Either way the stage takes `<exportBuildsDir>/.publish.lock` first. import path from "node:path"; -import { setKillChildTrees } from "../jobs/runChild"; +import { killChildTreesNow, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; import { StageCancelled, StageFailure } from "./stageBodies"; import { LockWaitCancelled, acquirePublishLock, type LockEnv } from "./stageLock"; @@ -141,10 +141,16 @@ const CANCEL_GRACE_MS = 15_000; */ export async function stageMain(req: StageRequest, opts: { paths?: Paths } = {}): Promise<number> { const ac = new AbortController(); + // Exiting without the unwind (a second signal, or the grace run out) takes + // the detached process groups down first: no orphan `next build`. + const exitNow = () => { + killChildTreesNow("SIGKILL"); + process.exit(STAGE_EXIT.cancelled); + }; const onSignal = () => { - if (ac.signal.aborted) process.exit(STAGE_EXIT.cancelled); + if (ac.signal.aborted) exitNow(); ac.abort(); - setTimeout(() => process.exit(STAGE_EXIT.cancelled), CANCEL_GRACE_MS).unref(); + setTimeout(exitNow, CANCEL_GRACE_MS).unref(); }; process.on("SIGTERM", onSignal); process.on("SIGINT", onSignal);