diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a16027..a0dc08f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **A single transient storage error no longer discards an entire multipart upload** — each part is now retried up to 8 times with exponential backoff and jitter (≈254s of tolerance per part) instead of failing the whole upload on the first non-OK response. A multi-gigabyte file is several hundred sequential requests, so one 500/503 from the object store or a brief network drop was enough to throw away everything already transferred; on a slow uplink that can be an hour of work. The presigned URL is re-fetched on every attempt, since `presign_upload_part` expires after an hour and a large upload can outlive that. User-initiated cancels are never retried, and a 4xx is thrown straight through instead of being repeated eight times over four minutes — it would fail identically every time, so retrying it only delays the error. The part loop, previously duplicated between new-asset and new-version uploads, is now shared. +- **A machine going to sleep no longer silently kills an upload** — the browser is now asked for a Screen Wake Lock while an upload is in progress, and releases it when the last one finishes. An upload lives entirely in the tab, so a suspend mid-transfer kills the loop pushing chunks; worse, nothing runs afterwards, so the `/upload/abort` call never happens and the version is left at `processing_status = 'uploading'`, which in the UI is indistinguishable from a stalled transcode. Best-effort by design: without HTTPS, in low-power mode, or in a browser without the API there is simply no lock, which never blocks the upload. The lock is re-acquired on `visibilitychange`, since browsers drop it whenever the tab is hidden. ## [1.7.5] - 2026-07-23 diff --git a/apps/web/stores/__tests__/upload-wake-lock.test.ts b/apps/web/stores/__tests__/upload-wake-lock.test.ts new file mode 100644 index 00000000..b24349a2 --- /dev/null +++ b/apps/web/stores/__tests__/upload-wake-lock.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +vi.mock('@/lib/api', () => ({ + api: { + post: vi.fn(), + get: vi.fn(), + }, +})) + +import { retainWakeLock, releaseWakeLock } from '../upload-store' + +/** Minimal stand-in for a WakeLockSentinel. */ +function makeSentinel() { + return { + release: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn(), + } +} + +describe('upload wake lock', () => { + let request: ReturnType + let sentinel: ReturnType + + beforeEach(() => { + sentinel = makeSentinel() + request = vi.fn().mockResolvedValue(sentinel) + Object.defineProperty(navigator, 'wakeLock', { + value: { request }, + configurable: true, + writable: true, + }) + }) + + afterEach(() => { + // Leave no holder behind for the next test. + releaseWakeLock() + releaseWakeLock() + Reflect.deleteProperty(navigator as object, 'wakeLock') + }) + + it('requests a screen wake lock while an upload runs', async () => { + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalledWith('screen')) + }) + + it('releases the lock when the last upload finishes', async () => { + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + + releaseWakeLock() + expect(sentinel.release).toHaveBeenCalled() + }) + + it('holds the lock until every concurrent upload is done', async () => { + retainWakeLock() + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + + releaseWakeLock() + expect(sentinel.release).not.toHaveBeenCalled() // one upload still running + + releaseWakeLock() + expect(sentinel.release).toHaveBeenCalled() + }) + + it('requests the lock only once for concurrent uploads', async () => { + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)) + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)) + }) + + it('is a no-op when the browser has no Wake Lock API', () => { + Reflect.deleteProperty(navigator as object, 'wakeLock') + expect(() => { + retainWakeLock() + releaseWakeLock() + }).not.toThrow() + }) + + it('does not fail the upload when the lock is refused', async () => { + request.mockRejectedValue(new DOMException('denied', 'NotAllowedError')) + expect(() => retainWakeLock()).not.toThrow() + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + expect(() => releaseWakeLock()).not.toThrow() + }) + + // Dropping several files calls startUpload once per file, and each runs + // synchronously as far as retainWakeLock() before its first await. Selecting + // N files therefore means N retains before the first request has resolved. + it('requests a single sentinel when several uploads start in the same tick', async () => { + const acquired: ReturnType[] = [] + request.mockImplementation(async () => { + const next = makeSentinel() + acquired.push(next) + return next + }) + + retainWakeLock() + retainWakeLock() + retainWakeLock() + await vi.waitFor(() => expect(request).toHaveBeenCalled()) + + expect(request).toHaveBeenCalledTimes(1) + + releaseWakeLock() + releaseWakeLock() + releaseWakeLock() + const stillHeld = acquired.filter((s) => s.release.mock.calls.length === 0) + expect(stillHeld).toHaveLength(0) + }) + + it('releases a lock that arrives after the last upload already finished', async () => { + let handOver: (s: unknown) => void = () => {} + request.mockImplementation(() => new Promise((resolve) => { handOver = resolve })) + + retainWakeLock() // the request is in flight + releaseWakeLock() // a short upload finishes before it resolves + handOver(sentinel) // only now does the browser hand the sentinel over + + await vi.waitFor(() => expect(sentinel.release).toHaveBeenCalled()) + }) +}) diff --git a/apps/web/stores/upload-store.ts b/apps/web/stores/upload-store.ts index cb6c51b0..85b1a7d5 100644 --- a/apps/web/stores/upload-store.ts +++ b/apps/web/stores/upload-store.ts @@ -127,6 +127,78 @@ export async function uploadAllParts( return parts } +/** + * Keeps the machine awake while an upload is running. + * + * An upload lives entirely in the browser tab. If the machine suspends + * mid-transfer the loop pushing chunks dies with it — and unlike a failed part, + * nothing runs afterwards: the `catch` that calls `/upload/abort` never + * executes, so the version is left at `processing_status = 'uploading'` and + * looks in the UI like a stalled transcode rather than a dead upload. + * + * Best-effort by design: without HTTPS, in low-power mode, or in a browser + * without the API there is no lock — none of which is a reason to refuse the + * upload. + * + * Reference-counted because several uploads can run at once; the lock is only + * released when the last one finishes. + */ +let wakeLock: WakeLockSentinel | null = null +let wakeLockPending: Promise | null = null +let wakeLockHolders = 0 + +function acquireWakeLock(): void { + // Dropping several files starts several uploads in the same tick, so this + // runs again long before the first request has resolved. Without the pending + // guard each one would request its own sentinel and only the last would be + // tracked, leaving the rest held for the life of the page. + if (wakeLock || wakeLockPending) return + if (typeof navigator === 'undefined' || !('wakeLock' in navigator)) return + + wakeLockPending = (async () => { + try { + const sentinel = await navigator.wakeLock.request('screen') + if (wakeLockHolders === 0) { + // Every upload finished while the request was still in flight. + void sentinel.release().catch(() => {}) + return + } + wakeLock = sentinel + // The browser drops the lock by itself when the tab is hidden. + sentinel.addEventListener('release', () => { + if (wakeLock === sentinel) wakeLock = null + }) + } catch { + // Not having a wake lock is not an upload error. + } finally { + wakeLockPending = null + } + })() +} + +/** Exported for tests. */ +export function retainWakeLock(): void { + wakeLockHolders += 1 + acquireWakeLock() +} + +/** Exported for tests. */ +export function releaseWakeLock(): void { + wakeLockHolders = Math.max(0, wakeLockHolders - 1) + if (wakeLockHolders > 0) return + const held = wakeLock + wakeLock = null + void held?.release().catch(() => {}) +} + +// Coming back to the foreground needs a fresh lock: the one dropped on hide is +// dead and cannot be reused. +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && wakeLockHolders > 0) acquireWakeLock() + }) +} + export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'complete' | 'failed' | 'cancelled' export interface UploadFile { @@ -279,6 +351,7 @@ const storeCreator: StateCreator = let s3_key: string | undefined let version_id: string | undefined + retainWakeLock() try { updateFile(id, { status: 'uploading' }) @@ -333,6 +406,7 @@ const storeCreator: StateCreator = api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {}) } } finally { + releaseWakeLock() delete abortControllers[id] } })() @@ -367,6 +441,7 @@ const storeCreator: StateCreator = let upload_id: string | undefined let s3_key: string | undefined let version_id: string | undefined + retainWakeLock() try { updateFile(id, { status: 'uploading' }) const initRes = await api.post( @@ -401,6 +476,7 @@ const storeCreator: StateCreator = api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {}) } } finally { + releaseWakeLock() delete abortControllers[id] } })()