From 2fc6d5d4dc3c41dc0d262e2ebdd8b6ab37ce1204 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 10:43:05 +0800 Subject: [PATCH] Release response streams used by cache metadata probes --- frontend/asset-sync-core.js | 17 +++++++++++++---- frontend/asset-sync-core.test.js | 7 +++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index 1339fdb..9f34ce7 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -49,10 +49,19 @@ return response ? response.json() : null; } + function verifiedProbe(response, hash) { + if (!response) return false; + const valid = response.headers.get('X-Asset-Hash') === hash; + // Metadata checks must release Blob/data-pipe readers. They never consume + // asset bytes, and cancelling this response does not remove its cache entry. + response.body?.cancel().catch(() => {}); + return valid; + } + async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) { const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { const response = await cache.match(url(path, manifest.files[path])); - return !!response && response.headers.get('X-Asset-Hash') === manifest.files[path].h; + return verifiedProbe(response, manifest.files[path].h); })); const missing = found.filter(value => !value).length; return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) }; @@ -85,7 +94,7 @@ for (const path of selected) { const key = url(path, manifest.files[path]); const response = await cache.match(key); - if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key); + if (!verifiedProbe(response, manifest.files[path].h)) missing.push(key); } const count = missing.length; let failure; @@ -94,7 +103,7 @@ const key = missing.shift(); try { await sharedDownload(key, async () => { const held = await cache.match(key); - if (held?.headers.get('X-Asset-Hash') === key.split('v=')[1]) return; + if (verifiedProbe(held, key.split('v=')[1])) return; let error; for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) { const controller = new AbortController(); @@ -127,7 +136,7 @@ const files = paths(manifest); const missingFiles = (await Promise.all(files.map(async path => { const response = await cache.match(url(path, manifest.files[path])); - return !response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h ? path : null; + return verifiedProbe(response, manifest.files[path].h) ? null : path; }))).filter(Boolean); const total = files.length; return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles }; diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 371864b..b5abdb6 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -89,3 +89,10 @@ test('blocking commit never waits on an optional file readiness probe',{timeout: await core.commit(m,c);assert.equal(extraProbes,0);assert.equal((await core.state(c)).current.buildTag,m.buildTag); c.match=match;assert.equal((await core.status(m,c)).offlineReady,false); }); + +test('metadata probes release response streams while stored asset bodies remain readable',async()=>{ + const c=cache(),m=manifest();await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b')); + const match=c.match,probes=[];c.match=async key=>{const r=await match(key);if(r)probes.push(r);return r;}; + assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed)); + assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b'); +});