From d234cbfaaecc60d2ec539a4522b2188287107051 Mon Sep 17 00:00:00 2001 From: Lennart-Pingpong Date: Fri, 14 Aug 2026 12:43:43 +0200 Subject: [PATCH 1/2] fix(web): hold a wake lock so a sleeping machine can't kill an upload An upload lives entirely in the browser tab. When 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. The version is left at processing_status='uploading', which in the UI is indistinguishable from a stalled transcode, and the multipart upload stays open until reap_stale_uploads gets to it. The browser is now asked for a Screen Wake Lock while an upload runs. Reference-counted, since several uploads can be in flight; the lock is only released when the last one finishes, and re-acquired on visibilitychange because browsers drop it whenever the tab is hidden. Best-effort on purpose: without HTTPS, in low-power mode, or in a browser without the API there is simply no lock. None of that is a reason to refuse the upload, so every failure path is swallowed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + .../stores/__tests__/upload-wake-lock.test.ts | 87 +++++++++++++++++++ apps/web/stores/upload-store.ts | 59 +++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 apps/web/stores/__tests__/upload-wake-lock.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a16027..7638d83c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **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. + ### Changed - **Pinned `starlette` and `botocore` so self-hosted Docker builds are reproducible** — both were floating transitives, so rebuilding the same commit on a different day could silently install different versions with no diff and no PR. FastAPI declares `starlette>=0.46.0` with no upper bound, meaning builds were free to cross a Starlette major (the ASGI layer under the SSE endpoint and the middleware stack); `botocore` is where S3 request signing lives, and unreviewed moves there have broken S3 compatibility before. Both are pinned to the versions already resolving, so no installed version changes. 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..3d4a55cc --- /dev/null +++ b/apps/web/stores/__tests__/upload-wake-lock.test.ts @@ -0,0 +1,87 @@ +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() + }) +}) diff --git a/apps/web/stores/upload-store.ts b/apps/web/stores/upload-store.ts index cb6c51b0..ae4f589c 100644 --- a/apps/web/stores/upload-store.ts +++ b/apps/web/stores/upload-store.ts @@ -127,6 +127,61 @@ 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 wakeLockHolders = 0 + +async function acquireWakeLock(): Promise { + if (wakeLock) return + if (typeof navigator === 'undefined' || !('wakeLock' in navigator)) return + try { + wakeLock = await navigator.wakeLock.request('screen') + // The browser drops the lock by itself when the tab is hidden. + wakeLock.addEventListener('release', () => { + wakeLock = null + }) + } catch { + // Not having a wake lock is not an upload error. + } +} + +/** Exported for tests. */ +export function retainWakeLock(): void { + wakeLockHolders += 1 + void acquireWakeLock() +} + +/** Exported for tests. */ +export function releaseWakeLock(): void { + wakeLockHolders = Math.max(0, wakeLockHolders - 1) + if (wakeLockHolders > 0) return + void wakeLock?.release().catch(() => {}) + wakeLock = null +} + +// 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) void acquireWakeLock() + }) +} + export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'complete' | 'failed' | 'cancelled' export interface UploadFile { @@ -281,6 +336,7 @@ const storeCreator: StateCreator = try { updateFile(id, { status: 'uploading' }) + retainWakeLock() const initRes = await api.post( '/upload/initiate', @@ -333,6 +389,7 @@ const storeCreator: StateCreator = api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {}) } } finally { + releaseWakeLock() delete abortControllers[id] } })() @@ -369,6 +426,7 @@ const storeCreator: StateCreator = let version_id: string | undefined try { updateFile(id, { status: 'uploading' }) + retainWakeLock() const initRes = await api.post( `/assets/${assetId}/versions`, { @@ -401,6 +459,7 @@ const storeCreator: StateCreator = api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {}) } } finally { + releaseWakeLock() delete abortControllers[id] } })() From f252ae9deab5138e2e8fb25848ae02cd21f821b9 Mon Sep 17 00:00:00 2001 From: ravirajsinh45 Date: Sat, 15 Aug 2026 09:26:26 +0530 Subject: [PATCH 2/2] fix(web): request one wake lock for concurrent uploads, never leak it Two defects in the reference-counted lock, both on paths users hit. acquireWakeLock had no in-flight guard. Dropping several files calls startUpload once per file, and each runs synchronously as far as retainWakeLock() before its first await, so N files meant N concurrent navigator.wakeLock.request('screen') calls. Only the last sentinel was stored in `wakeLock`; the others were never released and held the screen awake for the life of the page. The existing "requests the lock only once" test awaited between the two retains, which is exactly what hid the race. Second, an upload finishing before the request resolved left the lock held forever: releaseWakeLock ran while `wakeLock` was still null, then the sentinel arrived and was stored with the holder count already at zero. The acquire path now re-checks holders after awaiting and releases immediately if everything has finished. Also moves retainWakeLock() above the try, so a throw before it cannot run the finally's release against a concurrent upload's lock, and orders the CHANGELOG sections per Keep a Changelog. Two regression tests added, both failing before this change. --- CHANGELOG.md | 4 +- .../stores/__tests__/upload-wake-lock.test.ts | 36 ++++++++++++++ apps/web/stores/upload-store.ts | 49 +++++++++++++------ 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7638d83c..a0dc08f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed -- **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. - ### Changed - **Pinned `starlette` and `botocore` so self-hosted Docker builds are reproducible** — both were floating transitives, so rebuilding the same commit on a different day could silently install different versions with no diff and no PR. FastAPI declares `starlette>=0.46.0` with no upper bound, meaning builds were free to cross a Starlette major (the ASGI layer under the SSE endpoint and the middleware stack); `botocore` is where S3 request signing lives, and unreviewed moves there have broken S3 compatibility before. Both are pinned to the versions already resolving, so no installed version changes. ### 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 index 3d4a55cc..b24349a2 100644 --- a/apps/web/stores/__tests__/upload-wake-lock.test.ts +++ b/apps/web/stores/__tests__/upload-wake-lock.test.ts @@ -84,4 +84,40 @@ describe('upload wake lock', () => { 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 ae4f589c..85b1a7d5 100644 --- a/apps/web/stores/upload-store.ts +++ b/apps/web/stores/upload-store.ts @@ -144,41 +144,58 @@ export async function uploadAllParts( * released when the last one finishes. */ let wakeLock: WakeLockSentinel | null = null +let wakeLockPending: Promise | null = null let wakeLockHolders = 0 -async function acquireWakeLock(): Promise { - if (wakeLock) return +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 - try { - wakeLock = await navigator.wakeLock.request('screen') - // The browser drops the lock by itself when the tab is hidden. - wakeLock.addEventListener('release', () => { - wakeLock = null - }) - } catch { - // Not having a wake lock is not an upload error. - } + + 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 - void acquireWakeLock() + acquireWakeLock() } /** Exported for tests. */ export function releaseWakeLock(): void { wakeLockHolders = Math.max(0, wakeLockHolders - 1) if (wakeLockHolders > 0) return - void wakeLock?.release().catch(() => {}) + 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) void acquireWakeLock() + if (document.visibilityState === 'visible' && wakeLockHolders > 0) acquireWakeLock() }) } @@ -334,9 +351,9 @@ const storeCreator: StateCreator = let s3_key: string | undefined let version_id: string | undefined + retainWakeLock() try { updateFile(id, { status: 'uploading' }) - retainWakeLock() const initRes = await api.post( '/upload/initiate', @@ -424,9 +441,9 @@ const storeCreator: StateCreator = let upload_id: string | undefined let s3_key: string | undefined let version_id: string | undefined + retainWakeLock() try { updateFile(id, { status: 'uploading' }) - retainWakeLock() const initRes = await api.post( `/assets/${assetId}/versions`, {