From 940b3d5085422e6992d4efc837b07cd5c1005d9a Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 00:24:18 +0200 Subject: [PATCH 1/6] fix(transforms): fence and drain background JSX cache prune passes A scheduled prune runs from a timer callback, so no caller owns its promise and nothing could wait for it. cancelScheduledJsxCachePrunes() cleared the timers it could see, but a pass already suspended on its filesystem scan resumed afterwards and armed the follow-up work its own bookkeeping asked for. The new timer then fired inside whichever test happened to be running, which the leak sanitizer reported against that unrelated test: A timer was started before the test, but completed during the test. at scheduleJsxCachePruneRetry (jsx-cache.ts:1771) at promotePersistedJsxCachePruneRequest (jsx-cache.ts:1537) That is the intermittent "scheduled prune bound" failure: the step named in CI is only the one unlucky enough to be running when a prior test's timer landed, which is why it moved between shards and passed on rerun. - Cancellation is now a fence. Each pass captures a generation counter when it starts and re-arms only while that generation is current, so a pass that resumes after a cancellation declines to schedule the follow-up timer or promotion instead of racing teardown for it. - In-flight passes are retained as promises rather than bare keys, so waitForJsxCacheMaintenance can settle them. Cancelling cannot unwind filesystem work already issued, so teardown has to await it. - waitForJsxCacheMaintenanceForTests is renamed waitForJsxCacheMaintenance: draining the module's background work is the module's own contract, not a test-only affordance. Verified by running the affected file 40 times with no failures; the same loop reproduced the flake once in 30 runs beforehand. Refs veryfront/veryfront-issue-inbox#1466 --- .../mdx/esm-module-loader/jsx-cache.test.ts | 25 +++++- .../mdx/esm-module-loader/jsx-cache.ts | 84 +++++++++++++++---- .../transforms/mdx/loader-module-esm.test.ts | 2 +- .../transforms/mdx/shared-realm-cache.test.ts | 6 +- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts index 6d661be534..7c9fc4435f 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts @@ -2805,12 +2805,12 @@ describe("scheduled prune bound", () => { retirePersistedJsxCachePruneRequest, scheduleJsxCachePruneRetry, scheduledJsxCachePruneCount, - waitForJsxCacheMaintenanceForTests, + waitForJsxCacheMaintenance, } = __jsxCacheInternals; afterEach(async () => { cancelScheduledJsxCachePrunes(); - await waitForJsxCacheMaintenanceForTests(); + await waitForJsxCacheMaintenance(); cancelScheduledJsxCachePrunes(); await clearPersistedJsxCachePruneRequestsForTests(persistedTestPrefix); }); @@ -3250,6 +3250,27 @@ describe("scheduled prune bound", () => { await retirePersistedJsxCachePruneRequest(directory, replacementGeneration); assertEquals(await hasPersistedJsxCachePrune(directory), false); }); + + it("should leave no armed timer when cancellation lands mid-promotion", async () => { + // Arrange: persisted work that a promotion pass will want to schedule. + const directory = `${persistedTestPrefix}cancel-during-promotion`; + await persistJsxCachePruneRequest(directory, Date.now()); + + // Act: cancel while the promotion is suspended on its filesystem scan. + // The pass resumes into a superseded generation and must not arm the + // follow-up timer, which would otherwise fire inside an unrelated test. + const promotion = promotePersistedJsxCachePruneRequest(); + cancelScheduledJsxCachePrunes(); + await promotion; + await waitForJsxCacheMaintenance(); + + // Assert: teardown really did leave the module quiet. + assertEquals( + scheduledJsxCachePruneCount(), + 0, + "a cancelled promotion must not arm a prune timer after teardown drained", + ); + }); }); describe("served artifact memo", () => { diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index 094a88bd17..d96a34470d 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1246,7 +1246,26 @@ const pendingJsxCachePersistence = new IntrinsicMap< >(); let jsxCachePersistencePump: Promise | undefined; let jsxCachePersistenceRetry: ReturnType | undefined; -const inFlightJsxCachePrunes = new IntrinsicSet(); +/** + * In-flight scheduled prune passes, keyed by prune key. + * + * A pass is started from a timer callback, so nothing in the call stack owns + * its promise. Retaining it here is what lets teardown await the pass instead + * of leaving its filesystem work to settle after the caller believed the + * module was quiet. + */ +const inFlightJsxCachePrunes = new IntrinsicMap>(); +/** + * Generation counter for background prune work. + * + * Clearing a timer unschedules a pass that has not started, but a pass already + * running cannot be unscheduled: it resumes at its next await and arms the + * follow-up work its own bookkeeping asks for. Each pass captures the + * generation it began in and re-arms only while that generation is current, so + * cancellation is a fence the running pass observes rather than a race it can + * lose. + */ +let jsxCachePruneGeneration = 0; let persistedJsxCachePrunePromotion: Promise | undefined; let persistedJsxCachePrunePromotionRetry: ReturnType | undefined; const pendingJsxCachePrunePromotionDirectories = new IntrinsicSet(); @@ -1407,6 +1426,7 @@ async function retirePersistedJsxCachePruneRequest( async function promotePersistedJsxCachePruneRequest( requestDirectory = getPersistedJsxCachePruneRequestDirectory(), ): Promise { + const generation = jsxCachePruneGeneration; const queuedCandidates = primordialArraySort( mapEntries(queuedJsxCachePrunes), (left, right) => left[1].fireAtMs - right[1].fireAtMs, @@ -1485,7 +1505,7 @@ async function promotePersistedJsxCachePruneRequest( if ( mapHas(scheduledJsxCachePrunes, pruneKey) || mapHas(queuedJsxCachePrunes, pruneKey) || - setHas(inFlightJsxCachePrunes, pruneKey) + mapHas(inFlightJsxCachePrunes, pruneKey) ) { continue; } @@ -1499,6 +1519,11 @@ async function promotePersistedJsxCachePruneRequest( requestTombstoneRetryAtMs = hostNow() + JSX_CACHE_VARIANT_MIN_AGE_MS; } } + // The scan above is the only awaiting part of this pass. A cancellation that + // landed while it ran has already cleared the timers this pass was promoting + // work into, so arming fresh ones now would reintroduce exactly the timers + // teardown just retired. + if (generation !== jsxCachePruneGeneration) return; let queuedIndex = 0; let persistedIndex = 0; if (mapSize(scheduledJsxCachePrunes) >= MAX_PENDING_JSX_CACHE_PRUNE_DIRECTORIES) { @@ -1575,6 +1600,7 @@ function pumpPersistedJsxCachePrunePromotions(): void { setDelete(pendingJsxCachePrunePromotionDirectories, requestDirectory); activeJsxCachePrunePromotionDirectory = requestDirectory; activeJsxCachePrunePromotionRequestedAgain = false; + const generation = jsxCachePruneGeneration; const promotion = promotePersistedJsxCachePruneRequest(requestDirectory); persistedJsxCachePrunePromotion = promotion; void primordialPromiseThen(promotion, () => { @@ -1584,12 +1610,16 @@ function pumpPersistedJsxCachePrunePromotions(): void { activeJsxCachePrunePromotionDirectory = undefined; activeJsxCachePrunePromotionRequestedAgain = false; persistedJsxCachePrunePromotion = undefined; + // A cancellation during the promotion already emptied the pending set, so + // pumping again here would rebuild the backlog teardown just drained. + if (generation !== jsxCachePruneGeneration) return; pumpPersistedJsxCachePrunePromotions(); }, () => { - setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); activeJsxCachePrunePromotionDirectory = undefined; activeJsxCachePrunePromotionRequestedAgain = false; persistedJsxCachePrunePromotion = undefined; + if (generation !== jsxCachePruneGeneration) return; + setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); persistedJsxCachePrunePromotionRetry = hostSetTimeout(() => { persistedJsxCachePrunePromotionRetry = undefined; pumpPersistedJsxCachePrunePromotions(); @@ -1775,8 +1805,8 @@ function scheduleJsxCachePruneRetry( // the pass schedules can then replace it even when every other slot is // occupied, without overflowing to persistence and racing completion. fired.timer = undefined; - void (async () => { - setAdd(inFlightJsxCachePrunes, pruneKey); + const generation = jsxCachePruneGeneration; + const pass = (async () => { try { await revisitJsxCacheDirectory(esmCacheDir, requestDirectory); const followUp = mapGet(scheduledJsxCachePrunes, pruneKey); @@ -1792,13 +1822,19 @@ function scheduleJsxCachePruneRetry( ); } } finally { - setDelete(inFlightJsxCachePrunes, pruneKey); + mapDelete(inFlightJsxCachePrunes, pruneKey); if (mapGet(scheduledJsxCachePrunes, pruneKey)?.timer === undefined) { mapDelete(scheduledJsxCachePrunes, pruneKey); } - requestPersistedJsxCachePrunePromotion(requestDirectory); + // Requesting a promotion arms the next timer, so it belongs to the + // generation this pass started in. After a cancellation it would hand + // the following test a timer it never scheduled. + if (generation === jsxCachePruneGeneration) { + requestPersistedJsxCachePrunePromotion(requestDirectory); + } } })(); + mapSet(inFlightJsxCachePrunes, pruneKey, pass); }, delayMs); unrefTimer(timer); mapSet(scheduledJsxCachePrunes, pruneKey, { @@ -1835,8 +1871,17 @@ export function ensureJsxCacheSweepArmed(esmCacheDir: string): void { requestPersistedJsxCachePrunePromotion(requestDirectory); } -/** Drop every pending follow-up prune (test isolation only). */ +/** + * Drop every pending follow-up prune and fence the passes already running. + * + * Bumping the generation is what makes this a cancellation rather than a + * sweep: a pass suspended at an await resumes into a superseded generation and + * declines to arm the follow-up timer or promotion it would otherwise schedule. + * Passes still have to be awaited -- see {@link waitForJsxCacheMaintenance} -- + * because this cannot unwind filesystem work already issued. + */ function cancelScheduledJsxCachePrunes(): void { + jsxCachePruneGeneration++; for (const pending of primordialArrayValues(mapValues(scheduledJsxCachePrunes))) { if (pending.timer !== undefined) hostClearTimeout(pending.timer); } @@ -1860,13 +1905,24 @@ function cancelScheduledJsxCachePrunes(): void { mapClear(lazyJsxArtifactExpirations); } -async function waitForJsxCacheMaintenanceForTests(): Promise { +/** + * Settle every background prune pass this module still owns. + * + * Scheduled passes run from timer callbacks, so their promises have no caller + * to await them. Draining them here is what lets a process -- or a test -- know + * the module has stopped touching the filesystem, instead of discovering it + * from a leaked pending promise once the event loop has already resolved. + */ +async function waitForJsxCacheMaintenance(): Promise { while ( - jsxCachePersistencePump !== undefined || persistedJsxCachePrunePromotion !== undefined + jsxCachePersistencePump !== undefined || persistedJsxCachePrunePromotion !== undefined || + mapSize(inFlightJsxCachePrunes) > 0 ) { - await primordialPromiseAllSettled( - [jsxCachePersistencePump, persistedJsxCachePrunePromotion], - ); + await primordialPromiseAllSettled([ + jsxCachePersistencePump, + persistedJsxCachePrunePromotion, + ...primordialArrayValues(mapValues(inFlightJsxCachePrunes)), + ]); } } @@ -2354,6 +2410,6 @@ export const __jsxCacheInternals = { revisitJsxCacheDirectory, servedArtifactMemoSize: (): number => mapSize(servedArtifactTimestamps), withJsxArtifactRefreshSlot, - waitForJsxCacheMaintenanceForTests, + waitForJsxCacheMaintenance, wasJsxArtifactRecentlyServed, }; diff --git a/tests/integration/transforms/mdx/loader-module-esm.test.ts b/tests/integration/transforms/mdx/loader-module-esm.test.ts index ffb14f9bcc..4685955fbe 100644 --- a/tests/integration/transforms/mdx/loader-module-esm.test.ts +++ b/tests/integration/transforms/mdx/loader-module-esm.test.ts @@ -56,7 +56,7 @@ it("rearms idle cleanup when the initial sweep finishes before a transform write } finally { adapter.fs.readFileBytesWithinLimit = read; __jsxCacheInternals.cancelScheduledJsxCachePrunes(); - await __jsxCacheInternals.waitForJsxCacheMaintenanceForTests(); + await __jsxCacheInternals.waitForJsxCacheMaintenance(); await remove(cacheDir, { recursive: true }); const { stop } = await import("veryfront/extensions/bundler"); await stop(); diff --git a/tests/integration/transforms/mdx/shared-realm-cache.test.ts b/tests/integration/transforms/mdx/shared-realm-cache.test.ts index 2244781c46..f1d6fa0f01 100644 --- a/tests/integration/transforms/mdx/shared-realm-cache.test.ts +++ b/tests/integration/transforms/mdx/shared-realm-cache.test.ts @@ -335,7 +335,7 @@ describe("MDX cache shared-realm lifecycle", () => { Promise.prototype.catch = originalCatch; __jsxCacheInternals.releaseJsxArtifact(path); __jsxCacheInternals.cancelScheduledJsxCachePrunes(); - await __jsxCacheInternals.waitForJsxCacheMaintenanceForTests(); + await __jsxCacheInternals.waitForJsxCacheMaintenance(); await remove(dir, { recursive: true }); } assertEquals(pins, 0); @@ -462,7 +462,7 @@ describe("MDX cache shared-realm lifecycle", () => { Promise.prototype.catch = originals.catch; Promise.prototype.finally = originals.finally; __jsxCacheInternals.cancelScheduledJsxCachePrunes(); - await __jsxCacheInternals.waitForJsxCacheMaintenanceForTests(); + await __jsxCacheInternals.waitForJsxCacheMaintenance(); try { freshExists = await stat(artifact).then(() => true, () => false); staleExists = await stat(stale).then(() => true, () => false); @@ -527,7 +527,7 @@ describe("MDX cache shared-realm lifecycle", () => { Array.prototype[Symbol.iterator] = iterator; scope.release(); __jsxCacheInternals.cancelScheduledJsxCachePrunes(); - await __jsxCacheInternals.waitForJsxCacheMaintenanceForTests(); + await __jsxCacheInternals.waitForJsxCacheMaintenance(); await remove(dir, { recursive: true }); } assertEquals(value, 59); From 70729015384a8c71917acb5871cafd624f4422a5 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 00:43:45 +0200 Subject: [PATCH 2/6] fix(transforms): keep post-cancellation promotion requests from stranding Addresses Codex review on #4511. The generation fence was applied too broadly in the promotion pump. When a cancellation retired an active promotion and new work asked for the same request directory before that promise settled, requestPersistedJsxCachePrunePromotion only set activeJsxCachePrunePromotionRequestedAgain. The settle handler then put the directory back in the pending set but returned on the generation mismatch without pumping it, and every later request for that directory short-circuited on "already pending", so the work sat stranded until an unrelated directory happened to start the pump. The flag is already generation-scoped -- cancellation clears it -- so a set flag means the request arrived after the cancellation and is still live: - The fulfilled handler no longer checks the generation at all. It re-queues only what someone asked for again, and arming a timer is fenced inside the promotion itself. - The rejected handler keeps the fence for its own retry, which does belong to the retired generation, but now pumps a request that arrived after the cancellation instead of leaving it pending and unowned. Refs veryfront/veryfront-issue-inbox#1466 --- .../mdx/esm-module-loader/jsx-cache.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index d96a34470d..b8b0c2b9fa 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1610,21 +1610,29 @@ function pumpPersistedJsxCachePrunePromotions(): void { activeJsxCachePrunePromotionDirectory = undefined; activeJsxCachePrunePromotionRequestedAgain = false; persistedJsxCachePrunePromotion = undefined; - // A cancellation during the promotion already emptied the pending set, so - // pumping again here would rebuild the backlog teardown just drained. - if (generation !== jsxCachePruneGeneration) return; + // No generation check: the directory is re-queued above only when someone + // asked for it again, and cancellation clears that flag, so a set flag is + // a live request. Timer arming is fenced inside the promotion itself. pumpPersistedJsxCachePrunePromotions(); }, () => { + const requestedAgain = activeJsxCachePrunePromotionRequestedAgain; activeJsxCachePrunePromotionDirectory = undefined; activeJsxCachePrunePromotionRequestedAgain = false; persistedJsxCachePrunePromotion = undefined; - if (generation !== jsxCachePruneGeneration) return; + if (generation === jsxCachePruneGeneration) { + setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); + persistedJsxCachePrunePromotionRetry = hostSetTimeout(() => { + persistedJsxCachePrunePromotionRetry = undefined; + pumpPersistedJsxCachePrunePromotions(); + }, JSX_CACHE_PRUNE_RETRY_SLACK_MS); + unrefTimer(persistedJsxCachePrunePromotionRetry); + return; + } + // Cancellation retired this pass's own retry. A request that arrived after + // it is still live and must not be stranded in the pending set unpumped. + if (!requestedAgain) return; setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); - persistedJsxCachePrunePromotionRetry = hostSetTimeout(() => { - persistedJsxCachePrunePromotionRetry = undefined; - pumpPersistedJsxCachePrunePromotions(); - }, JSX_CACHE_PRUNE_RETRY_SLACK_MS); - unrefTimer(persistedJsxCachePrunePromotionRetry); + pumpPersistedJsxCachePrunePromotions(); }); } From d6a8c133c334c0508448e4a5c69d34722a86d28d Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 01:00:02 +0200 Subject: [PATCH 3/6] fix(transforms): give overlapping prune passes independent identity Addresses Codex review on #4511. Two passes for one cache directory can overlap. A firing pass keeps its map entry as a reserved slot with no timer, which is exactly what lets a follow-up arm the next timer for the same key before the first pass settles. Keying the in-flight map by prune key therefore let the second pass overwrite the first's promise, so whichever settled first deleted the shared entry and made the other invisible to waitForJsxCacheMaintenance(). The same settle could also delete a newer pass's reserved scheduled entry. - In-flight passes are keyed by a per-pass id, so each pass retires only its own promise and teardown sees every pass that is still running. - The persisted-request scan needs to know whether a key is covered at all, not by whom, so a small reference count replaces the membership check. - The reserved scheduled entry is retired only when it is still the settling pass's own entry and still has no timer. Refs veryfront/veryfront-issue-inbox#1466 --- .../mdx/esm-module-loader/jsx-cache.ts | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index b8b0c2b9fa..684c761218 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1254,7 +1254,31 @@ let jsxCachePersistenceRetry: ReturnType | undefined; * of leaving its filesystem work to settle after the caller believed the * module was quiet. */ -const inFlightJsxCachePrunes = new IntrinsicMap>(); +const inFlightJsxCachePrunes = new IntrinsicMap>(); +let nextJsxCachePrunePassId = 0; +/** + * How many in-flight passes cover each prune key. + * + * Passes for one directory can overlap, so the persisted-request scan needs a + * count rather than a flag: the key is still covered until the last pass + * holding it settles. + */ +const inFlightJsxCachePruneKeys = new IntrinsicMap(); + +function retainInFlightJsxCachePruneKey(pruneKey: string): void { + mapSet( + inFlightJsxCachePruneKeys, + pruneKey, + (mapGet(inFlightJsxCachePruneKeys, pruneKey) ?? 0) + 1, + ); +} + +function releaseInFlightJsxCachePruneKey(pruneKey: string): void { + const held = mapGet(inFlightJsxCachePruneKeys, pruneKey); + if (held === undefined) return; + if (held <= 1) mapDelete(inFlightJsxCachePruneKeys, pruneKey); + else mapSet(inFlightJsxCachePruneKeys, pruneKey, held - 1); +} /** * Generation counter for background prune work. * @@ -1505,7 +1529,7 @@ async function promotePersistedJsxCachePruneRequest( if ( mapHas(scheduledJsxCachePrunes, pruneKey) || mapHas(queuedJsxCachePrunes, pruneKey) || - mapHas(inFlightJsxCachePrunes, pruneKey) + mapHas(inFlightJsxCachePruneKeys, pruneKey) ) { continue; } @@ -1814,6 +1838,11 @@ function scheduleJsxCachePruneRetry( // occupied, without overflowing to persistence and racing completion. fired.timer = undefined; const generation = jsxCachePruneGeneration; + // Two passes for one directory can overlap: this one holds the map entry + // with no timer, so a follow-up is free to arm the next one before this + // pass settles. Identity has to be per pass, or whichever settles first + // retires the other's bookkeeping and hides it from teardown. + const passId = nextJsxCachePrunePassId++; const pass = (async () => { try { await revisitJsxCacheDirectory(esmCacheDir, requestDirectory); @@ -1830,8 +1859,15 @@ function scheduleJsxCachePruneRetry( ); } } finally { - mapDelete(inFlightJsxCachePrunes, pruneKey); - if (mapGet(scheduledJsxCachePrunes, pruneKey)?.timer === undefined) { + mapDelete(inFlightJsxCachePrunes, passId); + releaseInFlightJsxCachePruneKey(pruneKey); + // Only retire the reserved slot when it is still this pass's. A newer + // pass for the same directory owns its own entry, and dropping that + // would strand the timer it just armed. + if ( + mapGet(scheduledJsxCachePrunes, pruneKey) === fired && + fired.timer === undefined + ) { mapDelete(scheduledJsxCachePrunes, pruneKey); } // Requesting a promotion arms the next timer, so it belongs to the @@ -1842,7 +1878,8 @@ function scheduleJsxCachePruneRetry( } } })(); - mapSet(inFlightJsxCachePrunes, pruneKey, pass); + mapSet(inFlightJsxCachePrunes, passId, pass); + retainInFlightJsxCachePruneKey(pruneKey); }, delayMs); unrefTimer(timer); mapSet(scheduledJsxCachePrunes, pruneKey, { From 20f7cf205dca11f3b9b11b7bd5b240fa4974a0ca Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 01:17:51 +0200 Subject: [PATCH 4/6] fix(transforms): fence prune retries armed inside the scan Addresses Codex and CodeRabbit review on #4511, which both flagged this. The generation fence covered a pass's own follow-up but not the retries the scan arms on its way through. collectExcessJsxArtifacts schedules a retry when the directory scan fails and again when an artifact removal asks to be revisited, so a pass that resumed after a cancellation re-armed exactly the timers teardown had just retired, and waitForJsxCacheMaintenance() could settle the pass while maintenance was armed again. collectExcessJsxArtifacts and revisitJsxCacheDirectory now take the prune generation and check it before arming either retry. The parameter is optional: callers outside a scheduled pass, including the direct callers in the suites, pass nothing and are never fenced, so ordinary maintenance is unchanged. This covers the prune-owned retries. The lease-recovery retries in recoverStaleFilesystemLease are reached from withJsxArtifactLock on the serve, refresh and write paths as well, where the retry is wanted, so fencing those needs the generation carried rather than passed and is tracked separately in veryfront/veryfront-issue-inbox#1474. Regression test "should leave no armed timer when cancellation lands mid-scan" fails without the fence and passes with it. Refs veryfront/veryfront-issue-inbox#1466 --- .../mdx/esm-module-loader/jsx-cache.test.ts | 36 +++++++++++++++++++ .../mdx/esm-module-loader/jsx-cache.ts | 22 ++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts index 7c9fc4435f..5bae3279d2 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.test.ts @@ -3251,6 +3251,42 @@ describe("scheduled prune bound", () => { assertEquals(await hasPersistedJsxCachePrune(directory), false); }); + it("should leave no armed timer when cancellation lands mid-scan", async () => { + // Arrange: a scan whose directory cannot be read, which is the path that + // arms a retry from inside the pass rather than from its follow-up. + const directory = `${persistedTestPrefix}cancel-during-scan`; + const localFs = getLocalFs(); + const originalReadDir = localFs.readDir.bind(localFs); + (localFs as { readDir: unknown }).readDir = (path: string) => { + if (path !== directory) return originalReadDir(path); + // Cancel while the scan is suspended, then fail it. + cancelScheduledJsxCachePrunes(); + // deno-lint-ignore require-yield + return (async function* () { + throw new Error("scan failed after cancellation"); + })(); + }; + + try { + // Act + await __jsxCacheInternals.revisitJsxCacheDirectory( + directory, + undefined, + __jsxCacheInternals.currentJsxCachePruneGeneration(), + ); + + // Assert: the failed scan must not re-arm what teardown just retired. + assertEquals( + scheduledJsxCachePruneCount(), + 0, + "a scan that fails after cancellation must not arm a retry timer", + ); + } finally { + (localFs as { readDir: unknown }).readDir = originalReadDir; + cancelScheduledJsxCachePrunes(); + } + }); + it("should leave no armed timer when cancellation lands mid-promotion", async () => { // Arrange: persisted work that a promotion pass will want to schedule. const directory = `${persistedTestPrefix}cancel-during-promotion`; diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index 684c761218..7fab9a5fba 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1265,6 +1265,18 @@ let nextJsxCachePrunePassId = 0; */ const inFlightJsxCachePruneKeys = new IntrinsicMap(); +/** + * Whether work belonging to `pruneGeneration` may still arm a retry. + * + * A scan that resumes after a cancellation would otherwise re-arm the timers + * teardown just retired, and `waitForJsxCacheMaintenance` would then settle the + * pass while maintenance was armed again. Callers outside a scheduled pass pass + * no generation and are never fenced. + */ +function mayArmJsxCachePruneRetry(pruneGeneration: number | undefined): boolean { + return pruneGeneration === undefined || pruneGeneration === jsxCachePruneGeneration; +} + function retainInFlightJsxCachePruneKey(pruneKey: string): void { mapSet( inFlightJsxCachePruneKeys, @@ -1759,6 +1771,7 @@ function queueJsxCachePrune( async function revisitJsxCacheDirectory( esmCacheDir: string, requestDirectory = getPersistedJsxCachePruneRequestDirectory(), + pruneGeneration?: number, ): Promise { try { await scheduledJsxCachePruneSemaphore.acquire(async () => { @@ -1768,6 +1781,7 @@ async function revisitJsxCacheDirectory( hostNow(), 0, requestDirectory, + pruneGeneration, ); }); } catch (error) { @@ -1845,7 +1859,7 @@ function scheduleJsxCachePruneRetry( const passId = nextJsxCachePrunePassId++; const pass = (async () => { try { - await revisitJsxCacheDirectory(esmCacheDir, requestDirectory); + await revisitJsxCacheDirectory(esmCacheDir, requestDirectory, generation); const followUp = mapGet(scheduledJsxCachePrunes, pruneKey); if ( followUp?.timer === undefined && @@ -2209,6 +2223,7 @@ async function collectExcessJsxArtifacts( nowMs: number, reservedSlots = 0, requestDirectory = getPersistedJsxCachePruneRequestDirectory(), + pruneGeneration?: number, ): Promise { const localFs = getLocalFs(); @@ -2256,7 +2271,7 @@ async function collectExcessJsxArtifacts( logger.debug(`${LOG_PREFIX_MDX_LOADER} Failed to scan JSX cache artifacts for pruning`, { error: cacheFilesystemErrorCode(error), }); - if (!isNotFoundError(error)) { + if (!isNotFoundError(error) && mayArmJsxCachePruneRetry(pruneGeneration)) { scheduleJsxCachePruneRetry( esmCacheDir, JSX_CACHE_VARIANT_MIN_AGE_MS + JSX_CACHE_PRUNE_RETRY_SLACK_MS, @@ -2387,7 +2402,7 @@ async function collectExcessJsxArtifacts( } } - if (retryAtMs !== undefined) { + if (retryAtMs !== undefined && mayArmJsxCachePruneRetry(pruneGeneration)) { scheduleJsxCachePruneRetry( esmCacheDir, mathMax(retryAtMs - nowMs, 0) + JSX_CACHE_PRUNE_RETRY_SLACK_MS, @@ -2453,6 +2468,7 @@ export const __jsxCacheInternals = { }, runLazyJsxArtifactHeartbeat, revisitJsxCacheDirectory, + currentJsxCachePruneGeneration: (): number => jsxCachePruneGeneration, servedArtifactMemoSize: (): number => mapSize(servedArtifactTimestamps), withJsxArtifactRefreshSlot, waitForJsxCacheMaintenance, From 7942b076dc14f37c7566a12adc4e51dd40131c75 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 01:32:47 +0200 Subject: [PATCH 5/6] fix(transforms): pump remaining promotions after a fenced one rejects Addresses Codex review on #4511. The generation-mismatch branch pumped only when the request was for the same directory the fenced pass had been promoting. A request for a *different* directory that arrived while that pass still owned the promotion slot was added to the pending set and then left there: it did not set activeJsxCachePrunePromotionRequestedAgain, so this branch returned without pumping, and every later request for it short-circuited on "already pending". It stayed stranded until some unrelated directory happened to start the pump. The slot is free once the fenced pass rejects, so this branch now always pumps, and re-queues its own directory only when someone asked for it again. Refs veryfront/veryfront-issue-inbox#1466 --- src/transforms/mdx/esm-module-loader/jsx-cache.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index 7fab9a5fba..44c7625fcc 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1664,10 +1664,15 @@ function pumpPersistedJsxCachePrunePromotions(): void { unrefTimer(persistedJsxCachePrunePromotionRetry); return; } - // Cancellation retired this pass's own retry. A request that arrived after - // it is still live and must not be stranded in the pending set unpumped. - if (!requestedAgain) return; - setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); + // Cancellation retired this pass's own retry, but the promotion slot it + // held is now free. Anything still pending has to be pumped from here -- + // a request for this directory that arrived after the cancellation, or one + // for an unrelated directory that could not start while this pass held the + // slot. Neither will be pumped by its own caller, which short-circuited on + // "already pending". + if (requestedAgain) { + setAdd(pendingJsxCachePrunePromotionDirectories, requestDirectory); + } pumpPersistedJsxCachePrunePromotions(); }); } From 74841b81fe563f75d5028df24b37f075adbd1ce7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Thu, 17 Sep 2026 01:47:32 +0200 Subject: [PATCH 6/6] fix(transforms): fence the outer prune-failure retry Addresses Codex review on #4511. Threading the generation into the scan fenced the two retries collectExcessJsxArtifacts arms itself, but not the one revisitJsxCacheDirectory arms when the scan rejects outright. The scan's own catch only covers the directory walk, so a failure in a later awaited operation -- dating or removing an artifact -- propagates out and re-armed the directory unconditionally, leaving waitForJsxCacheMaintenance() able to finish with a timer armed. That catch now consults mayArmJsxCachePruneRetry as well, so all three retries a scheduled pass can arm are fenced by the generation it started in. Refs veryfront/veryfront-issue-inbox#1466 --- src/transforms/mdx/esm-module-loader/jsx-cache.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/transforms/mdx/esm-module-loader/jsx-cache.ts b/src/transforms/mdx/esm-module-loader/jsx-cache.ts index 44c7625fcc..d1ec6ed98d 100644 --- a/src/transforms/mdx/esm-module-loader/jsx-cache.ts +++ b/src/transforms/mdx/esm-module-loader/jsx-cache.ts @@ -1795,7 +1795,10 @@ async function revisitJsxCacheDirectory( }); // A pass that throws, rather than preserving an artifact and naming a // retry, never reaches the scheduling at its end. Re-arm the directory so - // transient lease or filesystem failures cannot strand its excess files. + // transient lease or filesystem failures cannot strand its excess files -- + // unless a cancellation retired this pass while the throwing operation was + // in flight, in which case re-arming would outlive teardown. + if (!mayArmJsxCachePruneRetry(pruneGeneration)) return; scheduleJsxCachePruneRetry( esmCacheDir, JSX_CACHE_VARIANT_MIN_AGE_MS + JSX_CACHE_PRUNE_RETRY_SLACK_MS,