diff --git a/apps/local-edge-demo/e2e/local-edge.spec.ts b/apps/local-edge-demo/e2e/local-edge.spec.ts index 9c9dfd8..763cbeb 100644 --- a/apps/local-edge-demo/e2e/local-edge.spec.ts +++ b/apps/local-edge-demo/e2e/local-edge.spec.ts @@ -2169,6 +2169,155 @@ test.describe('Local Edge v0', () => { } }) + test('a reset in another tab leaves no release cache behind', async ({ + browser, + }) => { + const releaseServer = await startReleaseUpdateServer() + const context = await browser.newContext() + const previousPage = await context.newPage() + const updatedPage = await context.newPage() + const releaseCaches = (page: typeof previousPage) => + page.evaluate(async () => + (await caches.keys()).filter((cacheName) => + cacheName.startsWith('fwa-local-edge:fwa-local-edge-demo:release:'), + ), + ) + + try { + await previousPage.goto(releaseServer.baseUrl) + await expect(previousPage.locator('[data-local-edge-status]')).toHaveAttribute( + 'data-local-edge-status', + 'ready', + { timeout: 20_000 }, + ) + await previousPage.evaluate(() => + fetch('/__test/switch-release', { method: 'POST' }), + ) + await updatedPage.goto(releaseServer.baseUrl) + await reloadAvailableUpdate(updatedPage) + await expect( + updatedPage.locator('meta[name="fwa-test-release"]'), + ).toHaveAttribute('content', 'app-update', { timeout: 20_000 }) + + // The client reset also unregisters and reopens the tab on the network. + await Promise.all([ + updatedPage.waitForURL(/[?&]__fwa=network/u, { timeout: 20_000 }), + updatedPage.evaluate(() => + ( + globalThis as typeof globalThis & { + __fwa: { localEdge: { reset(): Promise } } + } + ).__fwa.localEdge.reset(), + ), + ]) + expect( + await updatedPage.evaluate( + async () => (await navigator.serviceWorker.getRegistrations()).length, + ), + ).toBe(0) + + // The previous tab is still controlled by the reset worker instance and + // keeps issuing requests through it: a lazy asset of its pinned release, + // a descriptor revalidation, and a navigation-free state read. + await previousPage.evaluate(async (assetPath) => { + await import(assetPath).catch(() => undefined) + await fetch('/__fwa/revalidate', { + method: 'POST', + headers: { 'X-FWA-Control': 'revalidate' }, + }).catch(() => undefined) + await fetch('/__fwa/state').catch(() => undefined) + }, releaseServer.initialLazyAssetPath) + await previousPage.waitForTimeout(2_000) + + expect(await releaseCaches(previousPage)).toEqual([]) + expect(await releaseCaches(updatedPage)).toEqual([]) + } finally { + await context.close() + await releaseServer.close() + } + }) + + test('finds a new worker on a release check and applies it with the update', async ({ + browser, + }) => { + const releaseServer = await startReleaseUpdateServer() + const context = await browser.newContext() + const page = await context.newPage() + const workerGeneration = () => + page.evaluate(() => + fetch('/__test/worker-generation', { cache: 'no-store' }).then( + (response) => response.text(), + ), + ) + type LocalEdgeTestWindow = typeof globalThis & { + __fwa: { + localEdge: { + revalidate(): Promise + getState(): { updateAvailable: boolean } + applyUpdate(): boolean + } + } + } + + try { + await page.goto(releaseServer.baseUrl) + await expect(page.locator('[data-local-edge-status]')).toHaveAttribute( + 'data-local-edge-status', + 'ready', + { timeout: 20_000 }, + ) + expect(await workerGeneration()).toBe('1') + + // One deploy changes both the release and the worker script, while the + // document stays open: no navigation reaches the browser's own check. + await page.evaluate(async () => { + await fetch('/__test/switch-release', { method: 'POST' }) + await fetch('/__test/switch-worker', { method: 'POST' }) + }) + await page.evaluate(() => + (globalThis as LocalEdgeTestWindow).__fwa.localEdge.revalidate(), + ) + await expect + .poll(() => + page.evaluate(async () => { + const registration = + await navigator.serviceWorker.getRegistration() + return { + updateAvailable: ( + globalThis as LocalEdgeTestWindow + ).__fwa.localEdge.getState().updateAvailable, + workerWaiting: Boolean(registration?.waiting), + } + }), + ) + .toEqual({ updateAvailable: true, workerWaiting: true }) + // Until the user applies the update, the open document keeps its worker. + expect(await workerGeneration()).toBe('1') + + await Promise.all([ + page.waitForEvent('load'), + page.evaluate(() => + (globalThis as LocalEdgeTestWindow).__fwa.localEdge.applyUpdate(), + ), + ]) + await expect(page.locator('meta[name="fwa-test-release"]')).toHaveAttribute( + 'content', + 'app-update', + { timeout: 20_000 }, + ) + expect(await workerGeneration()).toBe('2') + expect( + await page.evaluate( + async () => + Boolean((await navigator.serviceWorker.getRegistration())?.waiting), + ), + ).toBe(false) + } finally { + await context.close() + await releaseServer.close() + } + }) + test('installs a third release while older clients stay pinned', async ({ browser, }) => { diff --git a/apps/local-edge-demo/e2e/release-update-server.ts b/apps/local-edge-demo/e2e/release-update-server.ts index 0a1b0c6..c2f30a3 100644 --- a/apps/local-edge-demo/e2e/release-update-server.ts +++ b/apps/local-edge-demo/e2e/release-update-server.ts @@ -130,9 +130,41 @@ export async function startReleaseUpdateServer( allowCandidateAsset = resolveAsset }) + // A second worker generation differs only by an appended fetch listener + // that answers the generation probe; the first generation leaves the probe + // to the network, which answers 1. + let workerGeneration = 1 + const workerGenerationProbePath = '/__test/worker-generation' + const secondWorkerGenerationSuffix = Buffer.from( + `\nself.addEventListener('fetch', (event) => {\n` + + ` if (new URL(event.request.url).pathname === '${workerGenerationProbePath}') {\n` + + ` event.respondWith(new Response('2', { headers: { 'Cache-Control': 'no-store' } }))\n` + + ` }\n` + + `})\n`, + ) + const server = createServer(async (request, response) => { const requestUrl = new URL(request.url ?? '/', 'http://localhost') + if ( + request.method === 'POST' && + requestUrl.pathname === '/__test/switch-worker' + ) { + workerGeneration = 2 + respond(response, Buffer.from('{}'), { + contentType: 'application/json; charset=utf-8', + cacheControl: 'no-store', + }) + return + } + if (requestUrl.pathname === workerGenerationProbePath) { + respond(response, Buffer.from('1'), { + contentType: 'text/plain; charset=utf-8', + cacheControl: 'no-store', + }) + return + } + if ( request.method === 'POST' && requestUrl.pathname === '/__test/switch-release' @@ -305,7 +337,13 @@ export async function startReleaseUpdateServer( throw new Error('path escapes dist root') } - respond(response, await readFile(filePath), { + const fileBody = await readFile(filePath) + respond( + response, + requestUrl.pathname === localEdgeConfig.workerPath && workerGeneration === 2 + ? Buffer.concat([fileBody, secondWorkerGenerationSuffix]) + : fileBody, + { contentType: contentTypeFor(filePath), cacheControl: requestUrl.pathname === localEdgeConfig.workerPath || @@ -313,7 +351,8 @@ export async function startReleaseUpdateServer( ? 'no-cache' : 'public, max-age=31536000, immutable', headOnly: request.method === 'HEAD', - }) + }, + ) } catch { response .writeHead(404, { diff --git a/packages/local-edge/docs/architecture.md b/packages/local-edge/docs/architecture.md index fd9d467..ab2a66b 100644 --- a/packages/local-edge/docs/architecture.md +++ b/packages/local-edge/docs/architecture.md @@ -58,6 +58,8 @@ The metadata epoch fences durable authority across worker globals and reset. Whe During a candidate install the worker keeps an in-memory progress counter and broadcasts best-effort `__fwa:revalidation-progress` messages to window clients, plus a single `__fwa:revalidation-committed` message after the active pointer is committed or a `__fwa:revalidation-failed` message when the install attempt does not commit. Progress stays in memory only; a worker restart clears it and clients fall back to activity UI. The kernel-issued instance and revision ordering shared by messages, snapshots, and responses is defined in [Revalidation observation ordering](revalidation-observation.md). +Worker script updates follow the browser's lifecycle and are never forced into an open document: the document runtime only speeds up discovery on its release checks, and a waiting worker takes over when every controlled document closes or when the user applies an update. Kernel Cache Storage reads go through `caches.match` with a cache name, so a worker read never recreates a release cache that reset or cleanup deleted; the only kernel `caches.open` creates a candidate cache under the candidate lock. The diagnostics panel still lists cache entries through `caches.keys()` and `caches.open()` and is not covered by this guarantee. + This is release-level stale-while-revalidate. It is not per-request HTTP stale-while-revalidate and does not promise the newest release while offline. ## Dependency direction diff --git a/packages/local-edge/docs/config-contract.md b/packages/local-edge/docs/config-contract.md index c45d788..b535d29 100644 --- a/packages/local-edge/docs/config-contract.md +++ b/packages/local-edge/docs/config-contract.md @@ -63,6 +63,14 @@ publishes the material runtime transition out of network-only mode. Switching from an already active release remains owned by the next navigation or `applyUpdate()`. +Each check from a controlled document also asks the browser to re-fetch the +worker script (`registration.update()`, best-effort). A long-lived document +does not navigate, and browsers otherwise check for a new worker only on +navigations, or on a subresource request once the registration is more than 24 +hours stale. A new worker found this way still waits for every controlled +document to close; `applyUpdate()` lets it take over before reloading, so the +user-applied update reaches both the new release and the new worker. + `window.__fwa.localEdge.setUpdateCheck()` can enable, disable, or change the interval for the current document. This override is not written to browser storage and does not change `fwa.config.json`. diff --git a/packages/local-edge/docs/loader-contract.md b/packages/local-edge/docs/loader-contract.md index 037cf22..e003ef1 100644 --- a/packages/local-edge/docs/loader-contract.md +++ b/packages/local-edge/docs/loader-contract.md @@ -51,7 +51,7 @@ unsubscribe?.() | `subscribe(listener)` | Emit immediately and on later state changes | | `revalidate()` | Check and install a candidate; return `current`, `updated`, `failed`, or `disabled` | | `setUpdateCheck(config)` | Change the enabled state or interval (1–35,791 minutes) for the current document without persistence | -| `applyUpdate()` | Reload only when a complete update is available | +| `applyUpdate()` | Reload only when a complete update is available; a new worker waiting to activate takes over first (bounded to 3 seconds) | | `reset()` | Clear Local Edge-owned state and enter network mode | | `networkUrl(url?)` | Preserve the URL while adding `__fwa=network` | | `openNetwork()` | Navigate to `networkUrl()` | diff --git a/packages/local-edge/src/loader/document-runtime.test.ts b/packages/local-edge/src/loader/document-runtime.test.ts index 78f5977..685528c 100644 --- a/packages/local-edge/src/loader/document-runtime.test.ts +++ b/packages/local-edge/src/loader/document-runtime.test.ts @@ -7,6 +7,7 @@ import { fwaRevalidationFailedMessageType, fwaRevalidationProgressMessageType, maxUpdateCheckIntervalMinutes, + fwaTakeoverMessageType, } from '../config-contract.ts' import { createLocalEdgeDocumentRuntime, @@ -210,6 +211,8 @@ function createControlledKernel(options: { waiting: null, } as unknown as ServiceWorkerRegistration const replaceServiceWorker = vi.fn(async () => registration) + const checkForWorkerUpdate = vi.fn(async () => undefined) + const readWaitingWorker = vi.fn(async (): Promise => null) let revalidationCallCount = 0 let observationRevision = snapshotRevalidation ? 1 : 0 @@ -362,13 +365,17 @@ function createControlledKernel(options: { throw new Error('registration is not expected') }, replaceServiceWorker, + checkForWorkerUpdate, + readWaitingWorker, scheduler: effectiveScheduler, }, ) runtime.start() return { + checkForWorkerUpdate, fetchMock, + readWaitingWorker, reload, replaceServiceWorker, runtime, @@ -469,6 +476,8 @@ describe('createLocalEdgeDocumentRuntime', () => { replaceServiceWorker: async () => { throw new Error('legacy takeover is not expected') }, + checkForWorkerUpdate: async () => undefined, + readWaitingWorker: async () => null, scheduler: fakeScheduler.scheduler, }, ) @@ -566,6 +575,8 @@ describe('createLocalEdgeDocumentRuntime', () => { throw new Error('registration is not expected') }, replaceServiceWorker, + checkForWorkerUpdate: async () => undefined, + readWaitingWorker: async () => null, scheduler: fakeScheduler.scheduler, }, ) @@ -599,6 +610,8 @@ describe('createLocalEdgeDocumentRuntime', () => { replaceServiceWorker: async () => { throw new Error('replacement is not expected') }, + checkForWorkerUpdate: async () => undefined, + readWaitingWorker: async () => null, scheduler: fakeScheduler.scheduler, }, ) @@ -664,6 +677,131 @@ describe('createLocalEdgeDocumentRuntime', () => { expect(reload).not.toHaveBeenCalled() }) + it('checks for a new worker on each release check of a controlled document', async () => { + const fakeScheduler = createFakeScheduler() + const { runtime, checkForWorkerUpdate } = createControlledKernel({ + scheduler: fakeScheduler.scheduler, + updateCheck: { intervalMinutes: 5 }, + }) + await settle(runtime) + const startupChecks = checkForWorkerUpdate.mock.calls.length + expect(startupChecks).toBeGreaterThan(0) + + // A failed script fetch does not fail the release check. + checkForWorkerUpdate.mockRejectedValueOnce(new Error('offline')) + fakeScheduler.elapse(updateCheckIntervalMs) + fakeScheduler.triggerVisible() + await vi.waitFor(() => { + expect(checkForWorkerUpdate).toHaveBeenCalledTimes(startupChecks + 1) + }) + await settle(runtime) + expect(runtime.getState().phase).toBe('ready') + }) + + it('lets a waiting worker take over before applying an update', async () => { + const fakeScheduler = createFakeScheduler() + const { runtime, reload, readWaitingWorker } = createControlledKernel({ + scheduler: fakeScheduler.scheduler, + updateCheck: { intervalMinutes: 5 }, + revalidationReleaseId: 'release-b', + revalidationStatus: 'updated', + }) + const waitingWorker = Object.assign(new EventTarget(), { + scriptURL: workerUrl, + state: 'installed' as ServiceWorkerState, + postMessage: vi.fn((message: { type?: string }) => { + if (message.type === fwaTakeoverMessageType) { + queueMicrotask(() => { + waitingWorker.state = 'activated' + waitingWorker.dispatchEvent(new Event('statechange')) + }) + } + }), + }) + readWaitingWorker.mockResolvedValue( + waitingWorker as unknown as ServiceWorker, + ) + await settle(runtime) + fakeScheduler.elapse(updateCheckIntervalMs) + fakeScheduler.triggerVisible() + await vi.waitFor(() => { + expect(runtime.getState().updateAvailable).toBe(true) + }) + + expect(runtime.applyUpdate()).toBe(true) + expect(reload).not.toHaveBeenCalled() + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)) + expect(waitingWorker.postMessage).toHaveBeenCalledWith({ + type: fwaTakeoverMessageType, + }) + expect(waitingWorker.state).toBe('activated') + }) + + for (const { name, state, scriptURL } of [ + { + name: 'does not wait for a worker another tab already activated', + state: 'activated' as ServiceWorkerState, + scriptURL: workerUrl, + }, + { + name: 'leaves a waiting worker of another script alone', + state: 'installed' as ServiceWorkerState, + scriptURL: 'https://app.example/other-worker.js', + }, + ]) { + it(name, async () => { + const fakeScheduler = createFakeScheduler() + const { runtime, reload, readWaitingWorker } = createControlledKernel({ + scheduler: fakeScheduler.scheduler, + updateCheck: { intervalMinutes: 5 }, + revalidationReleaseId: 'release-b', + revalidationStatus: 'updated', + }) + const waitingWorker = Object.assign(new EventTarget(), { + scriptURL, + state, + postMessage: vi.fn(), + }) + readWaitingWorker.mockResolvedValue( + waitingWorker as unknown as ServiceWorker, + ) + await settle(runtime) + fakeScheduler.elapse(updateCheckIntervalMs) + fakeScheduler.triggerVisible() + await vi.waitFor(() => { + expect(runtime.getState().updateAvailable).toBe(true) + }) + + expect(runtime.applyUpdate()).toBe(true) + // Well inside the 3 s takeover cap. + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1), { + timeout: 500, + }) + if (scriptURL !== workerUrl) { + expect(waitingWorker.postMessage).not.toHaveBeenCalled() + } + }) + } + + it('applies an update with a plain reload when no worker is waiting', async () => { + const fakeScheduler = createFakeScheduler() + const { runtime, reload } = createControlledKernel({ + scheduler: fakeScheduler.scheduler, + updateCheck: { intervalMinutes: 5 }, + revalidationReleaseId: 'release-b', + revalidationStatus: 'updated', + }) + await settle(runtime) + fakeScheduler.elapse(updateCheckIntervalMs) + fakeScheduler.triggerVisible() + await vi.waitFor(() => { + expect(runtime.getState().updateAvailable).toBe(true) + }) + + expect(runtime.applyUpdate()).toBe(true) + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)) + }) + it('does not claim a network-only document became ready after prefetch', async () => { const fakeScheduler = createFakeScheduler() const { runtime } = createControlledKernel({ diff --git a/packages/local-edge/src/loader/document-runtime.ts b/packages/local-edge/src/loader/document-runtime.ts index 5272af0..80ae5a0 100644 --- a/packages/local-edge/src/loader/document-runtime.ts +++ b/packages/local-edge/src/loader/document-runtime.ts @@ -28,6 +28,7 @@ import { localEdgeControlPathsFor, pathWithLocalEdgeNavigationMode, localEdgeNavigationModeFor, + fwaTakeoverMessageType, } from '../config-contract.ts' import type { LocalEdgeClientState, @@ -51,6 +52,12 @@ export interface LocalEdgeUpdateCheckConfig { interface LocalEdgeRegistrationOwner { registerServiceWorker(): Promise replaceServiceWorker(): Promise + // Re-fetches the worker script now. A long-lived document does not navigate, + // and the browser checks for a new worker on its own only on navigations or + // on a subresource request once the registration is over 24 hours stale. + checkForWorkerUpdate(): Promise + // An installed worker waiting for every controlled client to close. + readWaitingWorker(): Promise } export interface LocalEdgeDocumentScheduler { @@ -93,6 +100,9 @@ interface LocalEdgeDocumentRuntime { } const maxSnapshotPullAttempts = 8 +// Upper bound on waiting for a user-applied worker takeover before reloading +// anyway; the reload then still opens the new release under the old worker. +const waitingWorkerActivationTimeoutMs = 3_000 const initialState: LocalEdgeClientState = { phase: 'starting', @@ -548,6 +558,11 @@ export function createLocalEdgeDocumentRuntime( if (!silent) { showRevalidationActivity() } + if (navigator.serviceWorker?.controller) { + // Worker discovery rides on the release check cadence. It is + // best-effort: a failed script fetch must not fail the release check. + void registrationOwner.checkForWorkerUpdate().catch(() => undefined) + } revalidationInFlight = runRevalidation().finally(() => { if (revalidationVisible && state.revalidating) { publish({ @@ -862,10 +877,42 @@ export function createLocalEdgeDocumentRuntime( if (!state.updateAvailable || !state.availableReleaseId) { return false } - window.location.reload() + // A reload alone cannot activate a waiting worker: the reloading document + // is still a client when the new navigation starts. Applying an update the + // user asked for therefore lets the waiting worker take over first. + void activateWaitingWorker() + .catch(() => undefined) + .finally(() => window.location.reload()) return true } + const activateWaitingWorker = async () => { + const waitingWorker = await registrationOwner.readWaitingWorker() + if (!waitingWorker || !workerMatchesPath(waitingWorker, config.workerPath)) { + return + } + await new Promise((resolve) => { + const timeout = setTimeout(finish, waitingWorkerActivationTimeoutMs) + function finish() { + clearTimeout(timeout) + waitingWorker!.removeEventListener('statechange', handleStateChange) + resolve() + } + function handleStateChange() { + if ( + waitingWorker!.state === 'activated' || + waitingWorker!.state === 'redundant' + ) { + finish() + } + } + waitingWorker.addEventListener('statechange', handleStateChange) + // Another tab may have applied the update since the worker was read. + handleStateChange() + waitingWorker.postMessage({ type: fwaTakeoverMessageType }) + }) + } + const reset = async () => { const currentUrl = new URL(window.location.href) const response = await fetch( diff --git a/packages/local-edge/src/loader/loader-entry.ts b/packages/local-edge/src/loader/loader-entry.ts index a2bc558..344a57f 100644 --- a/packages/local-edge/src/loader/loader-entry.ts +++ b/packages/local-edge/src/loader/loader-entry.ts @@ -45,6 +45,11 @@ function bootstrap(script: HTMLScriptElement) { navigator.serviceWorker.register(paths.workerPath, { scope: paths.scopePath, }), + checkForWorkerUpdate: async () => { + await (await readOwnedRegistration())?.update() + }, + readWaitingWorker: async () => + (await readOwnedRegistration())?.waiting ?? null, replaceServiceWorker: async () => { const registrations = await navigator.serviceWorker.getRegistrations() const ownedRegistrations = registrations.filter( @@ -96,6 +101,13 @@ function bootstrap(script: HTMLScriptElement) { }, }, ) + async function readOwnedRegistration() { + const registrations = await navigator.serviceWorker.getRegistrations() + return registrations.find( + (registration) => + new URL(registration.scope).pathname === paths.scopePath, + ) + } let localEdge: FwaLocalEdgeApi const debug = createFwaDebugRuntime(() => localEdge) localEdge = Object.freeze({ diff --git a/packages/local-edge/src/worker/release-runtime.test.ts b/packages/local-edge/src/worker/release-runtime.test.ts index f819f5b..a12f2f9 100644 --- a/packages/local-edge/src/worker/release-runtime.test.ts +++ b/packages/local-edge/src/worker/release-runtime.test.ts @@ -149,6 +149,12 @@ describe('release-runtime candidate install progress', () => { match: vi.fn(async (path: string) => entries.get(path)), } }), + match: vi.fn( + async (path: string, options?: { cacheName?: string }) => + options?.cacheName + ? cacheStore.get(options.cacheName)?.get(path) + : undefined, + ), keys: vi.fn(async () => [...cacheStore.keys()]), delete: vi.fn(async (name: string) => cacheStore.delete(name)), } @@ -185,6 +191,22 @@ describe('release-runtime candidate install progress', () => { ) }) + it('reads a release deleted after an existence check without recreating it', async () => { + const release = (verifierState.descriptor as { + release: Parameters[0] + }).release + // Reset or cleanup deletes the cache between an existence check and the + // read: `has` still answers true, but the cache is gone. + vi.mocked(caches.has).mockResolvedValue(true) + + expect( + await runtime.readReleaseAsset(release, '/index.html'), + ).toBeUndefined() + expect(await runtime.isReleaseComplete(release)).toBe(false) + + expect([...cacheStore.keys()]).toEqual([]) + }) + it('broadcasts progress through the install and a committed message on success', async () => { const metadata = await import('./release-metadata.ts') const result = await runtime.revalidateReleaseForClient('window-1') @@ -789,6 +811,12 @@ describe('release-runtime candidate install progress', () => { match: vi.fn(async (path: string) => entries.get(path)), } }), + match: vi.fn( + async (path: string, options?: { cacheName?: string }) => + options?.cacheName + ? cacheStoreForDrain.get(options.cacheName)?.get(path) + : undefined, + ), keys: vi.fn(async () => [...cacheStoreForDrain.keys()]), delete: vi.fn(async (name: string) => cacheStoreForDrain.delete(name)), }) diff --git a/packages/local-edge/src/worker/release-runtime.ts b/packages/local-edge/src/worker/release-runtime.ts index 94de863..3d2f3ce 100644 --- a/packages/local-edge/src/worker/release-runtime.ts +++ b/packages/local-edge/src/worker/release-runtime.ts @@ -247,29 +247,25 @@ export async function pinRequestClient(event: FetchEvent, releaseId: string) { ) } +// Release reads go through CacheStorage.match with a cacheName: it resolves +// undefined for a missing cache instead of creating one. `caches.open` would +// recreate a cache that reset or cleanup deleted between a `has` check and the +// open, leaving an empty app-owned cache behind (spec §3.2: no resurrection). +function matchReleaseAsset(releaseId: string, assetPath: string) { + return caches.match(assetPath, { cacheName: releaseCacheName(releaseId) }) +} + export async function readReleaseAsset( release: AppRelease, assetPath: string, ) { - const cacheName = releaseCacheName(release.releaseId) - if (!(await caches.has(cacheName))) { - return undefined - } - - const releaseCache = await caches.open(cacheName) - return releaseCache.match(assetPath) + return matchReleaseAsset(release.releaseId, assetPath) } export async function isReleaseComplete(release: AppRelease) { - const cacheName = releaseCacheName(release.releaseId) - if (!(await caches.has(cacheName))) { - return false - } - - const releaseCache = await caches.open(cacheName) const matches = await Promise.all( releaseAssetPaths(release).map((assetPath) => - releaseCache.match(assetPath), + matchReleaseAsset(release.releaseId, assetPath), ), ) return matches.every(Boolean) @@ -451,11 +447,11 @@ async function revalidateRelease( let terminalIdentity = currentKernelObservationIdentity() await withCandidateCacheLock(async () => { commitBaseState = await readReleaseState(requiredMetadataEpoch()) - // Reopen by name for final verification: a Cache object detached by a - // concurrent delete must never authorize an unreachable committed release. - const finalCandidateCache = await caches.open(candidateCacheName) + // Verify by name, not through the Cache object used for install: a Cache + // detached by a concurrent delete must never authorize an unreachable + // committed release, and the lookup must not recreate a deleted cache. for (const asset of release.assets) { - if (!(await finalCandidateCache.match(asset.path))) { + if (!(await matchReleaseAsset(release.releaseId, asset.path))) { throw new Error(`${asset.path} missing after candidate install`) } }