From f4ad6eb5819862e62d6522eeda93498585b3144b Mon Sep 17 00:00:00 2001 From: sarahxsanders Date: Fri, 12 Jun 2026 16:46:35 -0400 Subject: [PATCH] fix: retry rule load after a transient failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan() cached the rulesPromise on first call and reused it forever. If loadRules() rejected (transient WASM read failure, momentarily missing rules directory, compile error), the rejected promise stayed cached and every later scan() re-awaited it and threw — permanently breaking scanning for the lifetime of a long-running host process. Clear the cached promise on rejection so the next scan() retries the load, while keeping the success fast-path (compile once, reuse) intact. Generated-By: PostHog Code Task-Id: 5b7b5c21-83c6-4c64-ad90-6bdb30815436 --- src/scanner/__tests__/engine.retry.test.ts | 38 ++++++++++++++++++++++ src/scanner/engine.ts | 7 +++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/scanner/__tests__/engine.retry.test.ts diff --git a/src/scanner/__tests__/engine.retry.test.ts b/src/scanner/__tests__/engine.retry.test.ts new file mode 100644 index 0000000..6e2193f --- /dev/null +++ b/src/scanner/__tests__/engine.retry.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Wrap the real node:fs, but make the first readdirSync call throw to simulate a +// transient, recoverable load failure (the rules directory being momentarily +// unavailable). Every other fs call — including the retry's readdirSync — +// delegates to the real implementation. We mock the module rather than vi.spyOn +// because ESM namespace exports aren't configurable and can't be spied on. +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + let failedOnce = false; + return { + ...actual, + readdirSync: (...args: Parameters) => { + if (!failedOnce) { + failedOnce = true; + throw new Error('ENOENT: rules directory temporarily missing'); + } + return (actual.readdirSync as (...a: unknown[]) => unknown)(...args); + }, + }; +}); + +import { scan } from '../engine.js'; + +// Separate test file on purpose: engine.ts caches the compiled rules in a +// module-level promise that is shared across tests in a single file. Once any +// test loads the rules successfully, the failure path can't be exercised again. +// A fresh test file gets a clean module instance, so the very first scan() here +// is the one that triggers the (forced-to-fail) load. +describe('engine.scan rule-load recovery', () => { + it('retries rule loading after a transient failure', async () => { + await expect(scan('anything')).rejects.toThrow(/rules directory/); + + // The cache was cleared by the rejection, so this call reloads and succeeds. + const result = await scan('hello world, nothing dangerous here'); + expect(result.matched).toBe(false); + }); +}); diff --git a/src/scanner/engine.ts b/src/scanner/engine.ts index 90e9c48..c1626c0 100644 --- a/src/scanner/engine.ts +++ b/src/scanner/engine.ts @@ -86,7 +86,12 @@ function extractMatchedStrings( */ export async function scan(content: string): Promise { if (!rulesPromise) { - rulesPromise = loadRules(); + // Cache the compile work, but if it fails, clear the cache so the next + // scan() retries instead of replaying a permanently-rejected promise. + rulesPromise = loadRules().catch((err) => { + rulesPromise = null; + throw err; + }); } const rules = await rulesPromise;