From 6a6effb706984bfdba314dd14112ef84816cbf6d Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 26 Mar 2026 15:41:49 +0100 Subject: [PATCH] fix: prevent repeated failed rebundle attempts and TOCTOU on cache fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a rebundle fails (e.g. pulled extensions with bare specifiers and no deno.json import map), the fallback to the cached bundle left the cache mtime unchanged. This meant every subsequent cold start would re-detect the bundle as stale, invoke the Deno bundler, wait for it to fail, then fall back again — adding seconds of latency per extension on every load. Touch the cache file's mtime after a successful fallback so the next mtime check sees it as fresh and short-circuits immediately. Also fix a pre-existing TOCTOU race: bundleExists was set via Deno.stat() early in the method, but the fallback Deno.readTextFile() runs later in a catch block. If the bundle is deleted between the stat and the read (e.g. concurrent GC), readTextFile would throw unhandled. Wrap the fallback read in a try/catch so deletion between stat and read falls through to the original bundleError instead of crashing. Applied consistently to all 4 extension loaders: models, vaults, drivers, and datastores. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/domain/datastore/user_datastore_loader.ts | 17 ++++++++++++++--- src/domain/drivers/user_driver_loader.ts | 17 ++++++++++++++--- src/domain/models/user_model_loader.ts | 17 ++++++++++++++--- src/domain/vaults/user_vault_loader.ts | 17 ++++++++++++++--- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/domain/datastore/user_datastore_loader.ts b/src/domain/datastore/user_datastore_loader.ts index 2107fba4..c66d559d 100644 --- a/src/domain/datastore/user_datastore_loader.ts +++ b/src/domain/datastore/user_datastore_loader.ts @@ -251,9 +251,20 @@ export class UserDatastoreLoader { return js; } catch (bundleError) { if (bundleExists) { - logger - .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; - return await Deno.readTextFile(bundlePath); + try { + const cached = await Deno.readTextFile(bundlePath); + logger + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; + // Touch the cache mtime so subsequent loads see it as fresh, + // avoiding repeated failed rebundle attempts on every cold start. + try { + const now = new Date(); + await Deno.utime(bundlePath, now, now); + } catch { /* ignore — worst case we retry next load */ } + return cached; + } catch { + // Cache file was removed between stat and read — treat as no cache. + } } throw bundleError; } diff --git a/src/domain/drivers/user_driver_loader.ts b/src/domain/drivers/user_driver_loader.ts index aa39747c..ea09050b 100644 --- a/src/domain/drivers/user_driver_loader.ts +++ b/src/domain/drivers/user_driver_loader.ts @@ -250,9 +250,20 @@ export class UserDriverLoader { return js; } catch (bundleError) { if (bundleExists) { - logger - .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; - return await Deno.readTextFile(bundlePath); + try { + const cached = await Deno.readTextFile(bundlePath); + logger + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; + // Touch the cache mtime so subsequent loads see it as fresh, + // avoiding repeated failed rebundle attempts on every cold start. + try { + const now = new Date(); + await Deno.utime(bundlePath, now, now); + } catch { /* ignore — worst case we retry next load */ } + return cached; + } catch { + // Cache file was removed between stat and read — treat as no cache. + } } throw bundleError; } diff --git a/src/domain/models/user_model_loader.ts b/src/domain/models/user_model_loader.ts index 1c3beac9..672f90e2 100644 --- a/src/domain/models/user_model_loader.ts +++ b/src/domain/models/user_model_loader.ts @@ -473,9 +473,20 @@ export class UserModelLoader { return js; } catch (bundleError) { if (bundleExists) { - logger - .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; - return await Deno.readTextFile(bundlePath); + try { + const cached = await Deno.readTextFile(bundlePath); + logger + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; + // Touch the cache mtime so subsequent loads see it as fresh, + // avoiding repeated failed rebundle attempts on every cold start. + try { + const now = new Date(); + await Deno.utime(bundlePath, now, now); + } catch { /* ignore — worst case we retry next load */ } + return cached; + } catch { + // Cache file was removed between stat and read — treat as no cache. + } } throw bundleError; } diff --git a/src/domain/vaults/user_vault_loader.ts b/src/domain/vaults/user_vault_loader.ts index a9d0d478..d56b0740 100644 --- a/src/domain/vaults/user_vault_loader.ts +++ b/src/domain/vaults/user_vault_loader.ts @@ -257,9 +257,20 @@ export class UserVaultLoader { return js; } catch (bundleError) { if (bundleExists) { - logger - .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; - return await Deno.readTextFile(bundlePath); + try { + const cached = await Deno.readTextFile(bundlePath); + logger + .warn`Rebundle failed for ${relativePath}, using cached bundle: ${bundleError}`; + // Touch the cache mtime so subsequent loads see it as fresh, + // avoiding repeated failed rebundle attempts on every cold start. + try { + const now = new Date(); + await Deno.utime(bundlePath, now, now); + } catch { /* ignore — worst case we retry next load */ } + return cached; + } catch { + // Cache file was removed between stat and read — treat as no cache. + } } throw bundleError; }