diff --git a/src/browser/runtime/local-cloak/cloak-version.test.ts b/src/browser/runtime/local-cloak/cloak-version.test.ts new file mode 100644 index 00000000..1c33f12a --- /dev/null +++ b/src/browser/runtime/local-cloak/cloak-version.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Isolated file: the version cache is module-level and populated by the first +// call in the process, so this must import a fresh copy to observe the read. +describe('resolveCloakBrowserVersion', () => { + it('resolves and reads cloakbrowser/package.json only once per process', async () => { + vi.resetModules(); + const fs = (await import('node:fs')).default; + // Import before spying: loading the module graph reads files of its own, + // and only reads made by resolveCloakBrowserVersion should be counted. + const { resolveCloakBrowserVersion } = await import('./session-manager.js'); + const readFileSync = vi.spyOn(fs, 'readFileSync'); + try { + const first = resolveCloakBrowserVersion(); + const second = resolveCloakBrowserVersion(); + const third = resolveCloakBrowserVersion(); + + expect(second).toBe(first); + expect(third).toBe(first); + expect(readFileSync).toHaveBeenCalledTimes(1); + } finally { + readFileSync.mockRestore(); + } + }); +}); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 1b8c3974..70e3476c 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -10,15 +10,28 @@ import { normalizeProfileId, resolveCloakProfileDir } from './profiles.js'; import { CloakNetworkCapture } from './network.js'; import { findPackageRoot } from '../../../package-paths.js'; -/** Installed `cloakbrowser` npm package version, for doctor/status display. */ +const UNRESOLVED = Symbol('unresolved'); +let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED; + +/** + * Installed `cloakbrowser` npm package version, for doctor/status display. + * + * Resolved once per process. The version cannot change while we are running, and + * `profileStatuses()` calls this per profile, so an uncached read meant N+1 + * synchronous resolve-read-parse cycles on every status poll. The sentinel keeps + * a genuine `undefined` (the catch path) cached too, so an unresolvable + * `cloakbrowser` is not retried on every call. + */ export function resolveCloakBrowserVersion(): string | undefined { + if (cachedCloakBrowserVersion !== UNRESOLVED) return cachedCloakBrowserVersion; try { const entryPath = fileURLToPath(import.meta.resolve('cloakbrowser')); - const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8')); - return typeof pkg.version === 'string' ? pkg.version : undefined; + const pkg = JSON.parse(fs.readFileSync(path.join(findPackageRoot(entryPath), 'package.json'), 'utf-8')) as { version?: unknown }; + cachedCloakBrowserVersion = typeof pkg.version === 'string' ? pkg.version : undefined; } catch { - return undefined; + cachedCloakBrowserVersion = undefined; } + return cachedCloakBrowserVersion; } export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;