From 34e2b7386f1b30c45a03bd1e67da3009f118055c Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Mon, 19 Oct 2026 18:37:06 +0800 Subject: [PATCH 1/5] test: trace Linux archive phases and compare coordination baselines --- .github/workflows/pr-check.yml | 27 +++ ...-search-pagination.full-regression.test.ts | 175 ++++++++++-------- scripts/diagnose-linux-workflows.mjs | 106 +++++++++++ .../source-ingestion.full-regression.test.ts | 40 ++-- test/helpers/regression-phase.ts | 17 ++ 5 files changed, 268 insertions(+), 97 deletions(-) create mode 100644 scripts/diagnose-linux-workflows.mjs create mode 100644 test/helpers/regression-phase.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index f939dc99..68432373 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: inputs: + linuxComparison: + description: Compare Linux workflow phases before and after Archive coordination changes + type: boolean + default: false nativeDiagnostics: description: Run native SQLite A/B experiments (known-bad controls intentionally fail) type: boolean @@ -26,6 +30,29 @@ permissions: contents: read jobs: + linux-comparison: + name: Linux archive workflow comparison (diagnostic only) + if: github.event_name == 'workflow_dispatch' && inputs.linuxComparison + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node scripts/diagnose-linux-workflows.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: linux-comparison + path: test/.reports/linux-comparison + if-no-files-found: error + packed-sqlite-consumer: name: Windows packed SDK Node ${{ matrix.node }} and Electron runs-on: windows-latest diff --git a/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts b/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts index c922d55c..20c75da3 100644 --- a/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts +++ b/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts @@ -14,6 +14,7 @@ import { } from "wiki-graph-core"; import { createWikiGraphSDK, NodeFile, type WikiGraphSDK } from "../index.js"; +import { regressionPhase } from "../../../../test/helpers/regression-phase.js"; const temporaryDirectories: string[] = []; @@ -32,7 +33,9 @@ describe("WikiGraphLibraryManager search pagination", () => { it.each(["standalone", "library"] as const)( "isolates a stalled wikipage from %s search, reads and writes", async (scope) => { - const setup = await createIndexedLibrary(false, true); + const setup = await regressionPhase(`${scope}:fixture`, () => + createIndexedLibrary(false, true), + ); const controller = new AbortController(); let pending: Promise | undefined; try { @@ -70,7 +73,9 @@ describe("WikiGraphLibraryManager search pagination", () => { ); pending = page("wikg://entity/Q1/wikipage", controller.signal); const settled = pending.catch((error: unknown) => error); - await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + await regressionPhase(`${scope}:remote-start`, async () => { + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + }); // These must finish while the first request's stream is still stalled. const results = scope === "standalone" @@ -381,93 +386,103 @@ async function createIndexedLibrary( stateDir: join(root, "state"), }); const archivePath = join(root, "source.wikg"); - await sdk.archives.create({ path: archivePath }); + await regressionPhase("fixture:create", () => + sdk.archives.create({ path: archivePath }), + ); const archive = await sdk.archives.open({ kind: "standalone", path: archivePath, }); - const chapter = await archive.addChapter({ - source: Array.from( - { length: 60 }, - (_, index) => - `Needle record ${index} remains independently searchable.${interleaved ? " Unrelated context." : ""}`, - ).join(" "), - title: "Pagination source", - }); - await sdk.run(async () => { - await new WikiGraphArchiveFile(new NodeFile(archivePath)).write( - async (document) => { - await document.openSession(async (opened) => { - if (withChunk) - await opened.chunks.save({ - content: "Needle reading chunk", - generation: 0, - id: 10, - label: "Needle chunk", - sentenceId: [chapter.chapterId, 0], - sentenceIds: [[chapter.chapterId, 0]], - weight: 1, - wordsCount: 3, + const chapter = await regressionPhase("fixture:add-chapter", () => + archive.addChapter({ + source: Array.from( + { length: 60 }, + (_, index) => + `Needle record ${index} remains independently searchable.${interleaved ? " Unrelated context." : ""}`, + ).join(" "), + title: "Pagination source", + }), + ); + await regressionPhase("fixture:seed-indexes", () => + sdk.run(async () => { + await new WikiGraphArchiveFile(new NodeFile(archivePath)).write( + async (document) => { + await document.openSession(async (opened) => { + if (withChunk) + await opened.chunks.save({ + content: "Needle reading chunk", + generation: 0, + id: 10, + label: "Needle chunk", + sentenceId: [chapter.chapterId, 0], + sentenceIds: [[chapter.chapterId, 0]], + weight: 1, + wordsCount: 3, + }); + await opened.writeSummary( + chapter.chapterId, + "Needle summary sentence.", + ); + await opened.mentions.saveMany( + [0, 20].map((sentenceIndex) => ({ + chapterId: chapter.chapterId, + id: `mention-${sentenceIndex}`, + qid: "Q1", + rangeStart: 0, + rangeEnd: 6, + sentenceIndex, + surface: "Needle Example", + })), + ); + await opened.mentions.saveMany( + [0, 20].map((sentenceIndex) => ({ + chapterId: chapter.chapterId, + id: `target-${sentenceIndex}`, + qid: "Q2", + rangeStart: 7, + rangeEnd: 13, + sentenceIndex, + surface: "Target", + })), + ); + await opened.mentionLinks.save({ + id: "relation", + sourceMentionId: "mention-0", + targetMentionId: "target-0", + predicate: "relates", + note: "Needle relation", + evidenceSentenceIds: [ + [chapter.chapterId, 0], + [chapter.chapterId, 20], + ], }); - await opened.writeSummary( + }); + await replaceChapterFtsIndexArtifact(document, chapter.chapterId); + await replaceChapterSourceEmbeddingIndexArtifact( + document, chapter.chapterId, - "Needle summary sentence.", - ); - await opened.mentions.saveMany( - [0, 20].map((sentenceIndex) => ({ - chapterId: chapter.chapterId, - id: `mention-${sentenceIndex}`, - qid: "Q1", - rangeStart: 0, - rangeEnd: 6, - sentenceIndex, - surface: "Needle Example", - })), + provider, ); - await opened.mentions.saveMany( - [0, 20].map((sentenceIndex) => ({ - chapterId: chapter.chapterId, - id: `target-${sentenceIndex}`, - qid: "Q2", - rangeStart: 7, - rangeEnd: 13, - sentenceIndex, - surface: "Target", - })), + await replaceChapterSummaryEmbeddingIndexArtifact( + document, + chapter.chapterId, + provider, ); - await opened.mentionLinks.save({ - id: "relation", - sourceMentionId: "mention-0", - targetMentionId: "target-0", - predicate: "relates", - note: "Needle relation", - evidenceSentenceIds: [ - [chapter.chapterId, 0], - [chapter.chapterId, 20], - ], - }); - }); - await replaceChapterFtsIndexArtifact(document, chapter.chapterId); - await replaceChapterSourceEmbeddingIndexArtifact( - document, - chapter.chapterId, - provider, - ); - await replaceChapterSummaryEmbeddingIndexArtifact( - document, - chapter.chapterId, - provider, - ); - }, - ); - }); + }, + ); + }), + ); const library = await sdk.libraries.create(join(root, "library")); - const member = await sdk.libraries.addArchive({ - inputPath: archivePath, - target: library.uri, - to: "source.wikg", - }); - await sdk.libraries.rebuildIndex(library.uri); + const member = await regressionPhase("fixture:add-to-library", () => + sdk.libraries.addArchive({ + inputPath: archivePath, + target: library.uri, + to: "source.wikg", + }), + ); + await regressionPhase("fixture:rebuild-library", () => + sdk.libraries.rebuildIndex(library.uri), + ); return { archivePath, embeddingProbe, diff --git a/scripts/diagnose-linux-workflows.mjs b/scripts/diagnose-linux-workflows.mjs new file mode 100644 index 00000000..625f5aa8 --- /dev/null +++ b/scripts/diagnose-linux-workflows.mjs @@ -0,0 +1,106 @@ +// Compare identical test fixtures against pre-#784 and current product code. +// This is an opt-in investigation, never a substitute for product acceptance. +import { execFileSync, spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + writeFileSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +if (process.platform !== "linux") + throw new Error("Run this comparison on Linux"); +const repo = process.cwd(); +const root = mkdtempSync(join(tmpdir(), "wg-linux-comparison-")); +const report = resolve("test/.reports/linux-comparison"); +mkdirSync(report, { recursive: true }); +const fixtures = [ + "packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts", + "test/full-regression/cli/archive/source-ingestion.full-regression.test.ts", + "test/helpers/regression-phase.ts", +]; +const bindingRoot = join(root, "official"); +mkdirSync(bindingRoot); +execFileSync( + "npm", + [ + "install", + "--prefix", + bindingRoot, + "--no-audit", + "--no-fund", + "sqlite3@6.0.1", + ], + { stdio: "inherit" }, +); +const official = join(bindingRoot, "node_modules/sqlite3/lib/sqlite3.js"); +for (const variant of [ + "baseline-official", + "current-official", + "current-product", +]) { + const checkout = join(root, variant); + mkdirSync(checkout); + const archive = join(root, `${variant}.tar`); + execFileSync("git", [ + "archive", + "--format=tar", + `--output=${archive}`, + variant.startsWith("baseline") + ? "5ca523842b0aea8ef11fe7fed0308e593427baa5" + : "HEAD", + ]); + execFileSync("tar", ["-xf", archive, "-C", checkout]); + for (const fixture of fixtures) + copyFileSync(join(repo, fixture), join(checkout, fixture)); + for (const parent of [ + "", + "packages/sdk", + "packages/cli", + "packages/core", + "packages/job", + "packages/wikimedia", + ]) + symlinkSync( + join(repo, parent, "node_modules"), + join(checkout, parent, "node_modules"), + "dir", + ); + writeFileSync( + join(checkout, "diagnostic.config.ts"), + ` +import base from './vitest.config.ts'; +export default { ...base, resolve: { ...base.resolve, alias: [ +${variant.endsWith("official") ? `{find: /^sqlite3(?:\\/lib\\/sqlite3\\.js)?$/, replacement: ${JSON.stringify(official)}},` : ""} +...base.resolve.alias ] } }; +`, + ); + const started = Date.now(); + const result = spawnSync( + process.execPath, + [ + join(repo, "node_modules/vitest/vitest.mjs"), + "run", + "--config", + "diagnostic.config.ts", + ...fixtures.slice(0, 2), + "--reporter=verbose", + ], + { + cwd: checkout, + encoding: "utf8", + env: { ...process.env, WIKIGRAPH_REGRESSION_TRACE: "1" }, + timeout: 10 * 60_000, + maxBuffer: 20 * 1024 * 1024, + }, + ); + const log = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + writeFileSync(join(report, `${variant}.log`), log); + console.log( + `${variant}: exit=${result.status}, duration=${Date.now() - started}ms\n${log}`, + ); + // Known-failing controls must not suppress the remaining comparisons. +} diff --git a/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts b/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts index 9fe9d573..6b8ad5a6 100644 --- a/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts +++ b/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts @@ -13,6 +13,7 @@ import { runWikiGraphCLICaptured } from "../../../../packages/cli/src/index.js"; import { NodeFile } from "../../../../packages/cli/src/runtime/node-platform.js"; import { withTempDir } from "../../../helpers/temp.js"; +import { regressionPhase } from "../../../helpers/regression-phase.js"; const NEWTON_JSONL_PATH = resolve("test/fixtures/sources/newton.pdf.jsonl"); const LITTLE_PRINCE_EPUB_PATH = resolve( @@ -495,11 +496,13 @@ async function runJsonCLI( argv: readonly string[], stdin?: string, ): Promise> { - const result = await runWikiGraphCLICaptured({ - argv, - stateDir, - ...(stdin === undefined ? {} : { stdin, stdinIsTTY: false }), - }); + const result = await regressionPhase(`cli:${argv.join(" ")}`, () => + runWikiGraphCLICaptured({ + argv, + stateDir, + ...(stdin === undefined ? {} : { stdin, stdinIsTTY: false }), + }), + ); expect(result.exitCode, result.stderr).toBe(0); expect(result.stderr).toBe(""); @@ -513,18 +516,21 @@ async function readSingleSerial(archivePath: string): Promise { } async function readSerials(archivePath: string): Promise { - return await new WikiGraphArchiveFile(new NodeFile(archivePath)).readDocument( - async (document) => { - const toc = await document.readToc(); - const serialIds = collectSerialIds(toc?.items ?? []); - return await Promise.all( - serialIds.map(async (serialId) => ({ - maps: await document.sourceProvenance.listMap(serialId), - serialId, - text: (await document.getSerialFragments(serialId).readText()) ?? "", - })), - ); - }, + return await regressionPhase("cli:read-serials", () => + new WikiGraphArchiveFile(new NodeFile(archivePath)).readDocument( + async (document) => { + const toc = await document.readToc(); + const serialIds = collectSerialIds(toc?.items ?? []); + return await Promise.all( + serialIds.map(async (serialId) => ({ + maps: await document.sourceProvenance.listMap(serialId), + serialId, + text: + (await document.getSerialFragments(serialId).readText()) ?? "", + })), + ); + }, + ), ); } diff --git a/test/helpers/regression-phase.ts b/test/helpers/regression-phase.ts new file mode 100644 index 00000000..1af228cd --- /dev/null +++ b/test/helpers/regression-phase.ts @@ -0,0 +1,17 @@ +/** Opt-in wall-clock evidence for slow durable filesystem workflows. */ +export async function regressionPhase( + name: string, + operation: () => Promise, +): Promise { + const started = performance.now(); + const trace = process.env.WIKIGRAPH_REGRESSION_TRACE === "1"; + if (trace) console.log(`[regression-phase] start ${name}`); + try { + return await operation(); + } finally { + if (trace) + console.log( + `[regression-phase] end ${name} ${Math.round(performance.now() - started)}ms`, + ); + } +} From b8f1f63a28fa167a3935a1d8a05cc2959bbddbda Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Mon, 19 Oct 2026 18:43:49 +0800 Subject: [PATCH 2/5] test: drain timed-out workflows before cleanup and gate Linux regressions --- .github/workflows/full-regression.yml | 36 +- .github/workflows/pr-check.yml | 20 + ...-search-pagination.full-regression.test.ts | 419 +++++---- scripts/check-linux-regressions.mjs | 73 ++ scripts/diagnose-linux-workflows.mjs | 1 + .../regression-lifecycle/timeout.fixture.ts | 35 + .../regression-lifecycle/vitest.config.ts | 9 + .../source-ingestion.full-regression.test.ts | 881 +++++++++--------- test/helpers/regression-lifecycle.test.ts | 104 +++ test/helpers/regression-lifecycle.ts | 76 ++ test/helpers/regression-phase.ts | 7 +- test/release/linux-regression-gate.test.ts | 75 ++ tsconfig.json | 3 +- vitest.linux-workflows.config.ts | 14 + 14 files changed, 1100 insertions(+), 653 deletions(-) create mode 100644 scripts/check-linux-regressions.mjs create mode 100644 test/fixtures/regression-lifecycle/timeout.fixture.ts create mode 100644 test/fixtures/regression-lifecycle/vitest.config.ts create mode 100644 test/helpers/regression-lifecycle.test.ts create mode 100644 test/helpers/regression-lifecycle.ts create mode 100644 test/release/linux-regression-gate.test.ts create mode 100644 vitest.linux-workflows.config.ts diff --git a/.github/workflows/full-regression.yml b/.github/workflows/full-regression.yml index 59eff8bc..627eb300 100644 --- a/.github/workflows/full-regression.yml +++ b/.github/workflows/full-regression.yml @@ -13,45 +13,11 @@ permissions: contents: read jobs: - check-main-update: - name: Check main update - runs-on: ubuntu-latest - outputs: - should_run: ${{ steps.decision.outputs.should_run }} - steps: - - uses: actions/checkout@v5 - with: - ref: main - fetch-depth: 1 - - - id: decision - name: Run only when main changed today - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - main_date="$(TZ=Asia/Shanghai git show -s --format=%cd --date=format:%F HEAD)" - today="$(TZ=Asia/Shanghai date +%F)" - echo "main commit date: ${main_date}" - echo "today: ${today}" - if [ "$main_date" = "$today" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - else - echo "should_run=false" >> "$GITHUB_OUTPUT" - fi - tests: name: Full regression tests - needs: check-main-update - if: needs.check-main-update.outputs.should_run == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - ref: main - uses: pnpm/action-setup@v4 @@ -65,6 +31,8 @@ jobs: - name: Run full test suite run: pnpm test:full-regression + env: + WIKIGRAPH_REGRESSION_TRACE: "1" - name: Build packages run: pnpm build diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 68432373..c75787ac 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -30,6 +30,26 @@ permissions: contents: read jobs: + linux-workflows: + name: Linux archive workflow regressions + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: node scripts/check-linux-regressions.mjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: linux-workflows + path: test/.reports/linux-workflows.json + if-no-files-found: error + linux-comparison: name: Linux archive workflow comparison (diagnostic only) if: github.event_name == 'workflow_dispatch' && inputs.linuxComparison diff --git a/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts b/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts index 20c75da3..de349e2c 100644 --- a/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts +++ b/packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { replaceChapterFtsIndexArtifact, replaceChapterSourceEmbeddingIndexArtifact, @@ -15,24 +15,15 @@ import { import { createWikiGraphSDK, NodeFile, type WikiGraphSDK } from "../index.js"; import { regressionPhase } from "../../../../test/helpers/regression-phase.js"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map( - async (directory) => - await rm(directory, { force: true, recursive: true }), - ), - ); -}); +import { + regressionLifecycle, + regressionTest, +} from "../../../../test/helpers/regression-lifecycle.js"; describe("WikiGraphLibraryManager search pagination", () => { it.each(["standalone", "library"] as const)( "isolates a stalled wikipage from %s search, reads and writes", - async (scope) => { + regressionTest(async (scope: "standalone" | "library") => { const setup = await regressionPhase(`${scope}:fixture`, () => createIndexedLibrary(false, true), ); @@ -48,7 +39,9 @@ describe("WikiGraphLibraryManager search pagination", () => { ? { kind: "standalone" as const, path: setup.archivePath } : { kind: "library" as const, uri: setup.memberUri }), }); - await archive.ensureSearchIndex(); + await regressionPhase(`${scope}:ensure-index`, () => + archive.ensureSearchIndex(), + ); const cancel = vi.fn(() => new Promise(() => undefined)); const fetcher = vi .fn() @@ -71,30 +64,46 @@ describe("WikiGraphLibraryManager search pagination", () => { id, signal === undefined ? {} : { signal }, ); - pending = page("wikg://entity/Q1/wikipage", controller.signal); - const settled = pending.catch((error: unknown) => error); + pending = page( + "wikg://entity/Q1/wikipage", + AbortSignal.any([controller.signal, regressionLifecycle().signal]), + ); + let remoteSettled = false; + const settled = pending + .catch((error: unknown) => error) + .finally(() => { + remoteSettled = true; + }); await regressionPhase(`${scope}:remote-start`, async () => { await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); }); // These must finish while the first request's stream is still stalled. - const results = - scope === "standalone" - ? await archive.search("needle", { - queryMode: "fts", - sourceContext: 0, - limit: 1000, - }) - : await setup.sdk.libraries.search(setup.libraryUri, "needle", { - queryMode: "fts", - sourceContext: 0, - limit: 1000, - }); + expect(remoteSettled).toBe(false); + const results = await regressionPhase( + `${scope}:search-while-stalled`, + async () => + scope === "standalone" + ? await archive.search("needle", { + queryMode: "fts", + sourceContext: 0, + limit: 1000, + }) + : await setup.sdk.libraries.search(setup.libraryUri, "needle", { + queryMode: "fts", + sourceContext: 0, + limit: 1000, + }), + ); expect(results.items.length).toBeGreaterThan(0); - const localResults = await archive.search("needle", { - queryMode: "fts", - sourceContext: 0, - limit: 1000, - }); + const localResults = await regressionPhase( + `${scope}:local-search`, + () => + archive.search("needle", { + queryMode: "fts", + sourceContext: 0, + limit: 1000, + }), + ); for (const type of ["entity", "triple", "source", "summary"] as const) { const hit = localResults.items.find((item) => item.type === type); expect(hit, type).toBeDefined(); @@ -102,8 +111,10 @@ describe("WikiGraphLibraryManager search pagination", () => { type === "source" || type === "summary" ? archive.page(hit!.id) : page(hit!.id); - await expect(localPage).resolves.toMatchObject({ - type: type === "source" || type === "summary" ? "fragment" : type, + await regressionPhase(`${scope}:read-${type}`, async () => { + await expect(localPage).resolves.toMatchObject({ + type: type === "source" || type === "summary" ? "fragment" : type, + }); }); } await expect(page("wikg://entity/Q2/wikipage")).resolves.toMatchObject({ @@ -120,13 +131,18 @@ describe("WikiGraphLibraryManager search pagination", () => { "wikg://entity/Q1", ); expect(evidence.items.length).toBeGreaterThan(0); - if (scope === "library") - await setup.sdk.libraries.rebuildIndex(setup.libraryUri); - else - await archive.addChapter({ - source: "A later local write.", - title: "Still writable", - }); + await regressionPhase(`${scope}:write-while-stalled`, async () => { + if (scope === "library") + await setup.sdk.libraries.rebuildIndex(setup.libraryUri); + else + await archive.addChapter({ + source: "A later local write.", + title: "Still writable", + }); + }); + // A response/timeout that quietly released the first request would not + // prove isolation. It must remain pending through every local operation. + expect(remoteSettled).toBe(false); controller.abort(new Error("cancelled")); expect(await settled).toMatchObject({ message: "cancelled" }); expect(cancel).toHaveBeenCalledOnce(); @@ -139,169 +155,175 @@ describe("WikiGraphLibraryManager search pagination", () => { vi.unstubAllGlobals(); setup.sdk.close(); } - }, + }), 15_000, ); - it("keeps raw signals on SDK archive summaries and query evidence, including previews and next pages", async () => { - const setup = await createIndexedLibrary(true); - try { - const archive = await setup.sdk.archives.open({ - kind: "standalone", - path: setup.archivePath, - }); - await archive.ensureSearchIndex(); - setup.embeddingProbe.reset(); - const sourceFirst = await archive.search("needle", { - types: ["source"], - queryMode: "hybrid", - sourceContext: 0, - limit: 1, - }); - const sourceCursor = sourceFirst.nextCursor(); - expect(sourceCursor).not.toBeNull(); - const sourceNext = await sourceCursor!.next({ limit: 1 }); - for (const hit of [...sourceFirst.items, ...sourceNext.items]) { - expect(hit.ftsScore).toBeLessThan(0); - expect(hit.embeddingScore).toBeGreaterThan(0); - } - // Archive text continuations already query once per page; raw fields add no calls. - expect(setup.embeddingProbe.queryCount).toBe(2); - await sourceCursor!.release(); - const summary = await archive.search("needle", { - types: ["summary"], - queryMode: "hybrid", - sourceContext: 0, - }); - expect(summary.items[0]?.type).toBe("summary"); - expect(summary.items[0]?.ftsScore).toBeLessThan(0); - expect(summary.items[0]?.embeddingScore).toBeGreaterThan(0); - const summaryVector = [1, "Needle summary sentence.".length / 100]; - const queryVector = [1, "needle".length / 100]; - const cosine = - (summaryVector[0]! * queryVector[0]! + - summaryVector[1]! * queryVector[1]!) / - (Math.hypot(...summaryVector) * Math.hypot(...queryVector)); - expect(summary.items[0]?.embeddingScore).toBeCloseTo(cosine, 6); - for (const mode of ["fts", "hybrid"] as const) { - const first = await archive.evidence("wikg://entity/Q1", { - query: "needle", - queryMode: mode, + it( + "keeps raw signals on SDK archive summaries and query evidence, including previews and next pages", + regressionTest(async () => { + const setup = await createIndexedLibrary(true); + try { + const archive = await setup.sdk.archives.open({ + kind: "standalone", + path: setup.archivePath, + }); + await archive.ensureSearchIndex(); + setup.embeddingProbe.reset(); + const sourceFirst = await archive.search("needle", { + types: ["source"], + queryMode: "hybrid", sourceContext: 0, limit: 1, }); - expect(first.items[0]?.ftsScore).toBeLessThan(0); - if (mode === "hybrid") - expect(first.items[0]?.embeddingScore).toBeGreaterThan(0); - else expect(first.items[0]?.embeddingScore).toBeUndefined(); - const cursor = first.nextCursor(); - expect(cursor).not.toBeNull(); - const second = await cursor!.next(); - expect(second.items[0]?.ftsScore).toBeLessThan(0); - await cursor!.release(); - } - const search = await archive.search("needle", { - queryMode: "hybrid", - sourceContext: 0, - evidenceLimit: 2, - }); - const entity = search.items.find( - (hit) => hit.type === "entity" && hit.id.endsWith("/Q1"), - ); - expect(entity).toBeDefined(); - expect(entity?.ftsScore).toBeUndefined(); - expect(entity?.embeddingScore).toBeUndefined(); - expect(entity?.evidence?.sources[0]?.ftsScore).toBeLessThan(0); - expect(entity?.evidence?.sources[0]?.embeddingScore).toBeGreaterThan(0); - const lexical = await archive.search("needle", { - types: ["entity", "source", "triple"], - queryMode: "fts", - sourceContext: 0, - evidenceLimit: 2, - }); - for (const type of ["entity", "triple"] as const) { - const hit = lexical.items.find((hit) => hit.type === type); - expect(hit?.evidence?.sources[0]?.ftsScore).toBeLessThan(0); - expect(hit?.evidence?.sources[0]?.embeddingScore).toBeUndefined(); + const sourceCursor = sourceFirst.nextCursor(); + expect(sourceCursor).not.toBeNull(); + const sourceNext = await sourceCursor!.next({ limit: 1 }); + for (const hit of [...sourceFirst.items, ...sourceNext.items]) { + expect(hit.ftsScore).toBeLessThan(0); + expect(hit.embeddingScore).toBeGreaterThan(0); + } + // Archive text continuations already query once per page; raw fields add no calls. + expect(setup.embeddingProbe.queryCount).toBe(2); + await sourceCursor!.release(); + const summary = await archive.search("needle", { + types: ["summary"], + queryMode: "hybrid", + sourceContext: 0, + }); + expect(summary.items[0]?.type).toBe("summary"); + expect(summary.items[0]?.ftsScore).toBeLessThan(0); + expect(summary.items[0]?.embeddingScore).toBeGreaterThan(0); + const summaryVector = [1, "Needle summary sentence.".length / 100]; + const queryVector = [1, "needle".length / 100]; + const cosine = + (summaryVector[0]! * queryVector[0]! + + summaryVector[1]! * queryVector[1]!) / + (Math.hypot(...summaryVector) * Math.hypot(...queryVector)); + expect(summary.items[0]?.embeddingScore).toBeCloseTo(cosine, 6); + for (const mode of ["fts", "hybrid"] as const) { + const first = await archive.evidence("wikg://entity/Q1", { + query: "needle", + queryMode: mode, + sourceContext: 0, + limit: 1, + }); + expect(first.items[0]?.ftsScore).toBeLessThan(0); + if (mode === "hybrid") + expect(first.items[0]?.embeddingScore).toBeGreaterThan(0); + else expect(first.items[0]?.embeddingScore).toBeUndefined(); + const cursor = first.nextCursor(); + expect(cursor).not.toBeNull(); + const second = await cursor!.next(); + expect(second.items[0]?.ftsScore).toBeLessThan(0); + await cursor!.release(); + } + const search = await archive.search("needle", { + queryMode: "hybrid", + sourceContext: 0, + evidenceLimit: 2, + }); + const entity = search.items.find( + (hit) => hit.type === "entity" && hit.id.endsWith("/Q1"), + ); + expect(entity).toBeDefined(); + expect(entity?.ftsScore).toBeUndefined(); + expect(entity?.embeddingScore).toBeUndefined(); + expect(entity?.evidence?.sources[0]?.ftsScore).toBeLessThan(0); + expect(entity?.evidence?.sources[0]?.embeddingScore).toBeGreaterThan(0); + const lexical = await archive.search("needle", { + types: ["entity", "source", "triple"], + queryMode: "fts", + sourceContext: 0, + evidenceLimit: 2, + }); + for (const type of ["entity", "triple"] as const) { + const hit = lexical.items.find((hit) => hit.type === type); + expect(hit?.evidence?.sources[0]?.ftsScore).toBeLessThan(0); + expect(hit?.evidence?.sources[0]?.embeddingScore).toBeUndefined(); + } + const tripleEvidence = await archive.evidence( + "wikg://triple/Q1/relates/Q2", + { query: "needle", queryMode: "hybrid", sourceContext: 0 }, + ); + expect(tripleEvidence.items.length).toBeGreaterThan(0); + expect( + tripleEvidence.items.every( + (hit) => + hit.ftsScore !== undefined && hit.embeddingScore !== undefined, + ), + ).toBe(true); + const ordinary = await archive.evidence("wikg://entity/Q1", { + sourceContext: 0, + }); + expect( + ordinary.items.every( + (hit) => + hit.ftsScore === undefined && hit.embeddingScore === undefined, + ), + ).toBe(true); + const libraryEvidence = await setup.sdk.libraries.evidence( + setup.libraryUri, + "wikg://entity/Q1", + { query: "needle", queryMode: "hybrid", sourceContext: 0, limit: 1 }, + ); + expect(libraryEvidence.items[0]?.ftsScore).toBeLessThan(0); + expect(libraryEvidence.items[0]?.embeddingScore).toBeGreaterThan(0); + } finally { + setup.sdk.close(); } - const tripleEvidence = await archive.evidence( - "wikg://triple/Q1/relates/Q2", - { query: "needle", queryMode: "hybrid", sourceContext: 0 }, - ); - expect(tripleEvidence.items.length).toBeGreaterThan(0); - expect( - tripleEvidence.items.every( - (hit) => - hit.ftsScore !== undefined && hit.embeddingScore !== undefined, - ), - ).toBe(true); - const ordinary = await archive.evidence("wikg://entity/Q1", { - sourceContext: 0, - }); - expect( - ordinary.items.every( - (hit) => - hit.ftsScore === undefined && hit.embeddingScore === undefined, - ), - ).toBe(true); - const libraryEvidence = await setup.sdk.libraries.evidence( - setup.libraryUri, - "wikg://entity/Q1", - { query: "needle", queryMode: "hybrid", sourceContext: 0, limit: 1 }, - ); - expect(libraryEvidence.items[0]?.ftsScore).toBeLessThan(0); - expect(libraryEvidence.items[0]?.embeddingScore).toBeGreaterThan(0); - } finally { - setup.sdk.close(); - } - }); - it("shares one query build while bucket cursors advance independently", async () => { - const setup = await createIndexedLibrary(); + }), + ); + it( + "shares one query build while bucket cursors advance independently", + regressionTest(async () => { + const setup = await createIndexedLibrary(); - try { - setup.embeddingProbe.reset(); - const first = await setup.sdk.libraries.searchBuckets( - setup.libraryUri, - "needle", - { - buckets: [ - { id: "text-a", types: ["source"] }, - { id: "text-b", types: ["source"] }, - ], - limitPerBucket: 5, - queryMode: "hybrid", - }, - ); - expect(setup.embeddingProbe.queryCount).toBe(1); - expect(first.buckets).toHaveLength(2); - expect(ids(first.buckets[0]!.items)).toEqual( - ids(first.buckets[1]!.items), - ); - expect(first.buckets[0]!.rawNextCursor).not.toBeNull(); - expect(first.buckets[1]!.rawNextCursor).not.toBeNull(); + try { + setup.embeddingProbe.reset(); + const first = await setup.sdk.libraries.searchBuckets( + setup.libraryUri, + "needle", + { + buckets: [ + { id: "text-a", types: ["source"] }, + { id: "text-b", types: ["source"] }, + ], + limitPerBucket: 5, + queryMode: "hybrid", + }, + ); + expect(setup.embeddingProbe.queryCount).toBe(1); + expect(first.buckets).toHaveLength(2); + expect(ids(first.buckets[0]!.items)).toEqual( + ids(first.buckets[1]!.items), + ); + expect(first.buckets[0]!.rawNextCursor).not.toBeNull(); + expect(first.buckets[1]!.rawNextCursor).not.toBeNull(); - const pageA = await setup.sdk.libraries.continueSearchBucket( - setup.libraryUri, - first.buckets[0]!.rawNextCursor!, - { limit: 5 }, - ); - const pageB = await setup.sdk.libraries.continueSearchBucket( - setup.libraryUri, - first.buckets[1]!.rawNextCursor!, - { limit: 5 }, - ); - expect(pageA.id).toBe("text-a"); - expect(pageB.id).toBe("text-b"); - expect(ids(pageA.items)).toEqual(ids(pageB.items)); - expect(setup.embeddingProbe.queryCount).toBe(1); - } finally { - setup.sdk.close(); - } - }); + const pageA = await setup.sdk.libraries.continueSearchBucket( + setup.libraryUri, + first.buckets[0]!.rawNextCursor!, + { limit: 5 }, + ); + const pageB = await setup.sdk.libraries.continueSearchBucket( + setup.libraryUri, + first.buckets[1]!.rawNextCursor!, + { limit: 5 }, + ); + expect(pageA.id).toBe("text-a"); + expect(pageB.id).toBe("text-b"); + expect(ids(pageA.items)).toEqual(ids(pageB.items)); + expect(setup.embeddingProbe.queryCount).toBe(1); + } finally { + setup.sdk.close(); + } + }), + ); it.each(["fts", "embedding", "hybrid"] as const)( "paginates a real aggregate index in %s mode", - async (queryMode) => { + regressionTest(async (queryMode: SearchIndexQueryMode) => { const setup = await createIndexedLibrary(); try { @@ -363,7 +385,7 @@ describe("WikiGraphLibraryManager search pagination", () => { } finally { setup.sdk.close(); } - }, + }), ); }); @@ -378,13 +400,16 @@ async function createIndexedLibrary( readonly sdk: WikiGraphSDK; }> { const root = await mkdtemp(join(tmpdir(), "wiki-graph-sdk-pagination-")); - temporaryDirectories.push(root); + regressionLifecycle().defer( + async () => await rm(root, { force: true, recursive: true }), + ); const embeddingProbe = createEmbeddingProvider(); const provider = embeddingProbe.provider; const sdk = createWikiGraphSDK({ providers: { embedding: provider }, stateDir: join(root, "state"), }); + regressionLifecycle().defer(() => sdk.close()); const archivePath = join(root, "source.wikg"); await regressionPhase("fixture:create", () => sdk.archives.create({ path: archivePath }), diff --git a/scripts/check-linux-regressions.mjs b/scripts/check-linux-regressions.mjs new file mode 100644 index 00000000..d813433e --- /dev/null +++ b/scripts/check-linux-regressions.mjs @@ -0,0 +1,73 @@ +import { readFileSync, mkdirSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; + +const checkOnly = process.argv[2] === "--check-report"; +if (!checkOnly && process.platform !== "linux") + throw new Error("Linux workflow acceptance must run on Linux"); +const report = resolve( + checkOnly ? process.argv[3] : "test/.reports/linux-workflows.json", +); +if (!checkOnly) { + mkdirSync(resolve("test/.reports"), { recursive: true }); + rmSync(report, { force: true }); + execFileSync( + process.execPath, + [ + "node_modules/vitest/vitest.mjs", + "run", + "--config", + "vitest.linux-workflows.config.ts", + "--reporter=verbose", + "--reporter=json", + `--outputFile.json=${report}`, + ], + { + stdio: "inherit", + env: { ...process.env, WIKIGRAPH_REGRESSION_TRACE: "1" }, + }, + ); +} +const result = JSON.parse(readFileSync(report, "utf8")); +for (const [filename, required] of [ + [ + "library-search-pagination.full-regression.test.ts", + [ + "isolates a stalled wikipage from standalone search, reads and writes", + "isolates a stalled wikipage from library search, reads and writes", + ], + ], + [ + "source-ingestion.full-regression.test.ts", + ["creates an EPUB archive with native import provenance"], + ], +]) { + const suite = result.testResults.find((suite) => + suite.name.replaceAll("\\", "/").endsWith(`/${filename}`), + ); + if ( + !suite || + suite.assertionResults.length === 0 || + suite.assertionResults.some((test) => test.status !== "passed") || + required.some( + (name) => + !suite.assertionResults.some( + (test) => test.title === name && test.status === "passed", + ), + ) + ) + throw new Error( + `Linux workflow acceptance omitted, skipped or failed ${filename}`, + ); +} +if ( + result.success !== true || + result.numPendingTests !== 0 || + result.numTodoTests !== 0 +) + throw new Error( + "Linux workflow acceptance must pass without skips or todo cases", + ); +console.log( + `Linux workflows: ${result.numPassedTests} passed; 0 skipped. Report: ${report}`, +); diff --git a/scripts/diagnose-linux-workflows.mjs b/scripts/diagnose-linux-workflows.mjs index 625f5aa8..a92e26c3 100644 --- a/scripts/diagnose-linux-workflows.mjs +++ b/scripts/diagnose-linux-workflows.mjs @@ -21,6 +21,7 @@ const fixtures = [ "packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts", "test/full-regression/cli/archive/source-ingestion.full-regression.test.ts", "test/helpers/regression-phase.ts", + "test/helpers/regression-lifecycle.ts", ]; const bindingRoot = join(root, "official"); mkdirSync(bindingRoot); diff --git a/test/fixtures/regression-lifecycle/timeout.fixture.ts b/test/fixtures/regression-lifecycle/timeout.fixture.ts new file mode 100644 index 00000000..ffba2157 --- /dev/null +++ b/test/fixtures/regression-lifecycle/timeout.fixture.ts @@ -0,0 +1,35 @@ +import { mkdir, rm, writeFile } from "fs/promises"; +import { join } from "path"; +import { setTimeout } from "timers/promises"; +import { it } from "vitest"; +import { + regressionLifecycle, + regressionTest, +} from "../../helpers/regression-lifecycle.js"; + +it( + "deliberately times out while setup still owns files", + regressionTest(async () => { + const root = process.env.WIKIGRAPH_LIFECYCLE_ROOT!; + const workspace = join(root, "workspace"); + const order: string[] = []; + regressionLifecycle().defer(async () => { + await rm(workspace, { recursive: true }); + order.push("remove"); + await writeFile(join(root, "order.json"), JSON.stringify(order)); + }); + regressionLifecycle().defer(() => { + order.push("close"); + }); + await mkdir(workspace); + try { + await setTimeout(150); + await writeFile(join(workspace, "late.txt"), "still active"); + order.push("late-write"); + } finally { + await writeFile(join(workspace, "finally.txt"), "released"); + order.push("finally"); + } + }), + 25, +); diff --git a/test/fixtures/regression-lifecycle/vitest.config.ts b/test/fixtures/regression-lifecycle/vitest.config.ts new file mode 100644 index 00000000..b598e644 --- /dev/null +++ b/test/fixtures/regression-lifecycle/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +// A deliberately failing child runner, invoked only by its parent assertion. +export default defineConfig({ + test: { + include: ["test/fixtures/regression-lifecycle/timeout.fixture.ts"], + maxWorkers: 1, + }, +}); diff --git a/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts b/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts index 6b8ad5a6..b3a35352 100644 --- a/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts +++ b/test/full-regression/cli/archive/source-ingestion.full-regression.test.ts @@ -14,6 +14,10 @@ import { NodeFile } from "../../../../packages/cli/src/runtime/node-platform.js" import { withTempDir } from "../../../helpers/temp.js"; import { regressionPhase } from "../../../helpers/regression-phase.js"; +import { + regressionLifecycle, + regressionTest, +} from "../../../helpers/regression-lifecycle.js"; const NEWTON_JSONL_PATH = resolve("test/fixtures/sources/newton.pdf.jsonl"); const LITTLE_PRINCE_EPUB_PATH = resolve( @@ -50,445 +54,481 @@ interface TocItemLike { } describe("cli/archive/source-ingestion", () => { - it("lists source locators separately and opens their artifact URIs", async () => { - const digest = "a".repeat(64); - const shortUid = digest.slice(0, 12); - const firstText = "😀 First source sentence. "; - const secondText = "Second source sentence."; - const secondStart = Array.from(firstText).length; - const jsonl = [ - JSON.stringify({ - digest, - mediaType: "application/pdf", - name: "two-pages.pdf", - type: "artifact", - }), - JSON.stringify({ - locator: { bbox: [0, 0, 0.5, 0.5], pageIndex: 1 }, - text: firstText, - type: "text", - }), - JSON.stringify({ - locator: { bbox: [0.5, 0.5, 1, 1], pageIndex: 2 }, - text: secondText, - type: "text", - }), - ].join("\n"); - - await withTempDir("wikigraph-cli-source-provenance-", async (path) => { - const stateDir = resolve(path, "state"); - const archivePath = resolve(path, "provenance.wikg"); - const archiveUri = formatWikiGraphCommandUri(archivePath); - - await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); - const chapter = await runJsonCLI(stateDir, [ - `${archiveUri}/chapter`, - "add", - "--title", - "Provenance", - "--json", - ]); - expect(chapter.uri).toMatch(/^wikg:\/\/chapter\//u); - const locatedChapterUri = requireString(chapter, "locatedUri"); - expect(locatedChapterUri).toBe( - `${archiveUri}/chapter/${requireChapterPath(chapter)}`, - ); + it( + "lists source locators separately and opens their artifact URIs", + regressionTest(async () => { + const digest = "a".repeat(64); + const shortUid = digest.slice(0, 12); + const firstText = "😀 First source sentence. "; + const secondText = "Second source sentence."; + const secondStart = Array.from(firstText).length; + const jsonl = [ + JSON.stringify({ + digest, + mediaType: "application/pdf", + name: "two-pages.pdf", + type: "artifact", + }), + JSON.stringify({ + locator: { bbox: [0, 0, 0.5, 0.5], pageIndex: 1 }, + text: firstText, + type: "text", + }), + JSON.stringify({ + locator: { bbox: [0.5, 0.5, 1, 1], pageIndex: 2 }, + text: secondText, + type: "text", + }), + ].join("\n"); + + await withTempDir("wikigraph-cli-source-provenance-", async (path) => { + const stateDir = resolve(path, "state"); + const archivePath = resolve(path, "provenance.wikg"); + const archiveUri = formatWikiGraphCommandUri(archivePath); + + await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); + const chapter = await runJsonCLI(stateDir, [ + `${archiveUri}/chapter`, + "add", + "--title", + "Provenance", + "--json", + ]); + expect(chapter.uri).toMatch(/^wikg:\/\/chapter\//u); + const locatedChapterUri = requireString(chapter, "locatedUri"); + expect(locatedChapterUri).toBe( + `${archiveUri}/chapter/${requireChapterPath(chapter)}`, + ); - const sourceUri = `${locatedChapterUri}/source`; - await runJsonCLI( - stateDir, - [sourceUri, "set", "--input", "-", "--input-format", "jsonl", "--json"], - jsonl, - ); + const sourceUri = `${locatedChapterUri}/source`; + await runJsonCLI( + stateDir, + [ + sourceUri, + "set", + "--input", + "-", + "--input-format", + "jsonl", + "--json", + ], + jsonl, + ); - const wholeSource = await runJsonCLI(stateDir, [sourceUri, "--json"]); - const firstLocator = `wikg://artifact/${shortUid}#page=1&bbox=0,0,0.5,0.5`; - const secondLocator = `wikg://artifact/${shortUid}#page=2&bbox=0.5,0.5,1,1`; - expect(wholeSource).not.toHaveProperty("locators"); - const wholePlain = await runWikiGraphCLICaptured({ - argv: [sourceUri], - stateDir, - }); - expect(wholePlain.exitCode, wholePlain.stderr).toBe(0); - expect(wholePlain.stdout).toBe(`${firstText}${secondText}\n`); - - const locatorScopeUri = `${sourceUri}/locators`; - const firstLocatorPage = await runJsonCLI(stateDir, [ - locatorScopeUri, - "--limit", - "1", - "--json", - ]); - expect(firstLocatorPage.objects).toStrictEqual([ - { range: [1, secondStart], uri: firstLocator }, - ]); - const nextCursor = requireString(firstLocatorPage, "nextCursor"); - const secondLocatorPage = await runJsonCLI(stateDir, [ - "next", - nextCursor, - "--json", - ]); - expect(secondLocatorPage.objects).toStrictEqual([ - { - range: [secondStart + 1, secondStart + Array.from(secondText).length], - uri: secondLocator, - }, - ]); - expect(secondLocatorPage.nextCursor).toBeNull(); - - const secondRangeLocators = await runJsonCLI(stateDir, [ - `${locatorScopeUri}#2`, - "--json", - ]); - expect(secondRangeLocators.objects).toStrictEqual([ - { - range: [1, Array.from(secondText).length], - uri: secondLocator, - }, - ]); - - const artifact = await runJsonCLI(stateDir, [ - `${archiveUri}/artifact/${digest}`, - "--json", - ]); - expect(artifact).toMatchObject({ - digest, - mediaType: "application/pdf", - name: "two-pages.pdf", - shortUid, - uri: `wikg://artifact/${shortUid}`, - }); - const locator = await runJsonCLI(stateDir, [ - `${archiveUri}/artifact/${digest}#page=1&bbox=0,0,0.5,0.5`, - "--json", - ]); - expect(locator).toMatchObject({ - locator: { bbox: [0, 0, 0.5, 0.5], pageIndex: 1 }, - uri: firstLocator, - }); - await expect( - runJsonCLI(stateDir, [ - `${archiveUri}/artifact/${shortUid}#page=1&bbox=0,0,0.5,0.5`, + const wholeSource = await runJsonCLI(stateDir, [sourceUri, "--json"]); + const firstLocator = `wikg://artifact/${shortUid}#page=1&bbox=0,0,0.5,0.5`; + const secondLocator = `wikg://artifact/${shortUid}#page=2&bbox=0.5,0.5,1,1`; + expect(wholeSource).not.toHaveProperty("locators"); + const wholePlain = await runWikiGraphCLICaptured({ + argv: [sourceUri], + stateDir, + }); + expect(wholePlain.exitCode, wholePlain.stderr).toBe(0); + expect(wholePlain.stdout).toBe(`${firstText}${secondText}\n`); + + const locatorScopeUri = `${sourceUri}/locators`; + const firstLocatorPage = await runJsonCLI(stateDir, [ + locatorScopeUri, + "--limit", + "1", "--json", - ]), - ).resolves.toMatchObject({ digest, shortUid, uri: firstLocator }); - const unassignedPrefix = await runWikiGraphCLICaptured({ - argv: [`${archiveUri}/artifact/${digest.slice(0, 13)}`, "--json"], - stateDir, - }); - expect(unassignedPrefix.exitCode).toBe(1); + ]); + expect(firstLocatorPage.objects).toStrictEqual([ + { range: [1, secondStart], uri: firstLocator }, + ]); + const nextCursor = requireString(firstLocatorPage, "nextCursor"); + const secondLocatorPage = await runJsonCLI(stateDir, [ + "next", + nextCursor, + "--json", + ]); + expect(secondLocatorPage.objects).toStrictEqual([ + { + range: [ + secondStart + 1, + secondStart + Array.from(secondText).length, + ], + uri: secondLocator, + }, + ]); + expect(secondLocatorPage.nextCursor).toBeNull(); + + const secondRangeLocators = await runJsonCLI(stateDir, [ + `${locatorScopeUri}#2`, + "--json", + ]); + expect(secondRangeLocators.objects).toStrictEqual([ + { + range: [1, Array.from(secondText).length], + uri: secondLocator, + }, + ]); + + const artifact = await runJsonCLI(stateDir, [ + `${archiveUri}/artifact/${digest}`, + "--json", + ]); + expect(artifact).toMatchObject({ + digest, + mediaType: "application/pdf", + name: "two-pages.pdf", + shortUid, + uri: `wikg://artifact/${shortUid}`, + }); + const locator = await runJsonCLI(stateDir, [ + `${archiveUri}/artifact/${digest}#page=1&bbox=0,0,0.5,0.5`, + "--json", + ]); + expect(locator).toMatchObject({ + locator: { bbox: [0, 0, 0.5, 0.5], pageIndex: 1 }, + uri: firstLocator, + }); + await expect( + runJsonCLI(stateDir, [ + `${archiveUri}/artifact/${shortUid}#page=1&bbox=0,0,0.5,0.5`, + "--json", + ]), + ).resolves.toMatchObject({ digest, shortUid, uri: firstLocator }); + const unassignedPrefix = await runWikiGraphCLICaptured({ + argv: [`${archiveUri}/artifact/${digest.slice(0, 13)}`, "--json"], + stateDir, + }); + expect(unassignedPrefix.exitCode).toBe(1); + + const plain = await runWikiGraphCLICaptured({ + argv: [`${sourceUri}#2`], + stateDir, + }); + expect(plain.exitCode, plain.stderr).toBe(0); + expect(plain.stdout).toBe( + `@@ ${requireString(wholeSource, "uri")}#2 @@\n${secondText}\n`, + ); + const jsonlResult = await runWikiGraphCLICaptured({ + argv: [`${sourceUri}#1`, "--jsonl"], + stateDir, + }); + expect(jsonlResult.exitCode, jsonlResult.stderr).toBe(0); + expect(JSON.parse(jsonlResult.stdout)).toMatchObject({ + text: firstText, + }); + expect(JSON.parse(jsonlResult.stdout)).not.toHaveProperty("locators"); + + const locatorPlain = await runWikiGraphCLICaptured({ + argv: [locatorScopeUri, "--all"], + stateDir, + }); + expect(locatorPlain.exitCode, locatorPlain.stderr).toBe(0); + expect(locatorPlain.stdout).toBe( + `1..${secondStart} -> ${firstLocator}\n${secondStart + 1}..${secondStart + Array.from(secondText).length} -> ${secondLocator}\n`, + ); - const plain = await runWikiGraphCLICaptured({ - argv: [`${sourceUri}#2`], - stateDir, - }); - expect(plain.exitCode, plain.stderr).toBe(0); - expect(plain.stdout).toBe( - `@@ ${requireString(wholeSource, "uri")}#2 @@\n${secondText}\n`, - ); - const jsonlResult = await runWikiGraphCLICaptured({ - argv: [`${sourceUri}#1`, "--jsonl"], - stateDir, + const locatorJsonl = await runWikiGraphCLICaptured({ + argv: [`${locatorScopeUri}#1`, "--jsonl"], + stateDir, + }); + expect(locatorJsonl.exitCode, locatorJsonl.stderr).toBe(0); + expect( + locatorJsonl.stdout + .trim() + .split("\n") + .map((line): unknown => JSON.parse(line) as unknown), + ).toEqual([ + { range: [1, secondStart], uri: firstLocator }, + { nextCursor: null, type: "page" }, + ]); + + await runJsonCLI(stateDir, [ + locatedChapterUri, + "reset", + "--to", + "planned", + "--json", + ]); + await runJsonCLI(stateDir, [ + sourceUri, + "set", + "Plain source.", + "--json", + ]); + const plainSource = await runJsonCLI(stateDir, [sourceUri, "--json"]); + expect(plainSource).not.toHaveProperty("locators"); + const emptyLocators = await runJsonCLI(stateDir, [ + locatorScopeUri, + "--json", + ]); + expect(emptyLocators.objects).toStrictEqual([]); + const staleNext = await runWikiGraphCLICaptured({ + argv: ["next", nextCursor, "--json"], + stateDir, + }); + expect(staleNext.exitCode).not.toBe(0); + expect(JSON.parse(staleNext.stdout)).toMatchObject({ + error: { message: "Invalid or stale source locator cursor." }, + }); }); - expect(jsonlResult.exitCode, jsonlResult.stderr).toBe(0); - expect(JSON.parse(jsonlResult.stdout)).toMatchObject({ text: firstText }); - expect(JSON.parse(jsonlResult.stdout)).not.toHaveProperty("locators"); + }), + ); + + it( + "sets a PDF source from JSONL through real CLI stdin", + regressionTest(async () => { + const jsonl = await readFile(NEWTON_JSONL_PATH, "utf8"); + const records = parseJsonl(jsonl); + const artifact = requireArtifact(records); + const textRecords = records.filter(isTextRecord); + const expectedText = textRecords.map((record) => record.text).join(""); + + await withTempDir("wikigraph-cli-pdf-set-", async (path) => { + const stateDir = resolve(path, "state"); + const archivePath = resolve(path, "newton.wikg"); + const archiveUri = formatWikiGraphCommandUri(archivePath); + + await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); + const chapter = await runJsonCLI(stateDir, [ + `${archiveUri}/chapter`, + "add", + "--title", + "Newton", + "--json", + ]); + const chapterUri = formatLocatedChapterResourceUri( + archivePath, + requireChapterPath(chapter), + "source", + ); - const locatorPlain = await runWikiGraphCLICaptured({ - argv: [locatorScopeUri, "--all"], - stateDir, + await runJsonCLI( + stateDir, + [ + chapterUri, + "set", + "--input", + "-", + "--input-format", + "jsonl", + "--json", + ], + jsonl, + ); + + const serial = await readSingleSerial(archivePath); + expect(serial.text).toBe(expectedText); + expect(serial.maps).toHaveLength(textRecords.length); + expect(serial.maps[0]?.sourceStart).toBe(0); + expect(serial.maps.at(-1)?.sourceEnd).toBe( + Array.from(expectedText).length, + ); + expect( + serial.maps.every( + (mapping) => mapping.artifact.mediaType === artifact.mediaType, + ), + ).toBe(true); + expect( + serial.maps.every( + (mapping) => mapping.artifact.name === artifact.name, + ), + ).toBe(true); + expect( + serial.maps.every( + (mapping, index) => + index === 0 || + mapping.sourceStart === serial.maps[index - 1]!.sourceEnd, + ), + ).toBe(true); + expect( + textRecords.every((record) => { + const pageIndex = record.locator.pageIndex; + const bbox = record.locator.bbox; + return ( + Number.isInteger(pageIndex) && + (pageIndex as number) >= 1 && + Array.isArray(bbox) && + bbox.length === 4 + ); + }), + ).toBe(true); }); - expect(locatorPlain.exitCode, locatorPlain.stderr).toBe(0); - expect(locatorPlain.stdout).toBe( - `1..${secondStart} -> ${firstLocator}\n${secondStart + 1}..${secondStart + Array.from(secondText).length} -> ${secondLocator}\n`, + }), + ); + + it( + "creates an EPUB archive with native import provenance", + regressionTest(async () => { + const epubDigest = createHash("sha256") + .update(await readFile(LITTLE_PRINCE_EPUB_PATH)) + .digest("hex"); + const epubShortUid = epubDigest.slice(0, 12); + const generatedJsonl = parseJsonl( + await readFile(LITTLE_PRINCE_JSONL_PATH, "utf8"), ); + const generatedTextRecords = generatedJsonl.filter(isTextRecord); + const generatedText = generatedTextRecords + .map((record) => record.text) + .join(""); + + await withTempDir("wikigraph-cli-epub-import-", async (path) => { + const stateDir = resolve(path, "state"); + const archivePath = resolve(path, "little-prince.wikg"); + const archiveUri = formatWikiGraphCommandUri(archivePath); + + await runJsonCLI(stateDir, [ + archiveUri, + "create", + "--import", + LITTLE_PRINCE_EPUB_PATH, + "--json", + ]); + + const serials = await readSerials(archivePath); + expect(serials.length).toBeGreaterThan(0); + expect(serials.every((serial) => serial.text.length > 0)).toBe(true); + expect(serials.every((serial) => serial.maps.length > 0)).toBe(true); + expect(serials.map((serial) => serial.text).join("")).toBe( + generatedText, + ); + const importedMaps = serials.flatMap((serial) => serial.maps); + expect(importedMaps).toHaveLength(generatedTextRecords.length); + expect(importedMaps.map((mapping) => mapping.locator)).toStrictEqual( + generatedTextRecords.map((record) => record.locator), + ); + expect( + serials.every((serial) => + serial.maps.every( + (mapping) => + mapping.artifact.mediaType === "application/epub+zip" && + mapping.artifact.digest === epubDigest && + typeof mapping.locator.cfi === "string", + ), + ), + ).toBe(true); - const locatorJsonl = await runWikiGraphCLICaptured({ - argv: [`${locatorScopeUri}#1`, "--jsonl"], - stateDir, - }); - expect(locatorJsonl.exitCode, locatorJsonl.stderr).toBe(0); - expect( - locatorJsonl.stdout - .trim() - .split("\n") - .map((line): unknown => JSON.parse(line) as unknown), - ).toEqual([ - { range: [1, secondStart], uri: firstLocator }, - { nextCursor: null, type: "page" }, - ]); - - await runJsonCLI(stateDir, [ - locatedChapterUri, - "reset", - "--to", - "planned", - "--json", - ]); - await runJsonCLI(stateDir, [sourceUri, "set", "Plain source.", "--json"]); - const plainSource = await runJsonCLI(stateDir, [sourceUri, "--json"]); - expect(plainSource).not.toHaveProperty("locators"); - const emptyLocators = await runJsonCLI(stateDir, [ - locatorScopeUri, - "--json", - ]); - expect(emptyLocators.objects).toStrictEqual([]); - const staleNext = await runWikiGraphCLICaptured({ - argv: ["next", nextCursor, "--json"], - stateDir, - }); - expect(staleNext.exitCode).not.toBe(0); - expect(JSON.parse(staleNext.stdout)).toMatchObject({ - error: { message: "Invalid or stale source locator cursor." }, + const chapterList = await runJsonCLI(stateDir, [ + `${archiveUri}/chapter`, + "--json", + ]); + const chapters = chapterList.objects as readonly Record< + string, + unknown + >[]; + expect(chapters.length).toBeGreaterThan(0); + const chapterUri = formatLocatedChapterResourceUri( + archivePath, + requireChapterPath(chapters[0]!), + "source", + ); + const locatorPage = await runJsonCLI(stateDir, [ + `${chapterUri}/locators`, + "--limit", + "1", + "--json", + ]); + const locatorObjects = locatorPage.objects as readonly unknown[]; + expect(Array.isArray(locatorObjects)).toBe(true); + expect(locatorObjects).toHaveLength(1); + const firstLocator = locatorObjects[0] as { + readonly range: readonly [number, number]; + readonly uri: string; + }; + expect(firstLocator.range[0]).toBe(1); + expect(firstLocator.uri).toContain( + `wikg://artifact/${epubShortUid}#epubcfi(`, + ); + const sentenceLocatorPage = await runJsonCLI(stateDir, [ + `${chapterUri}/locators#1`, + "--all", + "--json", + ]); + const sentenceLocators = sentenceLocatorPage.objects as readonly { + readonly range: readonly [number, number]; + readonly uri: string; + }[]; + expect(sentenceLocators.length).toBeGreaterThan(0); + expect(sentenceLocators[0]?.range[0]).toBe(1); + expect( + sentenceLocators.every((locator) => + locator.uri.includes(`wikg://artifact/${epubShortUid}#epubcfi(`), + ), + ).toBe(true); + const artifactLocator = await runJsonCLI(stateDir, [ + `${archiveUri}/artifact/${epubDigest}#${firstLocator.uri.split("#")[1]}`, + "--json", + ]); + expect(artifactLocator).toMatchObject({ + uri: firstLocator.uri, + locator: { cfi: generatedTextRecords[0]?.locator.cfi }, + }); }); - }); - }); - - it("sets a PDF source from JSONL through real CLI stdin", async () => { - const jsonl = await readFile(NEWTON_JSONL_PATH, "utf8"); - const records = parseJsonl(jsonl); - const artifact = requireArtifact(records); - const textRecords = records.filter(isTextRecord); - const expectedText = textRecords.map((record) => record.text).join(""); - - await withTempDir("wikigraph-cli-pdf-set-", async (path) => { - const stateDir = resolve(path, "state"); - const archivePath = resolve(path, "newton.wikg"); - const archiveUri = formatWikiGraphCommandUri(archivePath); - - await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); - const chapter = await runJsonCLI(stateDir, [ - `${archiveUri}/chapter`, - "add", - "--title", - "Newton", - "--json", - ]); - const chapterUri = formatLocatedChapterResourceUri( - archivePath, - requireChapterPath(chapter), - "source", - ); + }), + 20_000, + ); - await runJsonCLI( - stateDir, - [ - chapterUri, - "set", - "--input", - "-", - "--input-format", - "jsonl", + it( + "sets an EPUB source from JSONL generated by the native importer", + regressionTest(async () => { + const jsonl = await readFile(LITTLE_PRINCE_JSONL_PATH, "utf8"); + const records = parseJsonl(jsonl); + const artifact = requireArtifact(records); + const textRecords = records.filter(isTextRecord); + const expectedText = textRecords.map((record) => record.text).join(""); + const epubDigest = createHash("sha256") + .update(await readFile(LITTLE_PRINCE_EPUB_PATH)) + .digest("hex"); + + await withTempDir("wikigraph-cli-epub-set-", async (path) => { + const stateDir = resolve(path, "state"); + const archivePath = resolve(path, "little-prince-set.wikg"); + const archiveUri = formatWikiGraphCommandUri(archivePath); + + await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); + const chapter = await runJsonCLI(stateDir, [ + `${archiveUri}/chapter`, + "add", + "--title", + "The little prince", "--json", - ], - jsonl, - ); + ]); + const chapterUri = formatLocatedChapterResourceUri( + archivePath, + requireChapterPath(chapter), + "source", + ); - const serial = await readSingleSerial(archivePath); - expect(serial.text).toBe(expectedText); - expect(serial.maps).toHaveLength(textRecords.length); - expect(serial.maps[0]?.sourceStart).toBe(0); - expect(serial.maps.at(-1)?.sourceEnd).toBe( - Array.from(expectedText).length, - ); - expect( - serial.maps.every( - (mapping) => mapping.artifact.mediaType === artifact.mediaType, - ), - ).toBe(true); - expect( - serial.maps.every((mapping) => mapping.artifact.name === artifact.name), - ).toBe(true); - expect( - serial.maps.every( - (mapping, index) => - index === 0 || - mapping.sourceStart === serial.maps[index - 1]!.sourceEnd, - ), - ).toBe(true); - expect( - textRecords.every((record) => { - const pageIndex = record.locator.pageIndex; - const bbox = record.locator.bbox; - return ( - Number.isInteger(pageIndex) && - (pageIndex as number) >= 1 && - Array.isArray(bbox) && - bbox.length === 4 - ); - }), - ).toBe(true); - }); - }); - - it("creates an EPUB archive with native import provenance", async () => { - const epubDigest = createHash("sha256") - .update(await readFile(LITTLE_PRINCE_EPUB_PATH)) - .digest("hex"); - const epubShortUid = epubDigest.slice(0, 12); - const generatedJsonl = parseJsonl( - await readFile(LITTLE_PRINCE_JSONL_PATH, "utf8"), - ); - const generatedTextRecords = generatedJsonl.filter(isTextRecord); - const generatedText = generatedTextRecords - .map((record) => record.text) - .join(""); - - await withTempDir("wikigraph-cli-epub-import-", async (path) => { - const stateDir = resolve(path, "state"); - const archivePath = resolve(path, "little-prince.wikg"); - const archiveUri = formatWikiGraphCommandUri(archivePath); - - await runJsonCLI(stateDir, [ - archiveUri, - "create", - "--import", - LITTLE_PRINCE_EPUB_PATH, - "--json", - ]); - - const serials = await readSerials(archivePath); - expect(serials.length).toBeGreaterThan(0); - expect(serials.every((serial) => serial.text.length > 0)).toBe(true); - expect(serials.every((serial) => serial.maps.length > 0)).toBe(true); - expect(serials.map((serial) => serial.text).join("")).toBe(generatedText); - const importedMaps = serials.flatMap((serial) => serial.maps); - expect(importedMaps).toHaveLength(generatedTextRecords.length); - expect(importedMaps.map((mapping) => mapping.locator)).toStrictEqual( - generatedTextRecords.map((record) => record.locator), - ); - expect( - serials.every((serial) => + await runJsonCLI( + stateDir, + [ + chapterUri, + "set", + "--input", + "-", + "--input-format", + "jsonl", + "--json", + ], + jsonl, + ); + + const serial = await readSingleSerial(archivePath); + expect(serial.text).toBe(expectedText); + expect(serial.maps).toHaveLength(textRecords.length); + expect(serial.maps.at(-1)?.sourceEnd).toBe( + Array.from(expectedText).length, + ); + expect(serial.maps.map((mapping) => mapping.locator)).toStrictEqual( + textRecords.map((record) => record.locator), + ); + expect( serial.maps.every( (mapping) => - mapping.artifact.mediaType === "application/epub+zip" && + mapping.artifact.mediaType === artifact.mediaType && mapping.artifact.digest === epubDigest && + mapping.artifact.name === artifact.name && typeof mapping.locator.cfi === "string", ), - ), - ).toBe(true); - - const chapterList = await runJsonCLI(stateDir, [ - `${archiveUri}/chapter`, - "--json", - ]); - const chapters = chapterList.objects as readonly Record< - string, - unknown - >[]; - expect(chapters.length).toBeGreaterThan(0); - const chapterUri = formatLocatedChapterResourceUri( - archivePath, - requireChapterPath(chapters[0]!), - "source", - ); - const locatorPage = await runJsonCLI(stateDir, [ - `${chapterUri}/locators`, - "--limit", - "1", - "--json", - ]); - const locatorObjects = locatorPage.objects as readonly unknown[]; - expect(Array.isArray(locatorObjects)).toBe(true); - expect(locatorObjects).toHaveLength(1); - const firstLocator = locatorObjects[0] as { - readonly range: readonly [number, number]; - readonly uri: string; - }; - expect(firstLocator.range[0]).toBe(1); - expect(firstLocator.uri).toContain( - `wikg://artifact/${epubShortUid}#epubcfi(`, - ); - const sentenceLocatorPage = await runJsonCLI(stateDir, [ - `${chapterUri}/locators#1`, - "--all", - "--json", - ]); - const sentenceLocators = sentenceLocatorPage.objects as readonly { - readonly range: readonly [number, number]; - readonly uri: string; - }[]; - expect(sentenceLocators.length).toBeGreaterThan(0); - expect(sentenceLocators[0]?.range[0]).toBe(1); - expect( - sentenceLocators.every((locator) => - locator.uri.includes(`wikg://artifact/${epubShortUid}#epubcfi(`), - ), - ).toBe(true); - const artifactLocator = await runJsonCLI(stateDir, [ - `${archiveUri}/artifact/${epubDigest}#${firstLocator.uri.split("#")[1]}`, - "--json", - ]); - expect(artifactLocator).toMatchObject({ - uri: firstLocator.uri, - locator: { cfi: generatedTextRecords[0]?.locator.cfi }, + ).toBe(true); }); - }); - }, 20_000); - - it("sets an EPUB source from JSONL generated by the native importer", async () => { - const jsonl = await readFile(LITTLE_PRINCE_JSONL_PATH, "utf8"); - const records = parseJsonl(jsonl); - const artifact = requireArtifact(records); - const textRecords = records.filter(isTextRecord); - const expectedText = textRecords.map((record) => record.text).join(""); - const epubDigest = createHash("sha256") - .update(await readFile(LITTLE_PRINCE_EPUB_PATH)) - .digest("hex"); - - await withTempDir("wikigraph-cli-epub-set-", async (path) => { - const stateDir = resolve(path, "state"); - const archivePath = resolve(path, "little-prince-set.wikg"); - const archiveUri = formatWikiGraphCommandUri(archivePath); - - await runJsonCLI(stateDir, [archiveUri, "create", "--json"]); - const chapter = await runJsonCLI(stateDir, [ - `${archiveUri}/chapter`, - "add", - "--title", - "The little prince", - "--json", - ]); - const chapterUri = formatLocatedChapterResourceUri( - archivePath, - requireChapterPath(chapter), - "source", - ); - - await runJsonCLI( - stateDir, - [ - chapterUri, - "set", - "--input", - "-", - "--input-format", - "jsonl", - "--json", - ], - jsonl, - ); - - const serial = await readSingleSerial(archivePath); - expect(serial.text).toBe(expectedText); - expect(serial.maps).toHaveLength(textRecords.length); - expect(serial.maps.at(-1)?.sourceEnd).toBe( - Array.from(expectedText).length, - ); - expect(serial.maps.map((mapping) => mapping.locator)).toStrictEqual( - textRecords.map((record) => record.locator), - ); - expect( - serial.maps.every( - (mapping) => - mapping.artifact.mediaType === artifact.mediaType && - mapping.artifact.digest === epubDigest && - mapping.artifact.name === artifact.name && - typeof mapping.locator.cfi === "string", - ), - ).toBe(true); - }); - }, 20_000); + }), + 20_000, + ); }); async function runJsonCLI( @@ -500,6 +540,7 @@ async function runJsonCLI( runWikiGraphCLICaptured({ argv, stateDir, + signal: regressionLifecycle().signal, ...(stdin === undefined ? {} : { stdin, stdinIsTTY: false }), }), ); diff --git a/test/helpers/regression-lifecycle.test.ts b/test/helpers/regression-lifecycle.test.ts new file mode 100644 index 00000000..a7effc7e --- /dev/null +++ b/test/helpers/regression-lifecycle.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { spawn } from "child_process"; +import { describe, expect, it } from "vitest"; +import { RegressionLifecycle } from "./regression-lifecycle.js"; + +describe("regression lifecycle", () => { + it("cancels and joins timed-out setup before closing the SDK and deleting its root", async () => { + const scope = new RegressionLifecycle(); + const root = await mkdtemp(join(tmpdir(), "wg-lifecycle-")); + const order: string[] = []; + scope.defer(async () => { + expect(await readFile(join(root, "finally.txt"), "utf8")).toBe("closed"); + order.push("remove"); + await rm(root, { recursive: true }); + }); + scope.defer(() => { + order.push("sdk-close"); + }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const setup = scope.track(async () => { + await gate; + expect(scope.signal.aborted).toBe(true); + await writeFile(join(root, "finally.txt"), "closed"); + order.push("setup-finally"); + }); + const cleanup = scope.dispose(); + expect(order).toEqual([]); + expect(scope.signal.aborted).toBe(true); + release(); + await Promise.all([setup, cleanup]); + expect(order).toEqual(["setup-finally", "sdk-close", "remove"]); + await scope.dispose(); + expect(order).toHaveLength(3); + expect(() => scope.track(() => Promise.resolve())).toThrow(); + }); + + it("retains an in-flight operation failure and all cleanup failures", async () => { + const scope = new RegressionLifecycle(); + const original = new Error("original operation failed"); + const cleanup = new Error("cleanup failed"); + const order: number[] = []; + scope.defer(() => { + order.push(1); + }); + scope.defer(() => { + throw cleanup; + }); + scope.defer(() => { + order.push(2); + }); + const operation = scope.track(() => Promise.reject(original)); + const disposing = scope.dispose(); + await expect(operation).rejects.toBe(original); + await expect(disposing).rejects.toMatchObject({ + errors: [original, cleanup], + }); + expect(order).toEqual([2, 1]); + }); + + it("joins the real body after a Vitest timeout before deleting its files", async () => { + const root = await mkdtemp(join(tmpdir(), "wg-runner-timeout-")); + try { + const child = spawn( + process.execPath, + [ + "node_modules/vitest/vitest.mjs", + "run", + "--config", + "test/fixtures/regression-lifecycle/vitest.config.ts", + ], + { + env: { ...process.env, WIKIGRAPH_LIFECYCLE_ROOT: root }, + stdio: ["ignore", "pipe", "pipe"], + timeout: 10_000, + }, + ); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + const exit = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + expect(exit, output).toBe(1); + expect(output).toContain("Test timed out"); + expect(output).not.toContain("ENOENT"); + expect(output).not.toContain("ENOTEMPTY"); + expect( + JSON.parse(await readFile(join(root, "order.json"), "utf8")), + ).toEqual(["late-write", "finally", "close", "remove"]); + } finally { + await rm(root, { recursive: true }); + } + }); +}); diff --git a/test/helpers/regression-lifecycle.ts b/test/helpers/regression-lifecycle.ts new file mode 100644 index 00000000..d03ff5f8 --- /dev/null +++ b/test/helpers/regression-lifecycle.ts @@ -0,0 +1,76 @@ +import { AsyncLocalStorage } from "async_hooks"; +import { onTestFinished } from "vitest"; + +/** A test timeout rejects the runner's wait; it does not stop the test promise. */ +export class RegressionLifecycle { + readonly #controller = new AbortController(); + readonly #tasks = new Set>(); + readonly #cleanup: Array<() => Promise | void> = []; + #disposing: Promise | undefined; + + public get signal(): AbortSignal { + return this.#controller.signal; + } + + public defer(cleanup: () => Promise | void): void { + this.#cleanup.push(cleanup); + } + + public track(operation: () => Promise): Promise { + this.signal.throwIfAborted(); + const task = Promise.resolve().then(operation); + this.#tasks.add(task); + void task.then( + () => this.#tasks.delete(task), + () => this.#tasks.delete(task), + ); + return task; + } + + public dispose(): Promise { + return (this.#disposing ??= this.#dispose()); + } + + async #dispose(): Promise { + this.#controller.abort(new Error("Regression test finished or timed out")); + const failures: unknown[] = []; + // Join the real body (including setup and finally), not a timeout race. + const results = await Promise.allSettled([...this.#tasks]); + for (const result of results) + if (result.status === "rejected") failures.push(result.reason); + for (const cleanup of this.#cleanup.reverse()) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) + throw new AggregateError(failures, "Regression operation/cleanup failed"); + } +} + +const context = new AsyncLocalStorage(); + +export function regressionLifecycle(): RegressionLifecycle { + const value = context.getStore(); + if (value === undefined) throw new Error("Missing regression test lifecycle"); + return value; +} + +export function regressionSignal(): AbortSignal | undefined { + return context.getStore()?.signal; +} + +/** Teardown joins timed-out work before the next test can reuse globals. */ +export function regressionTest( + operation: (...args: Args) => Promise, +): (...args: Args) => Promise { + return (...args) => { + const lifecycle = new RegressionLifecycle(); + onTestFinished(async () => await lifecycle.dispose(), 120_000); + return context.run(lifecycle, () => + lifecycle.track(async () => await operation(...args)), + ); + }; +} diff --git a/test/helpers/regression-phase.ts b/test/helpers/regression-phase.ts index 1af228cd..77e2d6bb 100644 --- a/test/helpers/regression-phase.ts +++ b/test/helpers/regression-phase.ts @@ -1,13 +1,18 @@ +import { regressionSignal } from "./regression-lifecycle.js"; + /** Opt-in wall-clock evidence for slow durable filesystem workflows. */ export async function regressionPhase( name: string, operation: () => Promise, ): Promise { const started = performance.now(); + regressionSignal()?.throwIfAborted(); const trace = process.env.WIKIGRAPH_REGRESSION_TRACE === "1"; if (trace) console.log(`[regression-phase] start ${name}`); try { - return await operation(); + const value = await operation(); + regressionSignal()?.throwIfAborted(); + return value; } finally { if (trace) console.log( diff --git a/test/release/linux-regression-gate.test.ts b/test/release/linux-regression-gate.test.ts new file mode 100644 index 00000000..17b2d75d --- /dev/null +++ b/test/release/linux-regression-gate.test.ts @@ -0,0 +1,75 @@ +import { spawnSync } from "child_process"; +import { writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../helpers/temp.js"; + +describe("Linux workflow acceptance report", () => { + it.each([ + "passed", + "skipped", + "missing-file", + "missing-isolation-case", + "failed", + ])( + "requires both workflows and the previously failing assertions (%s)", + async (scenario) => { + await withTempDir("wg-linux-gate-", async (root) => { + const suites = [ + { + name: "/test/library-search-pagination.full-regression.test.ts", + assertionResults: [ + { + title: + "isolates a stalled wikipage from standalone search, reads and writes", + status: "passed", + }, + { + title: + "isolates a stalled wikipage from library search, reads and writes", + status: "passed", + }, + ], + }, + { + name: "/test/source-ingestion.full-regression.test.ts", + assertionResults: [ + { + title: "creates an EPUB archive with native import provenance", + status: "passed", + }, + ], + }, + ]; + if (scenario === "skipped") + suites[0]!.assertionResults[0]!.status = "pending"; + if (scenario === "failed") + suites[0]!.assertionResults[0]!.status = "failed"; + if (scenario === "missing-file") suites.pop(); + if (scenario === "missing-isolation-case") + suites[0]!.assertionResults.pop(); + const report = join(root, "report.json"); + await writeFile( + report, + JSON.stringify({ + success: true, + numPassedTests: 3, + numPendingTests: 0, + numTodoTests: 0, + testResults: suites, + }), + ); + const result = spawnSync( + process.execPath, + ["scripts/check-linux-regressions.mjs", "--check-report", report], + { encoding: "utf8" }, + ); + if (scenario === "passed") expect(result.status, result.stderr).toBe(0); + else { + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Linux workflow acceptance"); + } + }); + }, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 701901e1..7a081957 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,7 +54,8 @@ "vitest.config.ts", "vitest.pr.config.ts", "vitest.e2e.config.ts", - "vitest.windows.config.ts" + "vitest.windows.config.ts", + "vitest.linux-workflows.config.ts" ], "exclude": ["packages/*/dist", "dist", "coverage", "node_modules"] } diff --git a/vitest.linux-workflows.config.ts b/vitest.linux-workflows.config.ts new file mode 100644 index 00000000..eac0acc1 --- /dev/null +++ b/vitest.linux-workflows.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import base from "./vitest.config.js"; + +export default defineConfig({ + ...base, + test: { + ...base.test, + include: [ + "packages/sdk/src/full-regression/library-search-pagination.full-regression.test.ts", + "test/full-regression/cli/archive/source-ingestion.full-regression.test.ts", + ], + passWithNoTests: false, + }, +}); From 27ee4582b6ef6d1dc16e102bd71fc5a006b716b2 Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Mon, 19 Oct 2026 18:49:58 +0800 Subject: [PATCH 3/5] test: focus Linux diagnosis on the three timed-out workflows --- .github/workflows/pr-check.yml | 6 ++++++ scripts/diagnose-linux-workflows.mjs | 1 + 2 files changed, 7 insertions(+) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c75787ac..266e4a0e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -32,6 +32,7 @@ permissions: jobs: linux-workflows: name: Linux archive workflow regressions + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -75,6 +76,7 @@ jobs: packed-sqlite-consumer: name: Windows packed SDK Node ${{ matrix.node }} and Electron + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: windows-latest timeout-minutes: 25 strategy: @@ -279,6 +281,7 @@ jobs: quality: name: Code Quality + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: ubuntu-latest steps: @@ -299,6 +302,7 @@ jobs: tests: name: Tests + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: ubuntu-latest steps: @@ -319,6 +323,7 @@ jobs: windows-archive-coordination: name: Windows Archive Coordination + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: windows-latest steps: @@ -353,6 +358,7 @@ jobs: package-readiness: name: End-to-End and Package Readiness + if: github.event_name != 'workflow_dispatch' || !inputs.linuxComparison runs-on: ubuntu-latest steps: diff --git a/scripts/diagnose-linux-workflows.mjs b/scripts/diagnose-linux-workflows.mjs index a92e26c3..1dec241a 100644 --- a/scripts/diagnose-linux-workflows.mjs +++ b/scripts/diagnose-linux-workflows.mjs @@ -88,6 +88,7 @@ ${variant.endsWith("official") ? `{find: /^sqlite3(?:\\/lib\\/sqlite3\\.js)?$/, "--config", "diagnostic.config.ts", ...fixtures.slice(0, 2), + "--testNamePattern=isolates a stalled wikipage|creates an EPUB archive", "--reporter=verbose", ], { From b8cda15968da876d682d8e270a429435545e7212 Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Mon, 19 Oct 2026 18:55:46 +0800 Subject: [PATCH 4/5] fix: release completed database and document async contexts --- CHANGELOG.md | 1 + docs/en/sdk.md | 7 + docs/zh-CN/sdk.md | 5 + packages/core/src/document/database.ts | 4 + packages/core/src/document/directory/core.ts | 6 +- packages/core/src/runtime/platform/types.ts | 2 + test/core/document/database-lifecycle.test.ts | 197 ++++++++++++++++++ test/linux-workflows.md | 82 ++++++++ 8 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 test/linux-workflows.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bf66f6ed..372b8b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.9.6 +- Release finished database and document async contexts, preventing progressively slower operations on Node 22's async_hooks implementation. Keep the existing 15/20-second regression limits; drain timed-out test bodies before removing their workspace, and require the affected Linux workflows in PR checks. Scheduled full regressions now actually run even when main has no same-day commit. - Pin the SDK/CLI asynchronous SQLite binding to `npm:@appthreat/sqlite3@7.0.1` (SQLite 3.50.4) to avoid official sqlite3 6.0.1 / SQLite 3.52.0's Windows extended-drive WAL locking regression. Preserve Node >=22.12 and published upstream prebuild installation; no project-owned native distribution or schema migration. Later 7.x versions contain the defect, so the pin is exact; this historical release is not a security-maintenance guarantee. - Fence Archive reader admission against physical commits, including manifest/schema probes, and distinguish reading, drained settlement, and completed logical operations across processes. Defer ZIP settlement behind active readers without blocking callbacks; the last drained session or recovery reaper persists completed overlays. Keep mutation tokens pinned to their original source reader and discard search caches built over deferred changes. - Make SDK standalone export drain readers and settle pending writes before copying a coherent snapshot; reject exporting onto the source itself. Directory export uses the existing transaction/recovery lock. diff --git a/docs/en/sdk.md b/docs/en/sdk.md index ce2529b5..64b557db 100644 --- a/docs/en/sdk.md +++ b/docs/en/sdk.md @@ -58,6 +58,13 @@ exit codes, and JSON/JSONL rendering belong to `wiki-graph`. ## Concurrent archive access +Short-lived database/document contexts are released when their resources close, +including Node's async-hooks tracking. Custom `HostAsyncContext` providers may +implement the optional synchronous `disable()` hook for equivalent cleanup; +existing providers without the hook remain compatible. The default Node provider +already implements it. Hosts must give each created context independent ownership +and must not invalidate other live contexts when disabling one. + An SDK instance can be called concurrently. Same-archive callbacks may be queued; cross-process sessions coordinate through the shared Home state. A ZIP reader retains its mutation-token generation until it closes. If another session finishes diff --git a/docs/zh-CN/sdk.md b/docs/zh-CN/sdk.md index e03925fe..2ce23d3e 100644 --- a/docs/zh-CN/sdk.md +++ b/docs/zh-CN/sdk.md @@ -53,6 +53,11 @@ JSON/JSONL 渲染属于 `wiki-graph`。 ## Archive 并发访问 +数据库和文档关闭时会释放其短生命周期异步上下文,包括 Node 的 +async-hooks 跟踪。自定义 `HostAsyncContext` 可实现可选的同步 `disable()` +钩子以释放同类资源;没有此钩子的现有 Host 仍兼容,默认 Node 实现已提供。 +每次创建的上下文应独立拥有资源,释放一个上下文不能影响其他仍在使用的上下文。 + 同一个 SDK 实例可以并发调用;同一 Archive 的回调可能排队,跨进程会话通过共享 Home 状态协调。ZIP reader 在关闭前保持原 mutation token 对应的数据视图。 若其他会话此时完成逻辑写入,已发布 overlay 会保留在 coordinator 状态中,由最后排空的 diff --git a/packages/core/src/document/database.ts b/packages/core/src/document/database.ts index b82ea3a1..986f6a1f 100644 --- a/packages/core/src/document/database.ts +++ b/packages/core/src/document/database.ts @@ -236,6 +236,10 @@ export class Database { await this.#closeDatabase(); this.#closed = true; + // Node's async_hooks implementation retains each enabled ALS instance + // until disable(). Short-lived coordinator connections otherwise make + // every subsequent Promise progressively more expensive on Node 22. + this.#operationScope.disable?.(); }); } diff --git a/packages/core/src/document/directory/core.ts b/packages/core/src/document/directory/core.ts index e08a780a..2835dbe3 100644 --- a/packages/core/src/document/directory/core.ts +++ b/packages/core/src/document/directory/core.ts @@ -494,7 +494,11 @@ export class DirectoryDocument implements Document { await this.flush(); await this.#database.close(); } finally { - await this.#fileStore.close(); + try { + await this.#fileStore.close(); + } finally { + this.#contextScope.disable?.(); + } } } diff --git a/packages/core/src/runtime/platform/types.ts b/packages/core/src/runtime/platform/types.ts index c6b7da2c..4be11bc1 100644 --- a/packages/core/src/runtime/platform/types.ts +++ b/packages/core/src/runtime/platform/types.ts @@ -81,6 +81,8 @@ export interface HostAsyncContext { run(store: T, callback: () => R): R; enterWith(store: T): void; getStore(): T | undefined; + /** Release host tracking for a finished context. Existing hosts may omit it. */ + disable?(): void; } export interface HostAsyncContextProvider { diff --git a/test/core/document/database-lifecycle.test.ts b/test/core/document/database-lifecycle.test.ts index c32fa0de..65c3386d 100644 --- a/test/core/document/database-lifecycle.test.ts +++ b/test/core/document/database-lifecycle.test.ts @@ -11,6 +11,7 @@ import { openSearchIndexDatabase } from "../../../packages/core/src/document/dir import type { DocumentFileStore } from "../../../packages/core/src/document/directory/types.js"; import { installWikiGraphPlatform, + type HostAsyncContext, type HostDatabaseConnection, } from "../../../packages/core/src/runtime/platform/index.js"; import { withTempDir } from "../../helpers/temp.js"; @@ -18,6 +19,202 @@ import { withTempDir } from "../../helpers/temp.js"; afterEach(() => installWikiGraphPlatform(nodeWikiGraphPlatform)); describe("database lifecycle", () => { + it("disables short-lived transaction contexts instead of accumulating enabled async hooks", async () => { + const active = new Set(); + const created: Array<{ disabled: number }> = []; + installWikiGraphPlatform({ + ...nodeWikiGraphPlatform, + asyncContext: { + create(): HostAsyncContext { + const native = nodeWikiGraphPlatform.asyncContext.create(); + const record = { disabled: 0 }; + created.push(record); + return { + getStore: () => native.getStore(), + enterWith: (store) => { + active.add(record); + native.enterWith(store); + }, + run: (store, operation) => { + active.add(record); + return native.run(store, operation); + }, + disable: () => { + record.disabled++; + active.delete(record); + native.disable?.(); + }, + }; + }, + }, + }); + // No wall-clock threshold or forced GC: enabled context count is the + // invariant, including on runtimes with a faster ALS implementation. + for (let index = 0; index < 100; index++) { + const database = new Database({ + close: () => Promise.resolve(), + execute: () => Promise.resolve(), + queryAll: () => Promise.resolve([]), + queryOne: () => Promise.resolve(undefined), + run: () => Promise.resolve(), + }); + await database.transaction(async () => { + expect(active.size).toBe(1); + await database.run("INSERT"); + }); + await database.close(); + await database.close(); + expect(active.size).toBe(0); + } + expect(created).toHaveLength(100); + expect(created.every((record) => record.disabled === 1)).toBe(true); + }); + + it("does not disable a connection's context until a failed native close can be retried", async () => { + const disable = vi.fn(); + installWikiGraphPlatform({ + ...nodeWikiGraphPlatform, + asyncContext: { + create(): HostAsyncContext { + const native = nodeWikiGraphPlatform.asyncContext.create(); + return { + getStore: () => native.getStore(), + enterWith: (store) => native.enterWith(store), + run: (store, operation) => native.run(store, operation), + disable: () => { + disable(); + native.disable?.(); + }, + }; + }, + }, + }); + const close = vi + .fn() + .mockRejectedValueOnce(new Error("close failed")) + .mockResolvedValue(undefined); + const database = new Database({ + close, + execute: () => Promise.resolve(), + queryAll: () => Promise.resolve([]), + queryOne: () => Promise.resolve(undefined), + run: () => Promise.resolve(), + }); + await database.transaction(async () => { + await database.run("INSERT"); + }); + await expect(database.close()).rejects.toThrow("close failed"); + expect(disable).not.toHaveBeenCalled(); + await database.transaction(async () => { + await database.run("INSERT"); + }); + await database.close(); + expect(disable).toHaveBeenCalledOnce(); + }); + + it("keeps older host contexts without disable compatible", async () => { + installWikiGraphPlatform({ + ...nodeWikiGraphPlatform, + asyncContext: { + create(): HostAsyncContext { + const native = nodeWikiGraphPlatform.asyncContext.create(); + // The old host contract has no disposal hook. The host owns its + // implementation; this test releases its own native tracker explicitly. + cleanups.push(() => native.disable?.()); + return { + getStore: () => native.getStore(), + enterWith: (store) => native.enterWith(store), + run: (store, operation) => native.run(store, operation), + }; + }, + }, + }); + const cleanups: Array<() => void> = []; + try { + const database = new Database({ + close: () => Promise.resolve(), + execute: () => Promise.resolve(), + queryAll: () => Promise.resolve([]), + queryOne: () => Promise.resolve(undefined), + run: () => Promise.resolve(), + }); + await database.transaction(async () => { + await database.run("INSERT"); + }); + await database.close(); + } finally { + for (const cleanup of cleanups) cleanup(); + } + }); + + it.each([false, true])( + "releases document contexts even if store close fails (%s)", + async (failClose) => { + const disabled: Array = []; + installWikiGraphPlatform({ + ...nodeWikiGraphPlatform, + asyncContext: { + create(): HostAsyncContext { + const native = nodeWikiGraphPlatform.asyncContext.create(); + const index = disabled.push(false) - 1; + return { + run: (store, operation) => native.run(store, operation), + enterWith: (store) => native.enterWith(store), + getStore: () => native.getStore(), + disable: () => { + disabled[index] = true; + native.disable?.(); + }, + }; + }, + }, + }); + await withTempDir("wg-document-context-", async (root) => { + const store = new DirectoryFileStore(new NodeDirectory(root)); + if (failClose) + vi.spyOn(store, "close").mockRejectedValue( + new Error("store close failed"), + ); + const document = await DirectoryDocument.openFileStore(store); + await document.openSession(async (opened) => { + await opened.serials.listIds(); + }); + if (failClose) + await expect(document.release()).rejects.toThrow( + "store close failed", + ); + else await document.release(); + expect(disabled.length).toBeGreaterThanOrEqual(2); + expect(disabled.every(Boolean)).toBe(true); + }); + }, + ); + + it("closing one context does not destroy another database's nested transaction scope", async () => { + const backend = () => ({ + close: () => Promise.resolve(), + execute: () => Promise.resolve(), + queryAll: () => Promise.resolve([]), + queryOne: () => Promise.resolve(undefined), + run: () => Promise.resolve(), + }); + const first = new Database(backend()); + const second = new Database(backend()); + try { + await first.transaction(async () => { + await second.transaction(async () => { + await first.run("nested write"); + }); + await second.close(); + await first.transaction(async () => { + await first.run("still nested"); + }); + }); + } finally { + await first.close(); + await second.close(); + } + }, 2_000); it.each([true, false])( "does not delete a damaged cache while opening (readonly=%s)", async (readonly) => { diff --git a/test/linux-workflows.md b/test/linux-workflows.md new file mode 100644 index 00000000..bc1251a0 --- /dev/null +++ b/test/linux-workflows.md @@ -0,0 +1,82 @@ +# Linux workflow timeouts: investigation and regression contract + +The original failure is [Full Regression on 4f9c977](https://github.com/oomol-lab/wiki-graph/actions/runs/39231772102/job/117991255926). +It failed **before** packed CLI E2E: two stalled-wikipage isolation cases hit +15 seconds and EPUB import/provenance hit 20 seconds. `ENOTEMPTY` occurred during +teardown. No `EPERM` or `SQLITE_PROTOCOL` appeared. + +## Evidence, not a timeout increase + +[Pre-fix Linux phase tracing](https://github.com/oomol-lab/wiki-graph/actions/runs/39239537784/job/118016732885) +reproduced all three timeouts after fixing teardown ownership. Standalone fixture +creation took 1.295 seconds; the first search during the stalled request took +6.915 seconds, and the next search 14.246 seconds. Searches finished, but each +subsequent operation grew slower. EPUB creation finished in 13.759 seconds; +later provenance reads pushed the combined workflow past 20 seconds. The repaired +teardown no longer removed a workspace while these operations were still active. + +[Same-run Linux comparison](https://github.com/oomol-lab/wiki-graph/actions/runs/39240274309/job/118019146067) +used identical focused fixtures on three combinations: + +| Product sources | SQLite binding | First/second standalone search | Isolation cases | +| -------------------------------- | ---------------------- | ------------------------------ | --------------- | +| 5ca5238, before #784 | official 6.0.1 | 6.137 / 12.409 seconds | both time out | +| 27ee458, before this product fix | official 6.0.1 | 6.562 / 13.221 seconds | both time out | +| 27ee458, before this product fix | pinned AppThreat 7.0.1 | 6.458 / 12.451 seconds | both time out | + +This comparison selects the three relevant cases (eight unrelated cases are +filtered, not acceptance evidence). All three variants retain the same 15/20 +second limits. The EPUB case passes when run in isolation, but also timed out in +the broader pre-fix comparison. The broader experiment and its failures remain +available in [the initial investigation](https://github.com/oomol-lab/wiki-graph/actions/runs/39238789953/job/118014284966). +Diagnostic controls intentionally fail; a green diagnostic job means the +comparison ran, **not** that the tested product passed. + +## Root cause and fix + +Each `Database` and `DirectoryDocument` created its own host async context. +Their transaction/session callbacks enabled Node AsyncLocalStorage tracking, but +close/release did not disable it. In Node's async-hooks implementation, enabled +ALS instances remain tracked until `disable()`; see the +[Node 22 lifetime contract](https://nodejs.org/download/release/v22.18.0/docs/api/async_context.html#asynclocalstoragedisable). +Repeated short-lived coordinator connections thus made later Promise operations +progressively more expensive and retained contexts after resources closed. Node +24's newer async-context implementation hid this on the development machine. +This predates #784; the additional coordinator transactions increase exposure, +but switching between the two native bindings does not remove the defect. + +The product fix releases each context at its resource boundary. A failed native +database close retains its context for retry; successful close disables once. +Document context cleanup still runs when file-store close fails. Host contexts +gain an **optional** synchronous `disable()` hook, preserving older custom hosts. +No SQLite binding, durability, reader fence, schema or runtime requirement changes. + +For a local diagnostic on Node 24, propagate +`NODE_OPTIONS=--no-async-context-frame` to Vitest and its workers (passing the flag +only to the parent does not qualify the child). Before the fix, the two isolation +cases took 22.468 / 25.271 seconds and failed. After the fix, all eleven cases +passed in the same mode; the cases took 1.581 / 1.242 seconds and EPUB import +1.672 seconds. These macOS observations supplement, not replace, Linux acceptance. + +## Regression/CI ownership + +- `database-lifecycle.test.ts` asserts that 100 transaction contexts do not + accumulate enabled trackers; it covers close retry, old hosts, document close + failure and nested transactions across two independent databases. It tests + ownership, not wall-clock speed or forced garbage collection. +- `regression-lifecycle.test.ts` runs a deliberately timed-out child Vitest + fixture. The late write and finally complete before SDK-close/remove callbacks. + Cleanup errors do not hide the original operation failure. Teardown joins the + actual body, not the runner's timeout race, and can cancel pending wikipage I/O. +- `Linux archive workflow regressions` runs both full-regression files on every + PR with JSON evidence. The gate requires both isolation cases and EPUB import, + no missing/empty file, failed assertion, skipped case or todo. It does not + substitute for the complete suite or the existing Windows jobs. +- Scheduled full regression no longer silently succeeds by skipping tests when + main's commit date differs from the scheduler's date. Dispatch runs the selected + ref; scheduled runs use the default branch. It runs full tests, build, then + packed CLI E2E. A failing prerequisite cannot count as E2E success. + +The existing 15/20-second limits remain unchanged. No reader-before-commit, +abort/old-data protection or long-path regression is removed. Release preparation +stays at the unpublished 0.9.6; Home v6, Archive v5 and format v1 do not change. From 0b74b3dc501646f29a6b8adecde026a85afacbd4 Mon Sep 17 00:00:00 2001 From: Tao Zeyu Date: Mon, 19 Oct 2026 18:58:46 +0800 Subject: [PATCH 5/5] fix: retain transaction admission until a closing callback exits --- packages/core/src/document/database.ts | 6 +++++- test/core/document/database-lifecycle.test.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/document/database.ts b/packages/core/src/document/database.ts index 986f6a1f..d72f794f 100644 --- a/packages/core/src/document/database.ts +++ b/packages/core/src/document/database.ts @@ -217,6 +217,7 @@ export class Database { this.#transactionDepth -= 1; if (isRootTransaction) { this.#activeTransactionScope = undefined; + if (this.#closed) this.#operationScope.disable?.(); } } }); @@ -239,7 +240,10 @@ export class Database { // Node's async_hooks implementation retains each enabled ALS instance // until disable(). Short-lived coordinator connections otherwise make // every subsequent Promise progressively more expensive on Node 22. - this.#operationScope.disable?.(); + // A close requested from inside a transaction must keep the reentrant + // scope until its callback exits, so later calls fail as closed instead + // of queuing behind that same callback and deadlocking. + if (this.#transactionDepth === 0) this.#operationScope.disable?.(); }); } diff --git a/test/core/document/database-lifecycle.test.ts b/test/core/document/database-lifecycle.test.ts index 65c3386d..a8f01c62 100644 --- a/test/core/document/database-lifecycle.test.ts +++ b/test/core/document/database-lifecycle.test.ts @@ -215,6 +215,25 @@ describe("database lifecycle", () => { await second.close(); } }, 2_000); + it("retains reentrant admission until a callback that closes its database exits", async () => { + const database = new Database({ + close: () => Promise.resolve(), + execute: () => Promise.resolve(), + queryAll: () => Promise.resolve([]), + queryOne: () => Promise.resolve(undefined), + run: () => Promise.resolve(), + }); + await database.transaction(async () => { + await database.close(); + // Without deferring context disposal, this queued behind its own parent + // transaction forever instead of reaching the existing closed guard. + await expect(database.run("after close")).rejects.toThrow( + "Database is already closed", + ); + }); + await database.close(); + }, 2_000); + it.each([true, false])( "does not delete a damaged cache while opening (readonly=%s)", async (readonly) => {