diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e1700864..fbd9da52 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -175,6 +175,7 @@ export default defineConfig({ { text: "Authentication model", link: "/architecture/auth-model" }, { text: "Magic-link auth", link: "/architecture/magic-link-auth" }, { text: "Background jobs", link: "/architecture/jobs" }, + { text: "UI diagnostics", link: "/architecture/ui-diagnostics" }, ], }, { text: "FAQ", link: "/faq" }, diff --git a/docs/architecture/ui-diagnostics.md b/docs/architecture/ui-diagnostics.md new file mode 100644 index 00000000..10562115 --- /dev/null +++ b/docs/architecture/ui-diagnostics.md @@ -0,0 +1,209 @@ +# UI Diagnostics + +Runtime-tunable knobs and namespaced logging exposed to the browser +DevTools console so upload and session issues can be diagnosed without +a rebuild, a config flag, or a page reload. + +All entry points live under a single global — `window.oxi` — attached +during the client init hook (`frontend/src/hooks.client.ts`). Type +`oxi.` in DevTools with autocomplete to discover what's available; the +sections below name what each surface does. + +## The `oxi.*` helper + +```js +oxi.log // raw `loglevel` module +oxi.setLogLevel(ns, level) // toggle a namespace's level +oxi.listLogLevels() // enumerate persisted overrides +oxi.UPLOAD_BATCH_BYTES // get/set per-PUT byte cap for delta upload +``` + +`oxi.setLogLevel` returns a confirmation string (`"oxi:upload → debug"`) +so the DevTools echo is a positive signal instead of the confusing +`undefined` a naked `void`-returning setter would produce. + +## Log levels + +The frontend uses [`loglevel`](https://github.com/pimterry/loglevel) +with per-namespace levels. Each subsystem gets its own logger; toggle +them independently. + +### Namespaces + +| Namespace | Emitted by | +| ----------- | ------------------------------------------------------------------ | +| `oxi:upload` | Delta + direct upload pipeline (`lib/api/endpoints/deltaUpload.ts`, `static/workers/deltaWorker.js`) | + +New namespaces should follow the `oxi:` shape so a wildcard +filter across the whole app remains meaningful. + +### Levels + +`trace` < `debug` < `info` < `warn` < `error` < `silent`. + +Default per namespace is `info` — phase transitions and error paths +surface without any opt-in. Bump to `debug` for per-chunk / +per-batch verbose trace during a failure hunt. + +### Runtime toggle + +```js +// Deep dive into upload internals +oxi.setLogLevel('oxi:upload', 'debug') +// → 'oxi:upload → debug' + +// Quiet mode — only warnings and errors +oxi.setLogLevel('oxi:upload', 'warn') + +// Full silence +oxi.setLogLevel('oxi:upload', 'silent') + +// See every namespace's current override +oxi.listLogLevels() +// → { 'oxi:upload': 'DEBUG' } + +// Everything (including future namespaces) to debug +oxi.log.setLevel('debug') +``` + +Changes persist to `localStorage` under the key +`loglevel:` — the choice survives page reloads and browser +restarts until you explicitly change it back or clear localStorage. + +Worker context: `deltaWorker.js` runs in a Web Worker and can't +`import 'loglevel'` (the worker is served from `/static` without +bundler resolution). Instead it emits log events via `postMessage` and +the main-thread orchestrator relays them through the shared logger, so +`oxi.setLogLevel('oxi:upload', 'debug')` filters worker output too. + +## Upload batch tuning + +`oxi.UPLOAD_BATCH_BYTES` controls the target size of each `PUT +/api/files/delta/chunks` body — the delta worker groups missing chunks +into that size before sending. Default is 8 MiB; can go up (fewer, +larger requests) or down (more, smaller requests). + +```js +oxi.UPLOAD_BATCH_BYTES // read current value +// → 8388608 + +oxi.UPLOAD_BATCH_BYTES = 1024 * 1024 // 1 MiB per PUT +// → 1048576 + +oxi.UPLOAD_BATCH_BYTES = 8 * 1024 * 1024 // back to default (removes the override) +``` + +Persisted to `localStorage['oxi:upload:batchBytes']`. Setting the value +back to the default clears the entry so the storage stays clean. + +### When to lower it + +Behind reverse proxies with tight per-request timeouts. The classic +case is **Cloudflare Tunnel**: 100-second absolute per-request +timeout on the Free/Pro plans. A user on a slow uplink (say, hotel +Wi-Fi at 512 Kbps) can't complete an 8 MiB PUT in that window and gets +their request cut mid-flight. Lower to 1 MiB (~16 seconds at 512 Kbps) +and it fits comfortably. + +Cost: about 8× more HTTP requests per file. TCP keep-alive amortises +most of the connection setup; the extra CPU is negligible. + +### Read timing + +Read once per upload at worker spawn time. Change from the console → +the NEXT upload picks up the new value; the currently-running upload +finishes with the old value. No reload required. + +## Delta upload trace + +A healthy fresh upload at `info` level looks like: + +``` +[3f7a2b] delta start {file: "vacation.mp4", size: 524288000} +[3f7a2b] worker: worker start {file: "vacation.mp4", size: 524288000} +[3f7a2b] worker: wasm loaded +[3f7a2b] worker: hashed — blake3=<64-hex> (512 chunks) +[3f7a2b] worker: negotiate: 256 hashes → 240 missing, 16 dedup'd +[3f7a2b] worker: negotiate: 256 hashes → 256 missing, 0 dedup'd +[3f7a2b] worker: ✅ committed — uploaded 501346304 B, reused 22941696 B (4% dedup, blake3=) +[3f7a2b] worker: commit HTTP 201 {blake3, uploadedBytes, reusedBytes, totalBytes, attempt} +[3f7a2b] delta done {file, blake3, savedBytes, uploadedBytes} +``` + +At `debug` level the worker additionally emits one line per chunk PUT +(`chunk PUT: 28 chunks, 8825338 bytes`) — expect roughly one line per +`UPLOAD_BATCH_BYTES` worth of body sent. + +Every line prefixes a short 6-hex upload id (`3f7a2b`) so concurrent +uploads stay distinguishable in the console. The `blake3` field is the +whole-file BLAKE3 hash the commit call carried — same value as +`storage.file_blobs.hash` on the server, so log lines correlate +directly to server-side blob rows. + +### Common failure signatures + +| Log line | Meaning | What to try | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `worker fallback: wasm unavailable: ` | Browser blocked WebAssembly (CSP, extension, disabled in prefs) | Check CSP `script-src 'wasm-unsafe-eval'` allows it; disable content-blocking extensions on the tab | +| `delta disabled for this tab: Worker constructor threw` | `new Worker(...)` failed — usually CSP `worker-src` | Check CSP `worker-src` allows `blob:` if the worker uses one, or the origin | +| `worker: chunk PUT failed (HTTP 413)` → `worker requested fallback` | Server (or a proxy in front of it) rejected the PUT body as too large | Lower `oxi.UPLOAD_BATCH_BYTES`; check proxy body caps | +| `worker: chunk PUT failed: ` | Cloudflare (or another proxy) cut the request mid-flight | Lower `oxi.UPLOAD_BATCH_BYTES` so each PUT fits inside the proxy's per-request timeout | +| `worker: negotiate failed (HTTP 5xx)` | Server-side error during the negotiate stage | Server logs; grep for the emitted `request_id` | +| `delta worker went silent for 20s — disabling delta for this tab` | Worker stopped emitting progress — WASM hang, DoS, or extreme main-thread contention. Poisons this tab. | Reload to reset the poison flag; check `console` for errors emitted by the WASM module or the worker itself | +| `delta timeout after Xs — falling back to direct upload` | Wall-clock timeout (`120s + 90s per GB`) — usually means chunk PUTs are stalling | Look for the last `chunk PUT` line; if none appeared for many seconds, network is stalled at the tunnel | +| `delta done with non-2xx (HTTP 500) — falling back to direct upload` | Commit rejected server-side — chunk verification, quota, name conflict, etc. | Server logs; look for `delta_upload.rejected` audit line with a `reason` field | +| `worker: commit HTTP 507` | Storage quota exceeded | User needs to free space or admin needs to increase quota | + +The generic pattern for user reports: ask them to open DevTools → +Console tab (filter: `oxi:upload`), run `oxi.setLogLevel('oxi:upload', +'debug')`, retry the failing upload, and share the output. The last +line before the "Upload failed" toast names the actual failure. + +## Interrupted uploads + +Two coordinated behaviours help users recover from an accidental page +reload during an upload (details in +`frontend/src/lib/upload/interruption.ts`): + +### `beforeunload` guard + +While any upload is in flight, the browser prompts *"Leave site? +Changes may not be saved"* on refresh / tab close. Deliberate leave +(user clicks Leave) proceeds; accidental Cmd-R is cancelled. + +Installed lazily: the listener is added when the first batch acquires +the guard, removed when the last batch releases it. No cost outside +active uploads. + +### sessionStorage register + +Every `uploadBatch` writes a record to +`sessionStorage['oxi:upload:interrupted']` while it runs and removes +it on completion. If a reload survives them, the root layout's +`onMount` reads and clears the register, then toasts: + +> Upload interrupted: ``. Re-drop to resume — already-uploaded +> chunks are reused. + +Chunks landed in `storage.file_blobs` before the reload persist on the +server. A re-drop lets the delta worker's `negotiate` stage discover +them as "already on server", so a resumed upload only transfers what +was in flight when the reload hit — not everything from scratch. + +`sessionStorage` (not `localStorage`) on purpose: entries clear when +the tab closes entirely, so a user who closed the tab hours ago +doesn't get nagged on reopen. + +## Code entry points + +| Concern | File | +| ----------------------------------- | --------------------------------------------------------------- | +| `oxi.*` global attach + setters | `frontend/src/hooks.client.ts` | +| Delta orchestrator (main thread) | `frontend/src/lib/api/endpoints/deltaUpload.ts` | +| Delta worker (CDC + BLAKE3 + PUTs) | `frontend/static/workers/deltaWorker.js` | +| Direct upload (fallback path) | `frontend/src/lib/api/endpoints/files.ts::uploadFileWithProgress` | +| Interrupted-upload registry | `frontend/src/lib/upload/interruption.ts` | +| Reload-time toast wiring | `frontend/src/routes/+layout.svelte` (`onMount`) | + +Server-side counterpart for delta upload: +[Delta upload protocol](../delta-upload-protocol.md). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0180a4e6..03b46a81 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,8 @@ "name": "oxicloud-frontend", "version": "0.0.0", "dependencies": { - "@serenity-kit/opaque": "^1.1.0" + "@serenity-kit/opaque": "^1.1.0", + "loglevel": "^1.9.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -3969,6 +3970,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index daf4d24d..dd0287c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "vitest": "^4.1.9" }, "dependencies": { - "@serenity-kit/opaque": "^1.1.0" + "@serenity-kit/opaque": "^1.1.0", + "loglevel": "^1.9.2" } } diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 38c815c6..5329bb23 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -3,12 +3,106 @@ * - wires the API client's session-expired behaviour (clear store + redirect), * - loads translations for the resolved locale. */ +import log from 'loglevel'; import { setSessionExpiredHandler } from '$lib/api/client'; import { initI18n } from '$lib/i18n/index.svelte'; import { session } from '$lib/stores/session.svelte'; import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; +// DevTools shortcut: expose a small `oxi.*` helper on window so users +// can toggle log levels and knobs from the browser console without +// needing to import anything. +// +// Log levels — namespaces used today: `oxi:upload` (delta + direct +// upload pipeline). Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'. +// Choices persist to `localStorage['loglevel:']` via loglevel. +// +// oxi.setLogLevel('oxi:upload', 'debug') // deep dive +// oxi.setLogLevel('oxi:upload', 'warn') // quiet +// oxi.log.setLevel('debug') // everything to debug +// +// Delta-upload batch size — bytes per PUT to `/api/files/delta/chunks`. +// Default 8 MiB. Behind proxies with tight per-request timeouts +// (Cloudflare Tunnel: 100 s absolute), lower this so each PUT completes +// within the window on a slow uplink: +// +// oxi.UPLOAD_BATCH_BYTES = 1024 * 1024 // 1 MiB per PUT +// +// Persists to `localStorage['oxi:upload:batchBytes']`. Read on every +// upload — set once from the console, refresh not required. +const BATCH_BYTES_KEY = 'oxi:upload:batchBytes'; +const BATCH_BYTES_DEFAULT = 8 * 1024 * 1024; + +function readBatchBytes(): number { + try { + if (typeof localStorage === 'undefined') return BATCH_BYTES_DEFAULT; + const raw = localStorage.getItem(BATCH_BYTES_KEY); + if (!raw) return BATCH_BYTES_DEFAULT; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : BATCH_BYTES_DEFAULT; + } catch { + return BATCH_BYTES_DEFAULT; + } +} + +function writeBatchBytes(n: number): void { + if (typeof localStorage === 'undefined') return; + try { + if (n === BATCH_BYTES_DEFAULT) localStorage.removeItem(BATCH_BYTES_KEY); + else localStorage.setItem(BATCH_BYTES_KEY, String(n)); + } catch { + /* quota / disabled — best-effort */ + } +} + +declare global { + interface Window { + oxi?: { + log: typeof log; + setLogLevel: (namespace: string, level: log.LogLevelDesc) => string; + listLogLevels: () => Record; + UPLOAD_BATCH_BYTES: number; + }; + } +} + export async function init(): Promise { + if (typeof window !== 'undefined') { + const helpers = { + log, + // Return a confirmation string so the DevTools echo is a + // useful "worked → new level" signal instead of `undefined`. + setLogLevel(namespace: string, level: log.LogLevelDesc): string { + log.getLogger(namespace).setLevel(level); + return `${namespace} → ${level}`; + }, + // Enumerate the levels loglevel has persisted so users can see + // what's currently set without opening the Application tab. + listLogLevels(): Record { + const out: Record = {}; + if (typeof localStorage === 'undefined') return out; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith('loglevel:')) { + out[key.slice('loglevel:'.length)] = localStorage.getItem(key) ?? ''; + } + } + return out; + } + }; + // UPLOAD_BATCH_BYTES: getter reads live from localStorage so any + // tab / component pulling `window.oxi.UPLOAD_BATCH_BYTES` sees the + // current value; setter persists so the choice survives reload + // (mirrors loglevel's persistence pattern). + Object.defineProperty(helpers, 'UPLOAD_BATCH_BYTES', { + get: readBatchBytes, + set: writeBatchBytes, + enumerable: true, + configurable: true + }); + window.oxi = helpers as Window['oxi']; + } + setSessionExpiredHandler(() => { session.reset(); if (typeof window !== 'undefined') { diff --git a/frontend/src/lib/api/endpoints/deltaUpload.ts b/frontend/src/lib/api/endpoints/deltaUpload.ts index 802e3cd3..1be1092d 100644 --- a/frontend/src/lib/api/endpoints/deltaUpload.ts +++ b/frontend/src/lib/api/endpoints/deltaUpload.ts @@ -7,10 +7,27 @@ * commits. Any failure resolves `null` so the caller falls back to a plain * byte upload — delta is an optimization, never a gate. */ +import log from 'loglevel'; import { getCsrfToken } from '$lib/api/csrf'; import { createFileByHash, dedupCheckBatch } from '$lib/api/endpoints/files'; import { blake3HexOfFile } from '$lib/vendor/hashWasm'; +// Namespaced logger — level configurable at runtime from the browser +// console via `log.getLogger('oxi:upload').setLevel('debug')`, persisted +// to `localStorage['loglevel:oxi:upload']`. Default = info so common +// phase transitions are visible without extra opt-in; users chasing a +// bug flip to `debug` for per-chunk verbose trace without a page reload. +const uploadLog = log.getLogger('oxi:upload'); +uploadLog.setDefaultLevel('info'); + +// Short random id — one per upload attempt — so multiple concurrent +// files stay distinguishable in the console. +function newUploadId(): string { + const buf = new Uint8Array(3); + crypto.getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); +} + /** Files smaller than this skip delta: the round-trips cost more than the bytes. * Also the upper bound for client-side whole-file hashing (instant by-hash * uploads) — we never read a file larger than this fully into memory. Files at @@ -49,8 +66,32 @@ interface DoneMsg { type: 'done'; status: number; body?: { message?: string; error?: string; still_missing?: unknown }; + /** Final worker-side counters, sourced from the worker so throttled + * progress messages can't undercount on fast dedup-heavy paths. */ + reusedBytes?: number; + uploadedBytes?: number; + /** Whole-file BLAKE3 the delta protocol committed — same value the + * server stores as `file_blobs.hash`. Correlates a client-side log + * line with the resulting server-side blob. */ + fileHash?: string; } -type WorkerMsg = ProgressMsg | FallbackMsg | DoneMsg; +/** Worker-emitted log line forwarded to the main-thread `uploadLog` — the + * worker can't import loglevel from a static file, so it postMessages + * and we relay it through the shared logger. */ +interface LogMsg { + type: 'log'; + level: 'debug' | 'info' | 'warn' | 'error'; + msg: string; + extra?: Record; +} +/** Kept-alive signal the worker emits every 5 s while awaiting a long + * fetch (commit, slow chunk PUT). The orchestrator's on-every-message + * `armStall()` resets the watchdog just from receiving this — the type + * handler below intentionally no-ops so nothing else fires. */ +interface HeartbeatMsg { + type: 'heartbeat'; +} +type WorkerMsg = ProgressMsg | FallbackMsg | DoneMsg | LogMsg | HeartbeatMsg; /** * Try to upload `file` through the delta protocol. Resolves `null` whenever @@ -62,21 +103,38 @@ export function tryDeltaUpload( folderId: string | null | undefined, onProgress?: (pct: number) => void ): Promise { + const id = newUploadId(); if ( !folderId || file.size < DELTA_UPLOAD_MIN_SIZE || usable === false || typeof Worker === 'undefined' ) { + // Not a bug — these are the documented skip conditions. Log at + // debug so verbose-flag users see why delta was skipped; silent + // in the default path (would be noise on every small file). + const reason = !folderId + ? 'no folder id' + : file.size < DELTA_UPLOAD_MIN_SIZE + ? `file below ${DELTA_UPLOAD_MIN_SIZE} B threshold` + : usable === false + ? 'delta previously disabled for this tab' + : 'Worker constructor unavailable'; + uploadLog.debug(`[${id}] delta skipped: ${reason}`, { file: file.name, size: file.size }); return Promise.resolve(null); } + uploadLog.info(`[${id}] delta start`, { file: file.name, size: file.size }); + return new Promise((resolve) => { let worker: Worker; try { worker = new Worker(DELTA_WORKER_URL, { type: 'module' }); - } catch { + } catch (err) { usable = false; + uploadLog.warn(`[${id}] delta disabled for this tab: Worker constructor threw`, { + error: err instanceof Error ? err.message : String(err) + }); resolve(null); return; } @@ -86,13 +144,32 @@ export function tryDeltaUpload( let savedBytes = 0; let stallTimer: ReturnType; + // Page Visibility listener — pause the stall watchdog while the + // tab is hidden. Background tabs get main-thread timer throttling + // (Chrome/Firefox: 1 s min tick, ~5 min hidden → timers may pause + // entirely) but Web Workers keep running at full speed. Without + // this pause, a tab-switch during a large upload would let messages + // queue up on the throttled main thread while the watchdog fires + // spuriously — poisoning `usable = false` for the rest of the + // session even though the worker was healthy the whole time. + let visibilityListener: (() => void) | null = null; const settle = (answer: DeltaUploadAnswer | null) => { clearTimeout(timer); clearTimeout(stallTimer); + if (visibilityListener) { + document.removeEventListener('visibilitychange', visibilityListener); + visibilityListener = null; + } worker.terminate(); resolve(answer); }; - const timer = setTimeout(() => settle(null), timeoutMs); + const timer = setTimeout(() => { + uploadLog.error( + `[${id}] delta timeout after ${Math.round(timeoutMs / 1000)}s — falling back to direct upload`, + { file: file.name, size: file.size } + ); + settle(null); + }, timeoutMs); // Liveness watchdog: a healthy worker posts progress sub-second while it // hashes and uploads. If it goes SILENT this long it is wedged (WASM init @@ -100,19 +177,55 @@ export function tryDeltaUpload( // — exactly what freezes a folder upload ~2 min per large file. Disable // delta for this file AND every later one so they fall straight to a plain // upload instead of each burning the full size-scaled delta timeout. + // + // Long single fetches (commit, slow chunk PUTs) don't emit progress + // on their own — the worker sends `{ type: 'heartbeat' }` every 5 s + // while awaiting a network request so the watchdog stays fresh. const STALL_MS = 20_000; const armStall = () => { clearTimeout(stallTimer); + // Don't count time while the tab is hidden — background throttling + // on the main thread breaks the "no message in 20 s = wedged" + // premise. When the user comes back, the visibility listener + // re-arms. + if (typeof document !== 'undefined' && document.hidden) return; stallTimer = setTimeout(() => { usable = false; + uploadLog.error( + `[${id}] delta worker went silent for ${STALL_MS / 1000}s — disabling delta for this tab (later files this session will go direct)`, + { file: file.name } + ); settle(null); }, STALL_MS); }; + if (typeof document !== 'undefined') { + visibilityListener = () => { + if (document.hidden) clearTimeout(stallTimer); + else armStall(); + }; + document.addEventListener('visibilitychange', visibilityListener); + } armStall(); worker.onmessage = (event: MessageEvent) => { armStall(); // worker is alive — reset the liveness watchdog const msg = event.data; + if (msg.type === 'heartbeat') { + // armStall() above already served its purpose — no other work. + return; + } + if (msg.type === 'log') { + // Relay worker log through the shared logger so runtime-set + // level (via `log.getLogger('oxi:upload').setLevel(...)`) + // filters worker output too. Worker id doesn't know the + // upload id — we prefix it here for correlation. Skip the + // second arg when there's no extras: loglevel would log the + // literal `undefined` next to the message otherwise. + const line = `[${id}] worker: ${msg.msg}`; + if (msg.extra) uploadLog[msg.level](line, msg.extra); + else uploadLog[msg.level](line); + return; + } if (msg.type === 'progress') { savedBytes = msg.reusedBytes; if (onProgress && msg.totalBytes > 0) { @@ -125,33 +238,74 @@ export function tryDeltaUpload( return; } if (msg.type === 'fallback') { + uploadLog.warn(`[${id}] worker requested fallback: ${msg.reason ?? 'no reason'}`, { + file: file.name + }); settle(null); return; } if (msg.type === 'done') { if (msg.status === 201 || msg.status === 200) { - settle({ ok: true, data: msg.body, savedBytes }); + // Prefer the worker's authoritative final counter over + // the throttled progress-message-derived one — throttling + // can hide the reused-bytes update on fast paths. + const finalSaved = msg.reusedBytes ?? savedBytes; + uploadLog.info(`[${id}] delta done`, { + file: file.name, + blake3: msg.fileHash, + savedBytes: finalSaved, + uploadedBytes: msg.uploadedBytes ?? 0 + }); + settle({ ok: true, data: msg.body, savedBytes: finalSaved }); return; } const errorMsg = msg.body?.message || msg.body?.error || `Delta upload failed (HTTP ${msg.status})`; if (msg.status === 507) { + uploadLog.warn(`[${id}] delta hit quota (HTTP 507)`, { file: file.name, errorMsg }); settle({ ok: false, isQuotaError: true, errorMsg }); return; } if (msg.status === 409 && !msg.body?.still_missing) { + uploadLog.warn(`[${id}] delta conflict (HTTP 409)`, { file: file.name, errorMsg }); settle({ ok: false, errorMsg }); return; } + uploadLog.warn( + `[${id}] delta done with non-2xx (HTTP ${msg.status}) — falling back to direct upload`, + { file: file.name, errorMsg } + ); settle(null); } }; - worker.onerror = () => { + worker.onerror = (e) => { usable = false; + // Real browsers pass an ErrorEvent; test doubles fire onerror + // with no argument. Optional-chain so the no-arg path doesn't + // throw on `.message` and mask the real disable-signal. + uploadLog.error(`[${id}] delta worker onerror — disabling delta for this tab`, { + file: file.name, + message: e?.message, + filename: e?.filename, + lineno: e?.lineno + }); settle(null); }; - worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' }); + // Runtime-tunable batch size (`window.oxi.UPLOAD_BATCH_BYTES`, + // persisted to localStorage). Undefined = worker uses its own + // default (8 MiB). Behind Cloudflare Tunnel or other proxies + // with tight per-request timeouts, users can lower it via + // `oxi.UPLOAD_BATCH_BYTES = 1024 * 1024` so each PUT completes + // well inside the proxy's 100 s window on a slow uplink. + const uploadBatchBytes = window.oxi?.UPLOAD_BATCH_BYTES; + worker.postMessage({ + file, + folderId, + name: file.name, + csrfToken: getCsrfToken() || '', + uploadBatchBytes + }); }); } diff --git a/frontend/src/lib/upload/interruption.ts b/frontend/src/lib/upload/interruption.ts new file mode 100644 index 00000000..7ad7dec5 --- /dev/null +++ b/frontend/src/lib/upload/interruption.ts @@ -0,0 +1,128 @@ +/** + * Interrupted-upload registry — small helper so a page reload during + * upload doesn't leave the user without a hint that work was in flight. + * + * Two coordinated behaviours: + * + * 1. `beforeunload` warning while any upload is active. + * Registered lazily: as soon as an upload starts we install a + * page-scope handler that triggers the browser's "Leave site? + * Changes may not be saved" prompt on refresh / tab close. + * Deliberate leave (user clicks Leave) proceeds normally. + * + * 2. `sessionStorage`-backed "interrupted uploads" register. + * Every `uploadBatch` writes a record while it runs, clears it on + * completion. If a reload happens mid-flight, the record survives + * into the next page load and `readAndClearInterrupted` surfaces + * it so the layout can toast: "Uploads were interrupted — re-drop + * the files to resume (already-uploaded chunks reuse)." + * + * `sessionStorage` (not `localStorage`) on purpose: entries clear + * when the tab closes entirely, so a "closed the tab an hour ago" + * user isn't nagged. Only a same-tab reload preserves them. + */ + +/** Key under which the interrupted-uploads register is stored. */ +const STORAGE_KEY = 'oxi:upload:interrupted'; + +/** One record per active `uploadBatch()` invocation. */ +export interface InterruptedRecord { + /** UI-facing description — filename for singleton uploads, "N files" for batches. */ + description: string; + /** Where the batch was targeted. `null` = drive root. */ + folderId: string | null; + /** UNIX ms — used as the identity key that matches start/finish + * calls so multiple concurrent batches don't step on each other. */ + startedAt: number; +} + +// ── Page-scope beforeunload guard ──────────────────────────────────── + +let activeBatches = 0; + +function beforeUnloadHandler(e: BeforeUnloadEvent): void { + // Spec-compliant trigger for the browser's "Leave site?" dialog. + // Modern Chrome/Firefox/Safari all honor preventDefault(). The + // browser shows its own confirmation copy — we can't customize it. + e.preventDefault(); +} + +/** Register a live upload batch so the beforeunload guard is active while + * it runs. Balanced by `releaseUploadGuard` in the batch's finally. */ +export function acquireUploadGuard(): void { + if (typeof window === 'undefined') return; + if (activeBatches === 0) { + window.addEventListener('beforeunload', beforeUnloadHandler); + } + activeBatches++; +} + +/** Match to `acquireUploadGuard`; when the last active batch releases, + * the beforeunload listener is removed so unrelated navigations don't + * trigger the browser's "leave site?" prompt. */ +export function releaseUploadGuard(): void { + if (typeof window === 'undefined') return; + activeBatches = Math.max(0, activeBatches - 1); + if (activeBatches === 0) { + window.removeEventListener('beforeunload', beforeUnloadHandler); + } +} + +// ── sessionStorage register ────────────────────────────────────────── + +function readAll(): InterruptedRecord[] { + if (typeof sessionStorage === 'undefined') return []; + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as InterruptedRecord[]) : []; + } catch { + return []; + } +} + +function writeAll(records: InterruptedRecord[]): void { + if (typeof sessionStorage === 'undefined') return; + try { + if (records.length === 0) sessionStorage.removeItem(STORAGE_KEY); + else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(records)); + } catch { + /* quota exceeded / disabled — silent; the register is best-effort. */ + } +} + +/** Add a record when a batch starts. Returns a handle to pass back to + * `markUploadFinished` on completion / failure — that way multiple + * concurrent batches don't step on each other's entries. */ +export function markUploadStarted(description: string, folderId: string | null): number { + const record: InterruptedRecord = { + description, + folderId, + startedAt: Date.now() + }; + const all = readAll(); + all.push(record); + writeAll(all); + return record.startedAt; +} + +/** Remove the record when the batch completes (success or failure). Uses + * the `markUploadStarted` return value as the identity key. */ +export function markUploadFinished(startedAt: number): void { + const all = readAll(); + const idx = all.findIndex((r) => r.startedAt === startedAt); + if (idx >= 0) { + all.splice(idx, 1); + writeAll(all); + } +} + +/** Read and clear the register — called by the layout on mount. Returns + * what was there. Empties the register in the same call so a subsequent + * refresh doesn't re-notify. */ +export function readAndClearInterrupted(): InterruptedRecord[] { + const all = readAll(); + writeAll([]); + return all; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e0f6f83a..3209bf75 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -15,6 +15,8 @@ import { hashUrlToPath } from '$lib/utils/hashRedirect'; import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker'; import { getOidcProviders, type OidcProviders } from '$lib/api/endpoints/auth'; + import { readAndClearInterrupted } from '$lib/upload/interruption'; + import { t } from '$lib/i18n/index.svelte'; let { children } = $props(); @@ -156,6 +158,32 @@ // loading state) is already in the DOM behind it. document.getElementById('app-splash')?.remove(); + // If a page reload interrupted one or more uploads mid-flight, the + // registry in sessionStorage carries breadcrumbs into the next mount. + // Toast a resume hint — already-uploaded chunks reuse via delta's + // negotiate stage, so re-dropping the same file is fast, not from + // scratch. Reading the register clears it so a subsequent reload + // doesn't re-notify. + const interrupted = readAndClearInterrupted(); + if (interrupted.length > 0) { + const one = interrupted.length === 1; + const msg = one + ? t( + 'files.upload_interrupted_one', + { description: interrupted[0].description }, + `Upload interrupted: ${interrupted[0].description}. Re-drop to resume — already-uploaded chunks are reused.` + ) + : t( + 'files.upload_interrupted_many', + { n: interrupted.length }, + `${interrupted.length} uploads interrupted. Re-drop the files to resume — already-uploaded chunks are reused.` + ); + // 10 s dwell — same pattern as the other long-copy toasts + // (see profile SSO error). Info kind because the situation is + // recoverable and the user only needs to know how to resume. + ui.notify(msg, 'info', 10000); + } + // Redirect old `#/...` bookmarks to the new path before anything else. if (typeof location !== 'undefined' && location.hash.startsWith('#/')) { // hashUrlToPath returns a dynamic in-app path string; resolve() is typed diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 9c5f69cc..fc8ca3f4 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -30,6 +30,12 @@ resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload'; + import { + acquireUploadGuard, + markUploadFinished, + markUploadStarted, + releaseUploadGuard + } from '$lib/upload/interruption'; import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; @@ -710,6 +716,17 @@ async function uploadBatch(files: File[]) { if (files.length === 0) return; uploading = true; + // Arm the reload-guard + persist a "batch in flight" marker so a + // page refresh mid-upload (a) prompts the browser's "Leave site?" + // dialog and (b) leaves a breadcrumb the layout picks up on the + // next mount → toasts a "uploads were interrupted, re-drop to + // resume (chunks reuse)" hint. + acquireUploadGuard(); + const batchDescription = + files.length === 1 + ? files[0].name + : t('files.n_files_batch', { n: files.length }, `${files.length} files`); + const batchHandle = markUploadStarted(batchDescription, currentId); const nid = ui.startProgress( t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`) ); @@ -760,6 +777,8 @@ ui.finishProgress(nid, errorMessage(err), 'error'); } finally { uploading = false; + markUploadFinished(batchHandle); + releaseUploadGuard(); } } @@ -1539,6 +1558,14 @@ async function uploadTree(entries: { file: File; relativePath: string }[]) { if (entries.length === 0) return; uploading = true; + // Same reload-guard + interrupted-uploads breadcrumb as uploadBatch — + // the browser prompts on refresh, and if the user reloads anyway + // the layout picks up the marker on next mount and toasts. + acquireUploadGuard(); + const treeHandle = markUploadStarted( + t('files.n_files_batch', { n: entries.length }, `${entries.length} files`), + currentId + ); // Same bell progress notification as uploadBatch, so folder uploads show // live progress + a final result instead of staying silent until the end. const nid = ui.startProgress( @@ -1594,6 +1621,8 @@ ui.finishProgress(nid, errorMessage(err), 'error'); } finally { uploading = false; + markUploadFinished(treeHandle); + releaseUploadGuard(); } } diff --git a/frontend/static/workers/deltaWorker.js b/frontend/static/workers/deltaWorker.js index 6ceb22e6..2b5cfa3b 100644 --- a/frontend/static/workers/deltaWorker.js +++ b/frontend/static/workers/deltaWorker.js @@ -28,8 +28,13 @@ const WASM_GLUE_URL = '/vendors/hash-wasm/oxicloud_hash_wasm.js'; const SLICE_BYTES = 8 * 1024 * 1024; /** Negotiate after this many freshly hashed chunks (~64 MiB of content). */ const NEGOTIATE_BATCH = 256; -/** Group missing chunks into PUT bodies of at most this many bytes. */ -const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024; +/** Default target size of a PUT body (grouping multiple chunk frames). + * The orchestrator may override this per-upload via the init message's + * `uploadBatchBytes` field, sourced from `window.oxi.UPLOAD_BATCH_BYTES`. + * Lowering it (say to 1 MiB) helps clients behind proxies with tight + * per-request timeouts (Cloudflare Tunnel: 100 s absolute) at the cost + * of more requests per file. */ +const UPLOAD_BATCH_BYTES_DEFAULT = 8 * 1024 * 1024; /** Reclaim consumed queue slots periodically. A head cursor makes dequeue O(1); * compaction bounds the backing array when hashing stays ahead of the network. */ const UPLOAD_QUEUE_COMPACT_AT = 4096; @@ -63,10 +68,54 @@ async function loadWasm() { } workerScope.onmessage = async (event) => { - const { file, folderId, name, csrfToken } = /** @type {{ file: File, folderId: string, name: string, csrfToken: string }} */ (event.data); + const { file, folderId, name, csrfToken, uploadBatchBytes } = /** @type {{ file: File, folderId: string, name: string, csrfToken: string, uploadBatchBytes?: number }} */ (event.data); + // Per-upload override sourced from `window.oxi.UPLOAD_BATCH_BYTES` + // in the main thread. Falls back to the module default (8 MiB). + const uploadBatchBytesEff = + typeof uploadBatchBytes === 'number' && uploadBatchBytes > 0 + ? uploadBatchBytes + : UPLOAD_BATCH_BYTES_DEFAULT; + + /** + * Forward a log line to the main-thread orchestrator, which routes it + * through the shared `loglevel` logger (namespace `oxi:upload`). Worker + * can't `import 'loglevel'` — it's served from /static and isn't + * bundler-resolved — so postMessage is the transport. + * + * @param {'debug'|'info'|'warn'|'error'} level + * @param {string} msg + * @param {Record=} extra + */ + const log = (level, msg, extra) => + workerScope.postMessage({ type: 'log', level, msg, extra }); + + /** + * Wrap a long-running fetch so the main-thread stall watchdog stays + * fresh while the worker is legitimately awaiting the network. The + * watchdog resets on every worker → main-thread message; heartbeats + * every 5 s keep it from firing during a slow commit or chunk PUT. + * Cleanup in `finally` runs regardless of resolve / reject. + * + * @template T + * @param {() => Promise} fn + * @returns {Promise} + */ + const withHeartbeat = async (fn) => { + const hb = setInterval(() => workerScope.postMessage({ type: 'heartbeat' }), 5000); + try { + return await fn(); + } finally { + clearInterval(hb); + } + }; /** @param {string} reason */ - const fallback = (reason) => workerScope.postMessage({ type: 'fallback', reason }); + const fallback = (reason) => { + log('warn', `worker fallback: ${reason}`); + workerScope.postMessage({ type: 'fallback', reason }); + }; + + log('info', `worker start`, { file: name, size: file.size }); /** @type {Record} */ const mutHeaders = { 'Content-Type': 'application/json' }; @@ -75,6 +124,7 @@ workerScope.onmessage = async (event) => { let wasm; try { wasm = await loadWasm(); + log('debug', 'wasm loaded'); } catch (err) { fallback(`wasm unavailable: ${err instanceof Error ? err.message : String(err)}`); return; @@ -139,11 +189,11 @@ workerScope.onmessage = async (event) => { const uploadLoop = async () => { while (!failed) { - // Take up to UPLOAD_BATCH_BYTES from the queue. + // Take up to the effective per-PUT byte cap from the queue. /** @type {WorkerChunk[]} */ const batch = []; let bytes = 0; - while (uploadHead < uploadQueue.length && bytes < UPLOAD_BATCH_BYTES) { + while (uploadHead < uploadQueue.length && bytes < uploadBatchBytesEff) { const c = /** @type {WorkerChunk} */ (uploadQueue[uploadHead]); uploadQueue[uploadHead] = undefined; uploadHead++; @@ -172,23 +222,28 @@ workerScope.onmessage = async (event) => { try { // eslint-disable-next-line no-await-in-loop -- bounded by pool size const wire = await encodeFrames(batch); + log('debug', `chunk PUT: ${batch.length} chunks, ${wire.length} bytes`); // eslint-disable-next-line no-await-in-loop -- bounded by pool size - const response = await fetch('/api/files/delta/chunks', { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) - }, - body: wire - }); + const response = await withHeartbeat(() => + fetch('/api/files/delta/chunks', { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: wire + }) + ); if (!response.ok) { failed = `chunk PUT failed (HTTP ${response.status})`; + log('error', failed); return; } for (const c of batch) uploadedBytes += c.s; progress(); } catch (err) { failed = `chunk PUT failed: ${err instanceof Error ? err.message : String(err)}`; + log('error', failed); return; } } @@ -209,13 +264,16 @@ workerScope.onmessage = async (event) => { const run = negotiateTail.then(async () => { if (failed) return; try { - const response = await fetch('/api/files/delta/negotiate', { - method: 'POST', - headers: mutHeaders, - body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) }) - }); + const response = await withHeartbeat(() => + fetch('/api/files/delta/negotiate', { + method: 'POST', + headers: mutHeaders, + body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) }) + }) + ); if (!response.ok) { failed = failed || `negotiate failed (HTTP ${response.status})`; + log('error', `negotiate failed (HTTP ${response.status})`); return; } const missing = new Set(/** @type {{missing: string[]}} */ (await response.json()).missing); @@ -226,10 +284,15 @@ workerScope.onmessage = async (event) => { reusedBytes += c.s; } } + log( + 'info', + `negotiate: ${fresh.length} hashes → ${missing.size} missing, ${fresh.length - missing.size} dedup'd` + ); signalUploaders(); progress(); } catch (err) { failed = failed || `negotiate failed: ${err instanceof Error ? err.message : String(err)}`; + log('error', `negotiate failed: ${err instanceof Error ? err.message : String(err)}`); } }); negotiateTail = run.catch(() => {}); @@ -281,6 +344,10 @@ workerScope.onmessage = async (event) => { const fileHash = /** @type {string} */ (fin.file_hash); hashedBytes = file.size; progress(true); + // Log the whole-file BLAKE3 immediately so it's visible in the + // trace regardless of whether the commit succeeds. Correlates + // the client-side view with the server's `file_blobs.hash`. + log('info', `hashed — blake3=${fileHash} (${chunks.length} chunks)`); // ── Drain: negotiations → uploads → commit ─────────────── await Promise.all(negotiations); @@ -300,11 +367,13 @@ workerScope.onmessage = async (event) => { }; for (let attempt = 0; ; attempt++) { // eslint-disable-next-line no-await-in-loop -- retry loop - const response = await fetch('/api/files/delta/commit', { - method: 'POST', - headers: mutHeaders, - body: JSON.stringify(commitBody) - }); + const response = await withHeartbeat(() => + fetch('/api/files/delta/commit', { + method: 'POST', + headers: mutHeaders, + body: JSON.stringify(commitBody) + }) + ); /** @type {any} */ let body = null; try { @@ -329,14 +398,16 @@ workerScope.onmessage = async (event) => { } const wire = await encodeFrames(retry); // eslint-disable-next-line no-await-in-loop -- retry loop - const put = await fetch('/api/files/delta/chunks', { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) - }, - body: wire - }); + const put = await withHeartbeat(() => + fetch('/api/files/delta/chunks', { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: wire + }) + ); if (!put.ok) { fallback(`retry chunk PUT failed (HTTP ${put.status})`); return; @@ -349,7 +420,39 @@ workerScope.onmessage = async (event) => { // Conclusive: 201 created, or a real error (quota, name // conflict, validation). The spawner maps it to the uploaders' // UploadAnswer contract. - workerScope.postMessage({ type: 'done', status: response.status, body }); + const ok = response.status >= 200 && response.status < 300; + if (ok) { + // Human-friendly outcome line — the raw commit line below + // still carries the byte counts for anyone who wants them. + if (uploadedBytes === 0 && reusedBytes > 0) { + log('info', `✅ file already on server — 100% dedup, no bytes transferred (${reusedBytes.toLocaleString()} B reused, blake3=${fileHash})`); + } else if (reusedBytes > 0) { + const pct = Math.round((100 * reusedBytes) / file.size); + log('info', `✅ committed — uploaded ${uploadedBytes.toLocaleString()} B, reused ${reusedBytes.toLocaleString()} B (${pct}% dedup, blake3=${fileHash})`); + } else { + log('info', `✅ committed — uploaded ${uploadedBytes.toLocaleString()} B (no dedup, blake3=${fileHash})`); + } + } + log(ok ? 'info' : 'warn', `commit HTTP ${response.status}`, { + blake3: fileHash, + uploadedBytes, + reusedBytes, + totalBytes: file.size, + attempt, + }); + // Include the final counters + file hash on the done envelope + // so the orchestrator's summary is accurate even when the last + // throttled progress() got skipped (fast dedup-heavy paths + // complete under 150ms — progress' throttle window — so + // reusedBytes never surfaced via a progress message). + workerScope.postMessage({ + type: 'done', + status: response.status, + body, + reusedBytes, + uploadedBytes, + fileHash, + }); return; } } catch (err) {