From b2861527f705fafc9f6954c0552d511b791a9787 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste THERY Date: Tue, 27 Oct 2026 23:38:20 +0700 Subject: [PATCH] fix(release): publish Windows file-lock retries in v6.2.1 Release highlights: - keep indexing reliable on Windows machines where antivirus, search indexing, or sync clients such as OneDrive briefly hold new files Release details: - **Windows reliability:** manifest, state, cache, config, model, and lock-owner writes retry the transient EPERM, EACCES, and EBUSY renames that Windows reports while another process holds a file, for about two seconds, and directory removals retry busy or not-yet-empty directories; other platforms and other errors behave as before - **Tests:** integration test timeouts scale by the factor measured on Windows runners, where spawning git, fsync, and small-file churn cost 9 to 16 times more than on Linux - **Documentation:** the troubleshooting guide explains what to do if Windows still reports EPERM, EBUSY, or ENOTEMPTY Verification: - pass pnpm validate with 540 Core tests and 24 landing tests plus coverage, API, CLI and MCP smoke, builds, packaging, license policy and release smoke checks - pass Quality gate, Linux x64, macOS ARM64 and Windows x64 portability, Commitlint and CodeQL analysis on the fix pull request - measure Windows and Linux runners side by side: a demo ingest takes 409 ms against 147 ms and a warm search 23.5 ms against 14.8 ms, in line with the operating system's process and file costs --- docs/troubleshooting.md | 7 ++ packages/ragmir-core/src/client.test.ts | 5 +- .../ragmir-core/src/config-migration.test.ts | 7 +- .../ragmir-core/src/context-resources.test.ts | 7 +- packages/ragmir-core/src/destroy.ts | 5 +- packages/ragmir-core/src/doctor.test.ts | 9 +- packages/ragmir-core/src/durable-file.ts | 5 +- packages/ragmir-core/src/embeddings.ts | 13 +-- packages/ragmir-core/src/evaluate.test.ts | 5 +- packages/ragmir-core/src/fs-retry.test.ts | 90 +++++++++++++++++++ packages/ragmir-core/src/fs-retry.ts | 63 +++++++++++++ .../src/generation-retention.test.ts | 5 +- .../ragmir-core/src/index-write-lock.test.ts | 5 +- packages/ragmir-core/src/index-write-lock.ts | 15 ++-- packages/ragmir-core/src/mcp.test.ts | 5 +- packages/ragmir-core/src/ocr-cache.ts | 5 +- packages/ragmir-core/src/ocr.ts | 9 +- .../ragmir-core/src/project-config-file.ts | 5 +- packages/ragmir-core/src/query.test.ts | 11 +-- packages/ragmir-core/src/setup.test.ts | 13 +-- packages/ragmir-core/src/skill.ts | 7 +- .../src/source-fingerprint-cache.ts | 5 +- .../ragmir-core/src/storage-maintenance.ts | 5 +- packages/ragmir-core/src/storage-usage.ts | 5 +- packages/ragmir-core/src/store.test.ts | 5 +- .../ragmir-core/src/test-support/timeouts.ts | 10 +++ packages/ragmir-core/src/upgrade.test.ts | 11 +-- packages/ragmir-core/vitest.config.ts | 5 ++ 28 files changed, 270 insertions(+), 72 deletions(-) create mode 100644 packages/ragmir-core/src/fs-retry.test.ts create mode 100644 packages/ragmir-core/src/fs-retry.ts create mode 100644 packages/ragmir-core/src/test-support/timeouts.ts diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 00cdf87e..992a91ec 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -61,6 +61,13 @@ If the durable ingestion state is invalid or inconsistent with the current confi ignores it and starts a new safe run. It does not resume from untrusted table names or staged manifest paths. +## Windows reports `EPERM`, `EBUSY`, or `ENOTEMPTY` + +Antivirus scanners, search indexing, and sync clients such as OneDrive briefly hold new files on +Windows. Ragmir retries the atomic renames and directory removals they block for about two +seconds. If the error persists, keep `.ragmir/` out of synced folders or exclude it from real-time +scanning, then run `rgr ingest` again; the active index stays searchable meanwhile. + ## The activation manifest was recovered If `rgr doctor` reports that the canonical activation manifest is missing or invalid, Ragmir uses diff --git a/packages/ragmir-core/src/client.test.ts b/packages/ragmir-core/src/client.test.ts index fd8506ec..a030c882 100644 --- a/packages/ragmir-core/src/client.test.ts +++ b/packages/ragmir-core/src/client.test.ts @@ -9,6 +9,7 @@ import { isRagmirError, type RagmirError } from "./errors.js" import { initProject } from "./init.js" import { search } from "./query.js" import { INDEX_READ_DIAGNOSTICS_CHANNEL, type IndexReadDiagnosticsEvent } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" import { workloadSnapshot } from "./workload.js" const tempDirs: string[] = [] @@ -32,7 +33,7 @@ async function projectWithEvidence(prefix: string): Promise { return root } -describe("RagmirClient", () => { +describe("RagmirClient", { timeout: integrationTimeout(10_000) }, () => { it("should reuse one client for ingestion and cited retrieval", async () => { const root = await projectWithEvidence("ragmir-client-") const client = await createRagmirClient({ cwd: root }) @@ -169,7 +170,7 @@ describe("RagmirClient", () => { const config = await loadConfig(root) expect(workloadSnapshot(config, "search")).toMatchObject({ active: 0, queued: 0 }) expect(workloadSnapshot(config, "embedding")).toMatchObject({ active: 0, queued: 0 }) - }, 10_000) + }) it("should reject a 100-request burst at the configured queue boundary", async () => { const root = await projectWithEvidence("ragmir-client-overload-") diff --git a/packages/ragmir-core/src/config-migration.test.ts b/packages/ragmir-core/src/config-migration.test.ts index e35998d5..37e4296b 100644 --- a/packages/ragmir-core/src/config-migration.test.ts +++ b/packages/ragmir-core/src/config-migration.test.ts @@ -7,6 +7,7 @@ import { ingest } from "./ingest.js" import { initProject } from "./init.js" import { expandCitation, search } from "./query.js" import { readIndexManifest, writeIndexManifest } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" import { inspectUpgrade, upgradeProject } from "./upgrade.js" const roots: string[] = [] @@ -20,7 +21,7 @@ async function fixture() { return { root, configPath: path.join(root, ".ragmir/config.json") } } -describe("source-preserving migration", () => { +describe("source-preserving migration", { timeout: integrationTimeout(20_000) }, () => { it("should refuse silent removal when a legacy config requests masking", async () => { const { root, configPath } = await fixture() const previous = JSON.stringify({ redaction: { enabled: true }, privacyProfile: "private" }) @@ -74,7 +75,7 @@ describe("source-preserving migration", () => { removedKeys: [], backupPath: null, }) - }, 20_000) + }) it("should keep formerly blocked external operations disabled when migrating strict mode", async () => { const { root, configPath } = await fixture() @@ -99,5 +100,5 @@ describe("source-preserving migration", () => { mcpMaxTopK: 5, mcpMaxOutputBytes: 16_384, }) - }, 20_000) + }) }) diff --git a/packages/ragmir-core/src/context-resources.test.ts b/packages/ragmir-core/src/context-resources.test.ts index 3a5ed490..3d3f12fe 100644 --- a/packages/ragmir-core/src/context-resources.test.ts +++ b/packages/ragmir-core/src/context-resources.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest" import { getKnowledgeBaseContext, getKnowledgeBaseSourceCatalog } from "./context-resources.js" import { ingest } from "./ingest.js" import { initProject } from "./init.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] @@ -14,7 +15,7 @@ afterEach(async () => { } }) -describe("context resources", () => { +describe("context resources", { timeout: integrationTimeout(15_000) }, () => { it("should stop context diagnostics when the signal is already aborted", async () => { const controller = new AbortController() controller.abort("cancelled by caller") @@ -73,7 +74,7 @@ describe("context resources", () => { expect((await getKnowledgeBaseContext(secondRoot)).corpusFingerprint).not.toBe( first.corpusFingerprint, ) - }, 15_000) + }) it("should cap source details while preserving complete totals", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-source-resource-")) @@ -104,5 +105,5 @@ describe("context resources", () => { expect(secondPage.indexedFiles).toHaveLength(5) expect(secondPage.indexedFiles[0]?.source).toBe(".ragmir/raw/source-50.md") expect(secondPage.page).toEqual({ offset: 50, limit: 10, nextOffset: null }) - }, 15_000) + }) }) diff --git a/packages/ragmir-core/src/destroy.ts b/packages/ragmir-core/src/destroy.ts index 11ac0414..d4bb751c 100644 --- a/packages/ragmir-core/src/destroy.ts +++ b/packages/ragmir-core/src/destroy.ts @@ -1,10 +1,11 @@ import { existsSync } from "node:fs" -import { realpath, rm } from "node:fs/promises" +import { realpath } from "node:fs/promises" import os from "node:os" import path from "node:path" import { loadConfig } from "./config.js" import { INDEX_MANIFEST_FILENAME } from "./defaults.js" import { disposeTransformersCache } from "./embeddings.js" +import { removeTree } from "./fs-retry.js" import { withIndexWriteLock } from "./index-write-lock.js" import { readIngestionState } from "./ingestion-state.js" import { operationSignal } from "./operation.js" @@ -35,7 +36,7 @@ export async function destroyIndex( hasIngestionState, hasFingerprintCache, ) - await rm(config.storageDir, { recursive: true, force: true }) + await removeTree(config.storageDir) // Release any cached Transformers.js pipelines so a subsequent re-ingest with // a different embedding config does not pin stale ONNX weights in memory. await disposeTransformersCache() diff --git a/packages/ragmir-core/src/doctor.test.ts b/packages/ragmir-core/src/doctor.test.ts index 529c6aef..e76ba5a4 100644 --- a/packages/ragmir-core/src/doctor.test.ts +++ b/packages/ragmir-core/src/doctor.test.ts @@ -7,6 +7,7 @@ import { doctor } from "./doctor.js" import { ingest } from "./ingest.js" import { initProject } from "./init.js" import { installAgentSkills, installSkill } from "./skill.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] @@ -16,7 +17,7 @@ afterEach(async () => { } }) -describe("doctor", () => { +describe("doctor", { timeout: integrationTimeout(30_000) }, () => { it("should stop diagnostics when the signal is already aborted", async () => { const controller = new AbortController() controller.abort("cancelled by caller") @@ -71,7 +72,7 @@ describe("doctor", () => { expect(withEvidence.nextSteps).toContain( "Run `pnpm exec rgr doctor --fix` to rebuild stale or missing index data.", ) - }, 15_000) + }) it("reports files added or edited after ingestion instead of trusting the ingest snapshot", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-freshness-")) @@ -106,7 +107,7 @@ describe("doctor", () => { await ingest({ cwd: root }) const reindexed = await doctor(root) expect(reindexed.sourceFreshness).toMatchObject({ missingFromIndex: 0, staleInIndex: 0 }) - }, 30_000) + }) it("recommends the semantic setup shortcut after the index is ready", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-")) @@ -153,7 +154,7 @@ describe("doctor", () => { const integrated = await doctor(root) expect(integrated.agentIntegration.projectAgents).toContain("codex") expect(integrated.agentIntegration.ready).toBe(integrated.agentIntegration.runnerReady) - }, 15_000) + }) it("does not report complete coverage when a supported file yields no text", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-doctor-empty-")) diff --git a/packages/ragmir-core/src/durable-file.ts b/packages/ragmir-core/src/durable-file.ts index d8c30481..bfba138d 100644 --- a/packages/ragmir-core/src/durable-file.ts +++ b/packages/ragmir-core/src/durable-file.ts @@ -1,7 +1,8 @@ import { AsyncLocalStorage } from "node:async_hooks" import { randomUUID } from "node:crypto" import type { FileHandle } from "node:fs/promises" -import { open, rename, rm } from "node:fs/promises" +import { open, rm } from "node:fs/promises" +import { renameWithRetry } from "./fs-retry.js" import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js" export type DurableWritePhase = "before-write" | "before-sync" | "before-rename" | "after-rename" @@ -41,7 +42,7 @@ export async function writePrivateFileAtomic( await handle.close() handle = undefined await notifyPhase(targetPath, temporaryPath, "before-rename") - await rename(temporaryPath, targetPath) + await renameWithRetry(temporaryPath, targetPath) await syncDirectory(directory) await notifyPhase(targetPath, temporaryPath, "after-rename") } finally { diff --git a/packages/ragmir-core/src/embeddings.ts b/packages/ragmir-core/src/embeddings.ts index 80cf8a2a..112780aa 100644 --- a/packages/ragmir-core/src/embeddings.ts +++ b/packages/ragmir-core/src/embeddings.ts @@ -1,8 +1,9 @@ import { createHash, randomUUID } from "node:crypto" import { createReadStream } from "node:fs" -import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises" +import { mkdir, mkdtemp, readdir, stat } from "node:fs/promises" import path from "node:path" import { MAX_EMBEDDING_BATCH_SIZE } from "./defaults.js" +import { removeTree, renameWithRetry } from "./fs-retry.js" import { throwIfAborted } from "./operation.js" import { ensurePrivateDirectory } from "./permissions.js" import { tokenize } from "./text.js" @@ -145,7 +146,7 @@ export async function pullEmbeddingModel(config: Config): Promise false, ) if (hadPrevious) { - await rename(target, previous) + await renameWithRetry(target, previous) } try { - await rename(replacement, target) + await renameWithRetry(replacement, target) } catch (error) { if (hadPrevious) { - await rename(previous, target) + await renameWithRetry(previous, target) } throw error } if (hadPrevious) { - await rm(previous, { recursive: true, force: true }) + await removeTree(previous) } } diff --git a/packages/ragmir-core/src/evaluate.test.ts b/packages/ragmir-core/src/evaluate.test.ts index 2e3a655a..3a3535be 100644 --- a/packages/ragmir-core/src/evaluate.test.ts +++ b/packages/ragmir-core/src/evaluate.test.ts @@ -23,6 +23,7 @@ import { readIndexManifest, writeIndexManifest, } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -33,7 +34,7 @@ afterEach(async () => { } }) -describe("evaluateGoldenQueries", () => { +describe("evaluateGoldenQueries", { timeout: integrationTimeout(15_000) }, () => { it("should stop before reading a golden file when the signal is already aborted", async () => { const controller = new AbortController() controller.abort("cancelled by caller") @@ -233,7 +234,7 @@ describe("evaluateGoldenQueries", () => { } finally { unsubscribe(INDEX_READ_DIAGNOSTICS_CHANNEL, onDiagnostic) } - }, 15_000) + }) it("caps query topK when a caller provides a maximum", async () => { const parent = await mkdtemp(path.join(os.tmpdir(), "ragmir-evaluate-cap-")) diff --git a/packages/ragmir-core/src/fs-retry.test.ts b/packages/ragmir-core/src/fs-retry.test.ts new file mode 100644 index 00000000..f88d48de --- /dev/null +++ b/packages/ragmir-core/src/fs-retry.test.ts @@ -0,0 +1,90 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" +import { removeTree, renameWithRetry } from "./fs-retry.js" + +const tempDirs: string[] = [] + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }) + } +}) + +function systemError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: simulated`), { code }) +} + +describe("Windows-tolerant file operations", () => { + it("retries a rename that Windows refuses while another process holds the file", async () => { + const failures = ["EPERM", "EBUSY", "EACCES"] + const attempts: string[] = [] + + await renameWithRetry("source", "target", { + platform: "win32", + delaysMs: [0, 0, 0, 0], + renameFile: async () => { + const code = failures.shift() + attempts.push(code ?? "renamed") + if (code) { + throw systemError(code) + } + }, + }) + + expect(attempts).toEqual(["EPERM", "EBUSY", "EACCES", "renamed"]) + }) + + it("fails at once on other platforms and errors, and after the last Windows retry", async () => { + let calls = 0 + const failWith = (code: string) => async (): Promise => { + calls += 1 + throw systemError(code) + } + + await expect( + renameWithRetry("a", "b", { platform: "linux", renameFile: failWith("EPERM") }), + ).rejects.toMatchObject({ code: "EPERM" }) + expect(calls).toBe(1) + + calls = 0 + await expect( + renameWithRetry("a", "b", { + platform: "win32", + delaysMs: [0], + renameFile: failWith("ENOENT"), + }), + ).rejects.toMatchObject({ code: "ENOENT" }) + expect(calls).toBe(1) + + calls = 0 + await expect( + renameWithRetry("a", "b", { + platform: "win32", + delaysMs: [0, 0], + renameFile: failWith("EBUSY"), + }), + ).rejects.toMatchObject({ code: "EBUSY" }) + expect(calls).toBe(3) + }) + + it("renames real files and removes directory trees", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-fs-retry-")) + tempDirs.push(root) + const source = path.join(root, "state.json.tmp") + const target = path.join(root, "state.json") + await writeFile(target, "old\n") + await writeFile(source, "new\n") + + await renameWithRetry(source, target) + + expect(await readFile(target, "utf8")).toBe("new\n") + const tree = path.join(root, "generation", "nested") + await mkdir(tree, { recursive: true }) + await writeFile(path.join(tree, "fragment.lance"), "data") + await removeTree(path.join(root, "generation")) + await removeTree(path.join(root, "missing")) + await expect(stat(path.join(root, "generation"))).rejects.toMatchObject({ code: "ENOENT" }) + }) +}) diff --git a/packages/ragmir-core/src/fs-retry.ts b/packages/ragmir-core/src/fs-retry.ts new file mode 100644 index 00000000..0c3ca05e --- /dev/null +++ b/packages/ragmir-core/src/fs-retry.ts @@ -0,0 +1,63 @@ +import { rename, rm } from "node:fs/promises" + +// Antivirus scanners, search indexers, and sync clients briefly open new files on Windows without +// sharing them for deletion. A rename or removal then fails although nothing is wrong, so these +// sharing violations are retried with a short backoff, as npm's graceful-fs does. +const WINDOWS_SHARING_VIOLATION_CODES = new Set(["EACCES", "EBUSY", "EPERM"]) +const RENAME_RETRY_DELAYS_MS = [20, 40, 80, 160, 320, 640, 1_000] +const REMOVE_RETRIES = 5 +const REMOVE_RETRY_DELAY_MS = 100 + +export interface RenameRetryOptions { + platform?: NodeJS.Platform + delaysMs?: readonly number[] + renameFile?: (source: string, target: string) => Promise +} + +/** + * Atomic rename that survives the transient sharing violations Windows reports while another + * process holds the source or the target. Other platforms and other errors fail on the first try. + */ +export async function renameWithRetry( + source: string, + target: string, + options: RenameRetryOptions = {}, +): Promise { + const platform = options.platform ?? process.platform + const delays = options.delaysMs ?? RENAME_RETRY_DELAYS_MS + const renameFile = options.renameFile ?? rename + for (let attempt = 0; ; attempt += 1) { + try { + await renameFile(source, target) + return + } catch (error) { + const delay = delays[attempt] + if (delay === undefined || !isWindowsSharingViolation(error, platform)) { + throw error + } + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } +} + +/** + * Remove a file or directory tree, retrying busy or not-yet-empty directories with Node's linear + * backoff: a scanner or a writer that is still finishing can hold entries for a moment. + */ +export async function removeTree(target: string): Promise { + await rm(target, { + recursive: true, + force: true, + maxRetries: REMOVE_RETRIES, + retryDelay: REMOVE_RETRY_DELAY_MS, + }) +} + +function isWindowsSharingViolation(error: unknown, platform: NodeJS.Platform): boolean { + return ( + platform === "win32" && + error instanceof Error && + "code" in error && + WINDOWS_SHARING_VIOLATION_CODES.has(String(error.code)) + ) +} diff --git a/packages/ragmir-core/src/generation-retention.test.ts b/packages/ragmir-core/src/generation-retention.test.ts index 5eb3a6ce..7066891e 100644 --- a/packages/ragmir-core/src/generation-retention.test.ts +++ b/packages/ragmir-core/src/generation-retention.test.ts @@ -13,6 +13,7 @@ import { generationTableName } from "./ingestion-state.js" import { initProject } from "./init.js" import { connectStore, writeIndexManifest, writeRowsToTable } from "./store.js" import { testConfig } from "./test-support/config.js" +import { integrationTimeout } from "./test-support/timeouts.js" import type { Config } from "./types.js" const tempDirs: string[] = [] @@ -23,7 +24,7 @@ afterEach(async () => { } }) -describe("generation retention", () => { +describe("generation retention", { timeout: integrationTimeout(60_000) }, () => { it("should converge after ten real rebuilds using persisted generation roles", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-generation-rebuilds-")) tempDirs.push(root) @@ -54,7 +55,7 @@ describe("generation retention", () => { } finally { connection.close() } - }, 60_000) + }) it("should bound ten rebuild generations after the reader grace period", async () => { const { config, tableNames, activeState } = await generationFixture(10) diff --git a/packages/ragmir-core/src/index-write-lock.test.ts b/packages/ragmir-core/src/index-write-lock.test.ts index e767bfd6..de96cd7a 100644 --- a/packages/ragmir-core/src/index-write-lock.test.ts +++ b/packages/ragmir-core/src/index-write-lock.test.ts @@ -12,6 +12,7 @@ import { audit } from "./ingest.js" import { readIngestionState } from "./ingestion-state.js" import { initProject } from "./init.js" import { readIndexManifest, readRows } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] const childProcesses = new Set() @@ -31,7 +32,7 @@ afterEach(async () => { } }) -describe("withIndexWriteLock", () => { +describe("withIndexWriteLock", { timeout: integrationTimeout(30_000) }, () => { it("should let a queued caller abort without interrupting the active writer", async () => { const storageDir = await temporaryStorage("ragmir-lock-abort-") let markWriterStarted: (() => void) | undefined @@ -215,7 +216,7 @@ describe("withIndexWriteLock", () => { staleInIndex: [], totalChunks: rows.length, }) - }, 30_000) + }) }) interface ChildEvent { diff --git a/packages/ragmir-core/src/index-write-lock.ts b/packages/ragmir-core/src/index-write-lock.ts index 58df6fa9..ca3b9c90 100644 --- a/packages/ragmir-core/src/index-write-lock.ts +++ b/packages/ragmir-core/src/index-write-lock.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto" -import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises" +import { mkdir, open, readFile, rm, stat } from "node:fs/promises" import { hostname } from "node:os" import path from "node:path" import { RagmirError } from "./errors.js" +import { removeTree, renameWithRetry } from "./fs-retry.js" import { isRecord } from "./guards.js" import { throwIfAborted } from "./operation.js" import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js" @@ -142,7 +143,7 @@ async function tryCreateLease( await writeOwner(lockPath, owner) return createLease(lockPath, owner, heartbeatIntervalMs) } catch (error) { - await rm(lockPath, { recursive: true, force: true }) + await removeTree(lockPath) throw error } } @@ -182,7 +183,7 @@ function createLease( await heartbeat.catch(() => undefined) const current = await readOwner(lockPath) if (current?.ownerToken === owner.ownerToken) { - await rm(lockPath, { recursive: true, force: true }) + await removeTree(lockPath) } } finally { unregisterSignalCleanup() @@ -218,7 +219,7 @@ async function recoverAbandonedWriterLock( ) { return false } - await rm(lockPath, { recursive: true, force: true }) + await removeTree(lockPath) return true } finally { await removeOwnedDirectory(recoveryPath, recoveryOwner.ownerToken) @@ -255,7 +256,7 @@ async function tryCreateRecoveryLock(recoveryPath: string): Promise { const current = await readOwner(directory) if (ownerToken === undefined || current?.ownerToken === ownerToken) { - await rm(directory, { recursive: true, force: true }) + await removeTree(directory) } } @@ -311,7 +312,7 @@ async function writeOwner(directory: string, owner: IndexWriteLockOwner): Promis await handle.close() handle = undefined await hardenPrivateFile(temporaryPath) - await rename(temporaryPath, ownerPath) + await renameWithRetry(temporaryPath, ownerPath) } finally { await handle?.close() await rm(temporaryPath, { force: true }) diff --git a/packages/ragmir-core/src/mcp.test.ts b/packages/ragmir-core/src/mcp.test.ts index d7ae5e62..d5a6fbc0 100644 --- a/packages/ragmir-core/src/mcp.test.ts +++ b/packages/ragmir-core/src/mcp.test.ts @@ -19,6 +19,7 @@ import { resolveMcpProjectRoot, searchOptions, } from "./mcp.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] const connections: Array<{ client: Client; server: McpServer }> = [] @@ -140,7 +141,7 @@ describe("connectMcpServer", () => { }) }) -describe("MCP protocol contract", () => { +describe("MCP protocol contract", { timeout: integrationTimeout(15_000) }, () => { it("should carry evidence identity from compact search to checked expansion", async () => { const root = await createProject("ragmir-mcp-evidence-") const raw = path.join(root, ".ragmir", "raw") @@ -467,7 +468,7 @@ describe("MCP protocol contract", () => { }) const detailedAudit = await jsonToolResult(client, "ragmir_audit", { detail: true }) expect(detailedAudit).toMatchObject({ totalChunks: 1, missingFromIndex: [] }) - }, 15_000) + }) it("should bound audit and resource payloads", async () => { const root = await createProject("ragmir-mcp-budget-", { diff --git a/packages/ragmir-core/src/ocr-cache.ts b/packages/ragmir-core/src/ocr-cache.ts index 6ea31d28..92b934b9 100644 --- a/packages/ragmir-core/src/ocr-cache.ts +++ b/packages/ragmir-core/src/ocr-cache.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto" -import { open as openFile, readFile, rename, rm, stat } from "node:fs/promises" +import { open as openFile, readFile, rm, stat } from "node:fs/promises" import path from "node:path" +import { renameWithRetry } from "./fs-retry.js" import { isRecord } from "./guards.js" import { MAX_EXTERNAL_TEXT_STDIO_BYTES } from "./limits.js" import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js" @@ -87,7 +88,7 @@ export async function writePdfOcrCache( await handle.close() } await hardenPrivateFile(temporaryPath) - await rename(temporaryPath, targetPath) + await renameWithRetry(temporaryPath, targetPath) } finally { await rm(temporaryPath, { force: true }) } diff --git a/packages/ragmir-core/src/ocr.ts b/packages/ragmir-core/src/ocr.ts index c4785f38..f99ff7be 100644 --- a/packages/ragmir-core/src/ocr.ts +++ b/packages/ragmir-core/src/ocr.ts @@ -1,10 +1,11 @@ import { spawn } from "node:child_process" import { createHash } from "node:crypto" import { createReadStream, existsSync } from "node:fs" -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { findProjectConfig, loadConfig } from "./config.js" +import { removeTree } from "./fs-retry.js" import { initProject } from "./init.js" import { MAX_EXTERNAL_TEXT_STDIO_BYTES, MAX_PDF_PAGES } from "./limits.js" import { rgrCommand } from "./package-manager.js" @@ -249,7 +250,7 @@ export async function extractPdfPages( ), } } finally { - await rm(tempDir, { recursive: true, force: true }) + await removeTree(tempDir) } } @@ -886,7 +887,7 @@ export async function extractImageText(options: { return result.stdout } finally { if (workDir) { - await rm(workDir, { recursive: true, force: true }) + await removeTree(workDir) } } } @@ -922,7 +923,7 @@ export async function extractLegacyWordText(options: { ) return await readFile(output, "utf8") } finally { - await rm(workDir, { recursive: true, force: true }) + await removeTree(workDir) } } } diff --git a/packages/ragmir-core/src/project-config-file.ts b/packages/ragmir-core/src/project-config-file.ts index 7e01a2de..b141fb78 100644 --- a/packages/ragmir-core/src/project-config-file.ts +++ b/packages/ragmir-core/src/project-config-file.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto" import { existsSync } from "node:fs" -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" import path from "node:path" +import { renameWithRetry } from "./fs-retry.js" import { isRecord } from "./guards.js" export interface ProjectConfigLocation { @@ -76,7 +77,7 @@ async function writeProjectConfigObject( flag: "wx", mode: 0o600, }) - await rename(temporaryPath, configPath) + await renameWithRetry(temporaryPath, configPath) } finally { await rm(temporaryPath, { force: true }) } diff --git a/packages/ragmir-core/src/query.test.ts b/packages/ragmir-core/src/query.test.ts index 65bfb721..9d84479b 100644 --- a/packages/ragmir-core/src/query.test.ts +++ b/packages/ragmir-core/src/query.test.ts @@ -16,6 +16,7 @@ import { vectorCandidateLimit, } from "./query.js" import { openRowsTable } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" const tempDirs: string[] = [] const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") @@ -26,7 +27,7 @@ afterEach(async () => { } }) -describe("search", () => { +describe("search", { timeout: integrationTimeout(20_000) }, () => { it("should preserve long-query constraints and report the effective query", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-long-query-")) tempDirs.push(root) @@ -544,7 +545,7 @@ describe("search", () => { }) expect(fuzzy[0]?.text).toContain("BENCH-IDENTIFIER-14") expect(fuzzy[0]?.score?.lexicalQueryVariants).toBeGreaterThan(1) - }, 15_000) + }) it("should let hyphenated question words boost keyword matches without filtering them", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-query-hyphenated-words-")) @@ -576,7 +577,7 @@ describe("search", () => { expect(thyroid?.score?.lexicalBackend).toBe("fts") expect(thyroid?.score?.lexicalRank).toBeGreaterThan(0) - }, 15_000) + }) it("should scan a complete lexical fallback in bounded batches", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-query-truncated-fallback-")) @@ -638,7 +639,7 @@ describe("search", () => { lexicalExactPathMatch: true, }) expect(vectorCandidateLimit(1)).toBeLessThan(90) - }, 20_000) + }) it.each([false, true])( "should retrieve a basename when text omits it and fallback is %s", @@ -732,7 +733,7 @@ describe("search", () => { ) expect(result?.score?.lexicalScanBatches).toBe(2 * Math.ceil(indexed.chunks / 19)) expect(result?.score?.lexicalCoverage).toBe(1) - }, 20_000) + }) it("should retrieve later Markdown chunks through their heading context", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-query-markdown-context-")) diff --git a/packages/ragmir-core/src/setup.test.ts b/packages/ragmir-core/src/setup.test.ts index 739939b6..95acac54 100644 --- a/packages/ragmir-core/src/setup.test.ts +++ b/packages/ragmir-core/src/setup.test.ts @@ -4,6 +4,7 @@ import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it, vi } from "vitest" import { repairProject, setupProject } from "./setup.js" +import { integrationTimeout } from "./test-support/timeouts.js" vi.mock("@huggingface/transformers", async () => { const env: { cacheDir?: string; allowRemoteModels?: boolean } = {} @@ -34,7 +35,7 @@ afterEach(async () => { } }) -describe("setupProject", () => { +describe("setupProject", { timeout: integrationTimeout(15_000) }, () => { it("initializes, installs the agent kit, and reports next steps without documents", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-setup-")) tempDirs.push(root) @@ -74,7 +75,7 @@ describe("setupProject", () => { "opencode", "cline", ]) - }, 15_000) + }) it("can preload and enable semantic embeddings during setup", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-setup-semantic-")) @@ -111,7 +112,7 @@ describe("setupProject", () => { expect(config.embeddingModelDigest).toMatch(/^sha256:[0-9a-f]{64}$/u) expect(config.embeddingModelPath).toBe(".ragmir/models") expect(config.transformersAllowRemoteModels).toBe(false) - }, 15_000) + }) it("auto-ingests supported files when the privacy posture is clean", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-setup-")) @@ -135,7 +136,7 @@ describe("setupProject", () => { expect(second.nextSteps).toContain( "Restart or reload the selected agents so they discover the installed Ragmir skills.", ) - }, 15_000) + }) it("passes targeted MCP helper options to the generated agent kit", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-setup-")) @@ -162,7 +163,7 @@ describe("setupProject", () => { }) }) -describe("repairProject", () => { +describe("repairProject", { timeout: integrationTimeout(15_000) }, () => { it("keeps the agent integrations a project already exposes", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-repair-agents-")) tempDirs.push(root) @@ -178,7 +179,7 @@ describe("repairProject", () => { expect(existsSync(path.join(root, directory))).toBe(false) } expect(await readFile(path.join(root, ".gitignore"), "utf8")).not.toContain(".kimi/") - }, 15_000) + }) it.skipIf(process.platform === "win32")( "restores owner-only permissions on Ragmir-owned paths", diff --git a/packages/ragmir-core/src/skill.ts b/packages/ragmir-core/src/skill.ts index d54f1787..b1345208 100644 --- a/packages/ragmir-core/src/skill.ts +++ b/packages/ragmir-core/src/skill.ts @@ -15,6 +15,7 @@ import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" import { DEFAULT_SKILL_TARGET_DIR, RAGMIR_DIR, RAGMIR_PROJECT_ROOT_ENV } from "./defaults.js" +import { removeTree } from "./fs-retry.js" import { ensureRagmirGitignore } from "./gitignore.js" import { knowledgeBaseIdentity } from "./knowledge-bases.js" import { inspectMcpRegistrations, mcpRegistrationWarnings } from "./mcp-registrations.js" @@ -570,7 +571,7 @@ async function removeRetiredSkills(directory: string, ragmirOwned: boolean): Pro /[\\/]\.ragmir[\\/]skills[\\/]/u.test(await readlink(target).catch(() => ""))) || (stats.isDirectory() && (await readManagedSkillMetadata(target))?.managedBy === "ragmir") if (managed) { - await rm(target, { recursive: true, force: true }) + await removeTree(target) removed.push(target) } } @@ -639,7 +640,7 @@ async function copyAgentSkills( const source = path.join(sourceDir, skillName) const target = path.join(targetDir, skillName) await assertManagedSkillTarget(source, target, skillName, force) - await rm(target, { recursive: true, force: true }) + await removeTree(target) await cp(source, target, { recursive: true, force: true }) await writeFile( path.join(target, MANAGED_SKILL_METADATA_FILENAME), @@ -661,7 +662,7 @@ async function replaceWithDirectorySymlink( return } await assertManagedSkillTarget(source, target, skillName, force) - await rm(target, { recursive: true, force: true }) + await removeTree(target) await symlink(source, target, process.platform === "win32" ? "junction" : "dir") } diff --git a/packages/ragmir-core/src/source-fingerprint-cache.ts b/packages/ragmir-core/src/source-fingerprint-cache.ts index 2d4316fe..abcec664 100644 --- a/packages/ragmir-core/src/source-fingerprint-cache.ts +++ b/packages/ragmir-core/src/source-fingerprint-cache.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto" import { createReadStream } from "node:fs" -import { open as openFile, rename, rm } from "node:fs/promises" +import { open as openFile, rm } from "node:fs/promises" import path from "node:path" +import { renameWithRetry } from "./fs-retry.js" import { isRecord } from "./guards.js" import { ensurePrivateDirectory, hardenPrivateFile } from "./permissions.js" import type { Config, SourceFingerprintMode } from "./types.js" @@ -139,7 +140,7 @@ export async function writeSourceFingerprintCache( await handle.close() } await hardenPrivateFile(temporaryPath) - await rename(temporaryPath, targetPath) + await renameWithRetry(temporaryPath, targetPath) } finally { await rm(temporaryPath, { force: true }) } diff --git a/packages/ragmir-core/src/storage-maintenance.ts b/packages/ragmir-core/src/storage-maintenance.ts index 9f944f62..fb4592ff 100644 --- a/packages/ragmir-core/src/storage-maintenance.ts +++ b/packages/ragmir-core/src/storage-maintenance.ts @@ -1,9 +1,10 @@ import { randomUUID } from "node:crypto" -import { readFile, rename, rm, writeFile } from "node:fs/promises" +import { readFile, rm, writeFile } from "node:fs/promises" import path from "node:path" import type { Connection, OptimizeStats, Table } from "@lancedb/lancedb" import * as lancedb from "@lancedb/lancedb" import { loadConfig } from "./config.js" +import { renameWithRetry } from "./fs-retry.js" import { isRecord } from "./guards.js" import { withIndexWriteLock } from "./index-write-lock.js" import { operationSignal } from "./operation.js" @@ -545,7 +546,7 @@ async function writeMaintenanceState( try { await writeFile(temporary, JSON.stringify(state, null, 2), "utf8") await hardenPrivateFile(temporary) - await rename(temporary, target) + await renameWithRetry(temporary, target) } finally { await rm(temporary, { force: true }) } diff --git a/packages/ragmir-core/src/storage-usage.ts b/packages/ragmir-core/src/storage-usage.ts index dc2d4ab9..e8bd9b24 100644 --- a/packages/ragmir-core/src/storage-usage.ts +++ b/packages/ragmir-core/src/storage-usage.ts @@ -1,6 +1,7 @@ -import { lstat, readdir, rm } from "node:fs/promises" +import { lstat, readdir } from "node:fs/promises" import path from "node:path" import { RAGMIR_DIR } from "./defaults.js" +import { removeTree } from "./fs-retry.js" import type { Config } from "./types.js" export type StorageOrphanKind = "model" | "storage-copy" | "retired-output" @@ -96,7 +97,7 @@ export async function findStorageOrphans(config: Config): Promise { const removed: string[] = [] for (const orphan of orphans) { - await rm(orphan.path, { recursive: true, force: true }) + await removeTree(orphan.path) removed.push(orphan.path) } return removed diff --git a/packages/ragmir-core/src/store.test.ts b/packages/ragmir-core/src/store.test.ts index e1ea86e5..c9e9d4fd 100644 --- a/packages/ragmir-core/src/store.test.ts +++ b/packages/ragmir-core/src/store.test.ts @@ -22,6 +22,7 @@ import { writeRowsToTable, } from "./store.js" import { testConfig } from "./test-support/config.js" +import { integrationTimeout } from "./test-support/timeouts.js" import type { IndexManifest } from "./types.js" const tempDirs: string[] = [] @@ -77,7 +78,7 @@ async function fullTextEvidence(table: NonNullable { +describe("store", { timeout: integrationTimeout(30_000) }, () => { it("round-trips vector rows from LanceDB as plain numeric arrays", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-store-")) tempDirs.push(root) @@ -316,7 +317,7 @@ describe("store", () => { ) await expect(table.countRows()).resolves.toBe(80) await expect(fullTextEvidence(table)).resolves.toEqual([".ragmir/raw/source-0.md:1-1"]) - }, 30_000) + }) it("should keep the active table readable when optional compaction fails", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "ragmir-store-maintenance-failure-")) diff --git a/packages/ragmir-core/src/test-support/timeouts.ts b/packages/ragmir-core/src/test-support/timeouts.ts new file mode 100644 index 00000000..2df8ff1e --- /dev/null +++ b/packages/ragmir-core/src/test-support/timeouts.ts @@ -0,0 +1,10 @@ +// Ingest-heavy tests run about three times slower on Windows. Measured on GitHub runners, spawning +// git costs 16 times more there, an fsync 9 times more, and creating and removing small files 9 +// times more, so ingesting the demo corpus takes 409 ms against 147 ms on Linux. Test timeouts are +// calibrated on Linux and macOS, then scaled on Windows; vitest.config.ts scales the defaults. +export const PLATFORM_TIMEOUT_FACTOR = process.platform === "win32" ? 3 : 1 + +/** Timeout for an integration test that ingests, rebuilds, or migrates an index. */ +export function integrationTimeout(milliseconds: number): number { + return milliseconds * PLATFORM_TIMEOUT_FACTOR +} diff --git a/packages/ragmir-core/src/upgrade.test.ts b/packages/ragmir-core/src/upgrade.test.ts index b7e8194c..99fd6e97 100644 --- a/packages/ragmir-core/src/upgrade.test.ts +++ b/packages/ragmir-core/src/upgrade.test.ts @@ -9,6 +9,7 @@ import { ingest } from "./ingest.js" import { initProject } from "./init.js" import { search } from "./query.js" import { readIndexManifest, writeIndexManifest } from "./store.js" +import { integrationTimeout } from "./test-support/timeouts.js" import { inspectUpgrade, upgradeProject } from "./upgrade.js" const tempDirs: string[] = [] @@ -19,7 +20,7 @@ afterEach(async () => { } }) -describe("upgrade continuity", () => { +describe("upgrade continuity", { timeout: integrationTimeout(20_000) }, () => { it("should stage and atomically activate a rebuild when the index schema is old", async () => { const root = await upgradeFixture() const config = await loadConfig(root) @@ -53,7 +54,7 @@ describe("upgrade continuity", () => { expect(upgradedManifest?.schemaVersion).toBe(INDEX_SCHEMA_VERSION) expect(upgradedManifest?.corpusFingerprint).toBe(fingerprintBefore) await expect(search("continuity", { cwd: root })).resolves.toHaveLength(1) - }, 20_000) + }) it("should apply current safe defaults when an older config omits newer fields", async () => { const root = await upgradeFixture() @@ -76,7 +77,7 @@ describe("upgrade continuity", () => { action: "none", ready: true, }) - }, 20_000) + }) it("should report privacy warnings as non-blocking upgrade advisories", async () => { const root = await upgradeFixture() @@ -102,7 +103,7 @@ describe("upgrade continuity", () => { reason: null, }) await expect(search("continuity", { cwd: root })).resolves.toHaveLength(1) - }, 20_000) + }) it("should stage a rebuild when an old index has no compatible manifest", async () => { const root = await upgradeFixture() @@ -125,7 +126,7 @@ describe("upgrade continuity", () => { ready: true, }) await expect(search("continuity", { cwd: root })).resolves.toHaveLength(1) - }, 20_000) + }) }) async function upgradeFixture(): Promise { diff --git a/packages/ragmir-core/vitest.config.ts b/packages/ragmir-core/vitest.config.ts index a27a6d6e..3c40e3f0 100644 --- a/packages/ragmir-core/vitest.config.ts +++ b/packages/ragmir-core/vitest.config.ts @@ -1,8 +1,13 @@ import { defineConfig } from "vitest/config" +// Ingest-heavy tests run about three times slower on Windows; see src/test-support/timeouts.ts. +const platformTimeoutFactor = process.platform === "win32" ? 3 : 1 + export default defineConfig({ test: { maxWorkers: 4, + testTimeout: 5_000 * platformTimeoutFactor, + hookTimeout: 10_000 * platformTimeoutFactor, // MCP servers read the agent's workspace from these; tests run inside agent shells too. env: { CLAUDE_PROJECT_DIR: "", RAGMIR_CLIENT_CWD: "" }, coverage: {