Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
123 changes: 123 additions & 0 deletions apps/web/stores/__tests__/upload-wake-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>
let sentinel: ReturnType<typeof makeSentinel>

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<typeof makeSentinel>[] = []
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())
})
})
76 changes: 76 additions & 0 deletions apps/web/stores/upload-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | 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 {
Expand Down Expand Up @@ -279,6 +351,7 @@ const storeCreator: StateCreator<UploadStore, [['zustand/persist', unknown]]> =
let s3_key: string | undefined
let version_id: string | undefined

retainWakeLock()
try {
updateFile(id, { status: 'uploading' })

Expand Down Expand Up @@ -333,6 +406,7 @@ const storeCreator: StateCreator<UploadStore, [['zustand/persist', unknown]]> =
api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {})
}
} finally {
releaseWakeLock()
delete abortControllers[id]
}
})()
Expand Down Expand Up @@ -367,6 +441,7 @@ const storeCreator: StateCreator<UploadStore, [['zustand/persist', unknown]]> =
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<VersionInitiateResponse>(
Expand Down Expand Up @@ -401,6 +476,7 @@ const storeCreator: StateCreator<UploadStore, [['zustand/persist', unknown]]> =
api.post('/upload/abort', { s3_key, upload_id, version_id }).catch(() => {})
}
} finally {
releaseWakeLock()
delete abortControllers[id]
}
})()
Expand Down