diff --git a/dist/webbrain-chrome-32.0.0.zip b/dist/webbrain-chrome-32.0.0.zip index e36b21f43..7c9f4fec9 100644 Binary files a/dist/webbrain-chrome-32.0.0.zip and b/dist/webbrain-chrome-32.0.0.zip differ diff --git a/dist/webbrain-edge-32.0.0.zip b/dist/webbrain-edge-32.0.0.zip index c90484e96..ea56de7c0 100644 Binary files a/dist/webbrain-edge-32.0.0.zip and b/dist/webbrain-edge-32.0.0.zip differ diff --git a/dist/webbrain-firefox-32.0.0.zip b/dist/webbrain-firefox-32.0.0.zip index 985ca8744..2c88ef4d8 100644 Binary files a/dist/webbrain-firefox-32.0.0.zip and b/dist/webbrain-firefox-32.0.0.zip differ diff --git a/src/chrome/src/agent/apocalypse-mode.js b/src/chrome/src/agent/apocalypse-mode.js index 146519e92..25a2b97c1 100644 --- a/src/chrome/src/agent/apocalypse-mode.js +++ b/src/chrome/src/agent/apocalypse-mode.js @@ -822,6 +822,19 @@ export function createApocalypseArchiveManager(options = {}) { let processing = false; if (!store || !storage) throw new Error('Apocalypse Mode requires state and archive storage adapters.'); + async function cancelledResult() { + try { + const config = await store.getConfig(); + if (config?.enabled === true) { + const nextDelay = nextArchiveScheduleDelay(await store.listArchives(), now()); + if (nextDelay != null) schedule(nextDelay); + } + } catch { + // Preserve the cancellation result if rearming the shared alarm fails. + } + return { processed: false, reason: 'cancelled' }; + } + async function getSnapshot() { const [config, archives] = await Promise.all([store.getConfig(), store.listArchives()]); return { @@ -973,7 +986,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, recovered, { status: 'downloading', generation, leaseToken, updatedAt: record.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); record = recovered; } let writer = null; @@ -1006,7 +1019,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, marked, { status: 'downloading', generation, leaseToken, updatedAt: record.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); record = marked; writer = await storage.createWriter(record.target, record); usedWriteSession = true; @@ -1033,7 +1046,7 @@ export function createApocalypseArchiveManager(options = {}) { let current = await store.getArchive(record.id); let currentConfig = await store.getConfig(); if (!ownsDownloadClaim(current, generation, leaseToken, currentConfig)) { - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } if (writer) await writer.write(offset, bytes); else await storage.write(record.target, offset, bytes, record); @@ -1042,7 +1055,7 @@ export function createApocalypseArchiveManager(options = {}) { if (!ownsDownloadClaim(current, generation, leaseToken, currentConfig)) { await abortWriteSession(new Error('Archive download was cancelled.')).catch(() => {}); if (!current) await storage.remove(record.target, record).catch(() => {}); - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } const bytesDownloaded = offset + bytes.byteLength; const finished = bytesDownloaded >= Number(record.size); @@ -1082,7 +1095,7 @@ export function createApocalypseArchiveManager(options = {}) { if (writeSessionCommitted && !await store.getArchive(record.id)) { await storage.remove(record.target, record).catch(() => {}); } - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } piecesProcessed += 1; if (continueInWake) { @@ -1098,7 +1111,7 @@ export function createApocalypseArchiveManager(options = {}) { const current = await store.getArchive(record.id); if (!current || current.generation !== generation || current.leaseToken !== leaseToken || controller.signal.aborted) { if (!current && writeSessionCommitted) await storage.remove(record.target, record).catch(() => {}); - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } const rollbackWriteSession = current.writeSessionStartPiece != null && !writeSessionCommitted; const permissionRequired = isFilePermissionError(error, current.target); @@ -1128,7 +1141,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, next, { status: 'downloading', generation, leaseToken, updatedAt: current.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); const nextDelay = nextArchiveScheduleDelay(await store.listArchives(), now()); if (nextDelay != null) schedule(nextDelay); return { processed: false, reason: retrying ? 'retrying' : 'error', archive: next }; diff --git a/src/firefox/src/agent/apocalypse-mode.js b/src/firefox/src/agent/apocalypse-mode.js index 146519e92..25a2b97c1 100644 --- a/src/firefox/src/agent/apocalypse-mode.js +++ b/src/firefox/src/agent/apocalypse-mode.js @@ -822,6 +822,19 @@ export function createApocalypseArchiveManager(options = {}) { let processing = false; if (!store || !storage) throw new Error('Apocalypse Mode requires state and archive storage adapters.'); + async function cancelledResult() { + try { + const config = await store.getConfig(); + if (config?.enabled === true) { + const nextDelay = nextArchiveScheduleDelay(await store.listArchives(), now()); + if (nextDelay != null) schedule(nextDelay); + } + } catch { + // Preserve the cancellation result if rearming the shared alarm fails. + } + return { processed: false, reason: 'cancelled' }; + } + async function getSnapshot() { const [config, archives] = await Promise.all([store.getConfig(), store.listArchives()]); return { @@ -973,7 +986,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, recovered, { status: 'downloading', generation, leaseToken, updatedAt: record.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); record = recovered; } let writer = null; @@ -1006,7 +1019,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, marked, { status: 'downloading', generation, leaseToken, updatedAt: record.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); record = marked; writer = await storage.createWriter(record.target, record); usedWriteSession = true; @@ -1033,7 +1046,7 @@ export function createApocalypseArchiveManager(options = {}) { let current = await store.getArchive(record.id); let currentConfig = await store.getConfig(); if (!ownsDownloadClaim(current, generation, leaseToken, currentConfig)) { - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } if (writer) await writer.write(offset, bytes); else await storage.write(record.target, offset, bytes, record); @@ -1042,7 +1055,7 @@ export function createApocalypseArchiveManager(options = {}) { if (!ownsDownloadClaim(current, generation, leaseToken, currentConfig)) { await abortWriteSession(new Error('Archive download was cancelled.')).catch(() => {}); if (!current) await storage.remove(record.target, record).catch(() => {}); - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } const bytesDownloaded = offset + bytes.byteLength; const finished = bytesDownloaded >= Number(record.size); @@ -1082,7 +1095,7 @@ export function createApocalypseArchiveManager(options = {}) { if (writeSessionCommitted && !await store.getArchive(record.id)) { await storage.remove(record.target, record).catch(() => {}); } - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } piecesProcessed += 1; if (continueInWake) { @@ -1098,7 +1111,7 @@ export function createApocalypseArchiveManager(options = {}) { const current = await store.getArchive(record.id); if (!current || current.generation !== generation || current.leaseToken !== leaseToken || controller.signal.aborted) { if (!current && writeSessionCommitted) await storage.remove(record.target, record).catch(() => {}); - return { processed: false, reason: 'cancelled' }; + return await cancelledResult(); } const rollbackWriteSession = current.writeSessionStartPiece != null && !writeSessionCommitted; const permissionRequired = isFilePermissionError(error, current.target); @@ -1128,7 +1141,7 @@ export function createApocalypseArchiveManager(options = {}) { const saved = await putArchiveIfCurrent(store, next, { status: 'downloading', generation, leaseToken, updatedAt: current.updatedAt, }); - if (!saved) return { processed: false, reason: 'cancelled' }; + if (!saved) return await cancelledResult(); const nextDelay = nextArchiveScheduleDelay(await store.listArchives(), now()); if (nextDelay != null) schedule(nextDelay); return { processed: false, reason: retrying ? 'retrying' : 'error', archive: next }; diff --git a/test/run.js b/test/run.js index f27a5b76f..c9cd8978e 100644 --- a/test/run.js +++ b/test/run.js @@ -21366,6 +21366,60 @@ test('Apocalypse Mode requires opt-in and removal wins an in-flight download rac } }); +test('Apocalypse Mode rearms queued work after pausing or deleting the active download', async () => { + for (const [label, runtime] of [['chrome', ApocalypseModeCh], ['firefox', ApocalypseModeFx]]) { + for (const action of ['pause', 'remove']) { + const active = { + id: `${action}-active`, status: 'queued', generation: 1, updatedAt: 100, + filename: 'active.zim', size: 1, pieceLength: 1, pieceHashAlgorithm: 'sha-1', pieceHashes: ['aa'], + downloadUrl: 'https://example.test/active.zim', target: { kind: 'opfs', key: 'active.zim' }, + pieceIndex: 0, bytesDownloaded: 0, retryCount: 0, + }; + const queued = { + ...active, + id: `${action}-queued`, + filename: 'queued.zim', + downloadUrl: 'https://example.test/queued.zim', + target: { kind: 'opfs', key: 'queued.zim' }, + }; + const records = new Map([[active.id, active], [queued.id, queued]]); + const store = { + async getConfig() { return { enabled: true }; }, + async listArchives() { return [...records.values()].map(record => ({ ...record })); }, + async getArchive(id) { const record = records.get(id); return record ? { ...record } : null; }, + async putArchive(record) { records.set(record.id, { ...record }); return record; }, + async deleteArchive(id) { records.delete(id); }, + }; + let markFetchStarted; + const fetchStarted = new Promise(resolve => { markFetchStarted = resolve; }); + const scheduled = []; + const manager = runtime.createApocalypseArchiveManager({ + store, + storage: { async remove() {} }, + fetchImpl: async (_url, request) => await new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError')), { once: true }); + markFetchStarted(); + }), + digestHex: async () => 'aa', + schedule: delay => scheduled.push(delay), + randomId: () => `${action}-lease`, + now: () => 1000, + }); + + const running = manager.processNext(); + await fetchStarted; + await manager[action](active.id); + const result = await running; + + assert.equal(result.reason, 'cancelled', `${label}: ${action} did not cancel the active download`); + assert.deepEqual(scheduled, [0], `${label}: ${action} stranded the next queued archive`); + assert.equal(records.get(queued.id)?.status, 'queued', `${label}: ${action} changed the next archive state`); + if (action === 'pause') assert.equal(records.get(active.id)?.status, 'paused', `${label}: pause state was lost`); + else assert.equal(records.has(active.id), false, `${label}: removed active archive metadata was retained`); + } + } +}); + test('Apocalypse Mode disabling loses atomically to concurrent archive deletion', async () => { for (const [label, runtime] of [['chrome', ApocalypseModeCh], ['firefox', ApocalypseModeFx]]) { const config = { enabled: true, updatePolicy: 'manual' };