feat(upload): skip unreadable files (FIFOs/sockets) + auto-reload on new deploy
Two robustness fixes behind the recurring "folder upload stuck at ~93%" reports. 1. Skip non-regular files up front. A copied s6/runit service tree contains FIFOs (e.g. supervise/control named pipes) that report a size but BLOCK FOREVER when the browser reads them — the deterministic ~8-files-short that no retry/watchdog tweak could fix. uploadBatch/uploadTree now probe each file's first chunk against a 3 s timeout (partitionReadable), upload only the readable ones, and report the rest: "N uploaded · M skipped (not regular files)". Progress runs over the uploadable count, so it reaches 100% instead of parking at 93% while a lane hangs on a pipe. 2. Auto-reload on a new deploy. svelte.config.js polls _app/version.json (60 s); the root layout reloads itself when the deployed build changes — unless an upload is in flight — so an open tab can't keep running stale code after a rebuild (the recurring "my fix isn't applied" trap). npm run check: 0 errors, 58 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { page, updated } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import '$lib/styles/app.css';
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import DialogHost from '$lib/components/DialogHost.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { hashUrlToPath } from '$lib/utils/hashRedirect';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// A new build was deployed (`_app/version.json` changed, polled per
|
||||
// svelte.config.js) — reload so an open tab never keeps running stale code
|
||||
// after a rebuild. Deferred while a progress notification (e.g. an upload) is
|
||||
// in flight so we don't interrupt it; this effect re-runs when that clears
|
||||
// (notifications are reactive) and reloads then.
|
||||
$effect(() => {
|
||||
if (!updated.current) return;
|
||||
const busy = ui.notifications.some((n) => n.progress !== undefined);
|
||||
if (!busy) location.reload();
|
||||
});
|
||||
|
||||
// Routes reachable without an authenticated session.
|
||||
const PUBLIC_PREFIXES = ['/login', '/device', '/s/', '/nextcloud'];
|
||||
|
||||
|
||||
@@ -372,6 +372,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a file's content can actually be read. FIFOs / sockets /
|
||||
* device files — e.g. the `supervise/control` named pipes in a copied
|
||||
* s6/runit service tree — report a size but BLOCK FOREVER on read; uploading
|
||||
* one would hang its lane until the watchdog (~30 s). We read only the first
|
||||
* chunk, raced against a short timeout: a normal file (even 0-byte) yields
|
||||
* immediately, a pipe stalls and is reported unreadable so we can skip it fast.
|
||||
*/
|
||||
async function isReadable(file: File, timeoutMs = 3000): Promise<boolean> {
|
||||
if (typeof file.stream !== 'function') return true; // can't probe → let the watchdog catch it
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
reader = file.stream().getReader();
|
||||
const firstChunk = reader.read().then(() => true);
|
||||
const timedOut = new Promise<false>((res) => {
|
||||
timer = setTimeout(() => res(false), timeoutMs);
|
||||
});
|
||||
return await Promise.race([firstChunk, timedOut]);
|
||||
} catch {
|
||||
return false; // read threw → not a normal, uploadable file
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
reader?.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Map `fn` over `items` with at most `limit` concurrent calls, preserving order. */
|
||||
async function mapLimit<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Split items into the readable ones and the unreadable (FIFO/socket/…) ones. */
|
||||
async function partitionReadable<T>(
|
||||
items: T[],
|
||||
fileOf: (item: T) => File
|
||||
): Promise<{ readable: T[]; skipped: T[] }> {
|
||||
const ok = await mapLimit(items, 24, (it) => isReadable(fileOf(it)));
|
||||
const readable: T[] = [];
|
||||
const skipped: T[] = [];
|
||||
items.forEach((it, i) => (ok[i] ? readable : skipped).push(it));
|
||||
return { readable, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload `items` ({file, folderId}) with bounded concurrency, a per-file
|
||||
* deadline and live aggregate progress. A stuck or failing file no longer
|
||||
@@ -423,18 +480,35 @@
|
||||
return { savedBytes, failures };
|
||||
}
|
||||
|
||||
/** Resolve the upload's bell notification: success, partial, or failure. */
|
||||
function finishUpload(nid: number, savedBytes: number, failures: number, total: number) {
|
||||
if (failures === 0) {
|
||||
/** Resolve the upload's bell notification: success, partial, skipped, or failure.
|
||||
* `total` is the count of *readable* files actually attempted; `skipped` is the
|
||||
* non-regular files (FIFOs/sockets/…) that were filtered out before uploading. */
|
||||
function finishUpload(
|
||||
nid: number,
|
||||
savedBytes: number,
|
||||
failures: number,
|
||||
total: number,
|
||||
skipped = 0
|
||||
) {
|
||||
const ok = total - failures;
|
||||
if (failures === 0 && skipped === 0) {
|
||||
ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success');
|
||||
} else if (failures < total) {
|
||||
} else if (failures === 0) {
|
||||
// Every readable file uploaded; the rest were non-regular files
|
||||
// (FIFOs/sockets/devices) that can't be read and were skipped.
|
||||
ui.finishProgress(
|
||||
nid,
|
||||
t(
|
||||
'files.uploaded_partial',
|
||||
{ ok: total - failures, failed: failures },
|
||||
`${total - failures} uploaded, ${failures} failed`
|
||||
'files.uploaded_skipped',
|
||||
{ ok, skipped },
|
||||
`${ok} uploaded · ${skipped} skipped (not regular files)`
|
||||
),
|
||||
ok > 0 ? 'success' : 'warning'
|
||||
);
|
||||
} else if (ok > 0) {
|
||||
ui.finishProgress(
|
||||
nid,
|
||||
t('files.uploaded_partial', { ok, failed: failures }, `${ok} uploaded, ${failures} failed`),
|
||||
'warning'
|
||||
);
|
||||
} else {
|
||||
@@ -456,19 +530,28 @@
|
||||
async function uploadBatch(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
uploading = true;
|
||||
const total = files.length;
|
||||
const label = (done: number) =>
|
||||
total === 1
|
||||
? t('files.uploading_file', { name: files[0].name }, `Uploading ${files[0].name}…`)
|
||||
: t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
||||
const nid = ui.startProgress(label(0));
|
||||
const nid = ui.startProgress(
|
||||
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
|
||||
);
|
||||
try {
|
||||
const { savedBytes, failures } = await uploadAll(
|
||||
files.map((file) => ({ file, folderId: currentId })),
|
||||
nid,
|
||||
label
|
||||
);
|
||||
finishUpload(nid, savedBytes, failures, total);
|
||||
// Filter out non-regular files (FIFOs/sockets/devices) up front so they
|
||||
// can't hang a lane — progress then runs over only what's uploadable.
|
||||
const { readable, skipped } = await partitionReadable(files, (f) => f);
|
||||
const total = readable.length;
|
||||
const label = (done: number) =>
|
||||
total === 1
|
||||
? t('files.uploading_file', { name: readable[0].name }, `Uploading ${readable[0].name}…`)
|
||||
: t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
||||
if (total > 0) {
|
||||
const { savedBytes, failures } = await uploadAll(
|
||||
readable.map((file) => ({ file, folderId: currentId })),
|
||||
nid,
|
||||
label
|
||||
);
|
||||
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
||||
} else {
|
||||
finishUpload(nid, 0, 0, 0, skipped.length);
|
||||
}
|
||||
await reload();
|
||||
// Storage usage changed server-side — pull the fresh figure so the
|
||||
// "Almacenamiento" bar moves off its login value instead of 0%.
|
||||
@@ -1103,13 +1186,28 @@
|
||||
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||
if (entries.length === 0) return;
|
||||
uploading = true;
|
||||
const total = entries.length;
|
||||
const label = (done: number) =>
|
||||
t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
||||
// 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(label(0));
|
||||
const nid = ui.startProgress(
|
||||
t(
|
||||
'files.uploading_n',
|
||||
{ done: 0, total: entries.length },
|
||||
`Uploading 0/${entries.length} files…`
|
||||
)
|
||||
);
|
||||
try {
|
||||
// Drop non-regular files (FIFOs/sockets/devices) before building the tree
|
||||
// so they neither create empty folders nor hang a lane on read.
|
||||
const { readable, skipped } = await partitionReadable(entries, (e) => e.file);
|
||||
const total = readable.length;
|
||||
const label = (done: number) =>
|
||||
t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`);
|
||||
if (total === 0) {
|
||||
finishUpload(nid, 0, 0, 0, skipped.length);
|
||||
await reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Map each relative directory path to its created folder id; '' = current.
|
||||
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||
|
||||
@@ -1127,14 +1225,14 @@
|
||||
// concurrent creation of the same dir would race), then upload the
|
||||
// files into it with bounded concurrency.
|
||||
const items: { file: File; folderId: string | null }[] = [];
|
||||
for (const { file, relativePath } of entries) {
|
||||
for (const { file, relativePath } of readable) {
|
||||
const segs = relativePath.split('/');
|
||||
segs.pop(); // drop the filename, keep the directory trail
|
||||
items.push({ file, folderId: await ensureDir(segs.join('/')) });
|
||||
}
|
||||
|
||||
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
||||
finishUpload(nid, savedBytes, failures, total);
|
||||
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
||||
await reload();
|
||||
void session.refresh();
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user