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">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page, updated } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import '$lib/styles/app.css';
|
import '$lib/styles/app.css';
|
||||||
import AppShell from '$lib/components/AppShell.svelte';
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
import DialogHost from '$lib/components/DialogHost.svelte';
|
import DialogHost from '$lib/components/DialogHost.svelte';
|
||||||
import Toaster from '$lib/components/Toaster.svelte';
|
import Toaster from '$lib/components/Toaster.svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
import { hashUrlToPath } from '$lib/utils/hashRedirect';
|
import { hashUrlToPath } from '$lib/utils/hashRedirect';
|
||||||
|
|
||||||
let { children } = $props();
|
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.
|
// Routes reachable without an authenticated session.
|
||||||
const PUBLIC_PREFIXES = ['/login', '/device', '/s/', '/nextcloud'];
|
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
|
* Upload `items` ({file, folderId}) with bounded concurrency, a per-file
|
||||||
* deadline and live aggregate progress. A stuck or failing file no longer
|
* deadline and live aggregate progress. A stuck or failing file no longer
|
||||||
@@ -423,18 +480,35 @@
|
|||||||
return { savedBytes, failures };
|
return { savedBytes, failures };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the upload's bell notification: success, partial, or failure. */
|
/** Resolve the upload's bell notification: success, partial, skipped, or failure.
|
||||||
function finishUpload(nid: number, savedBytes: number, failures: number, total: number) {
|
* `total` is the count of *readable* files actually attempted; `skipped` is the
|
||||||
if (failures === 0) {
|
* 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');
|
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(
|
ui.finishProgress(
|
||||||
nid,
|
nid,
|
||||||
t(
|
t(
|
||||||
'files.uploaded_partial',
|
'files.uploaded_skipped',
|
||||||
{ ok: total - failures, failed: failures },
|
{ ok, skipped },
|
||||||
`${total - failures} uploaded, ${failures} failed`
|
`${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'
|
'warning'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -456,19 +530,28 @@
|
|||||||
async function uploadBatch(files: File[]) {
|
async function uploadBatch(files: File[]) {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
uploading = true;
|
uploading = true;
|
||||||
const total = files.length;
|
const nid = ui.startProgress(
|
||||||
const label = (done: number) =>
|
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
|
||||||
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));
|
|
||||||
try {
|
try {
|
||||||
const { savedBytes, failures } = await uploadAll(
|
// Filter out non-regular files (FIFOs/sockets/devices) up front so they
|
||||||
files.map((file) => ({ file, folderId: currentId })),
|
// can't hang a lane — progress then runs over only what's uploadable.
|
||||||
nid,
|
const { readable, skipped } = await partitionReadable(files, (f) => f);
|
||||||
label
|
const total = readable.length;
|
||||||
);
|
const label = (done: number) =>
|
||||||
finishUpload(nid, savedBytes, failures, total);
|
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();
|
await reload();
|
||||||
// Storage usage changed server-side — pull the fresh figure so the
|
// Storage usage changed server-side — pull the fresh figure so the
|
||||||
// "Almacenamiento" bar moves off its login value instead of 0%.
|
// "Almacenamiento" bar moves off its login value instead of 0%.
|
||||||
@@ -1103,13 +1186,28 @@
|
|||||||
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
uploading = true;
|
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
|
// Same bell progress notification as uploadBatch, so folder uploads show
|
||||||
// live progress + a final result instead of staying silent until the end.
|
// 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 {
|
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.
|
// Map each relative directory path to its created folder id; '' = current.
|
||||||
const dirIds = new Map<string, string | null>([['', currentId]]);
|
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||||
|
|
||||||
@@ -1127,14 +1225,14 @@
|
|||||||
// concurrent creation of the same dir would race), then upload the
|
// concurrent creation of the same dir would race), then upload the
|
||||||
// files into it with bounded concurrency.
|
// files into it with bounded concurrency.
|
||||||
const items: { file: File; folderId: string | null }[] = [];
|
const items: { file: File; folderId: string | null }[] = [];
|
||||||
for (const { file, relativePath } of entries) {
|
for (const { file, relativePath } of readable) {
|
||||||
const segs = relativePath.split('/');
|
const segs = relativePath.split('/');
|
||||||
segs.pop(); // drop the filename, keep the directory trail
|
segs.pop(); // drop the filename, keep the directory trail
|
||||||
items.push({ file, folderId: await ensureDir(segs.join('/')) });
|
items.push({ file, folderId: await ensureDir(segs.join('/')) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
||||||
finishUpload(nid, savedBytes, failures, total);
|
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
||||||
await reload();
|
await reload();
|
||||||
void session.refresh();
|
void session.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -503,6 +503,7 @@
|
|||||||
"uploaded": "Upload complete",
|
"uploaded": "Upload complete",
|
||||||
"uploaded_saved": "Upload complete — {{mb}} MB deduplicated",
|
"uploaded_saved": "Upload complete — {{mb}} MB deduplicated",
|
||||||
"uploaded_partial": "{{ok}} uploaded, {{failed}} failed",
|
"uploaded_partial": "{{ok}} uploaded, {{failed}} failed",
|
||||||
|
"uploaded_skipped": "{{ok}} uploaded · {{skipped}} skipped (not regular files)",
|
||||||
"upload_failed": "Upload failed",
|
"upload_failed": "Upload failed",
|
||||||
"uploading": "Uploading…",
|
"uploading": "Uploading…",
|
||||||
"uploading_file": "Uploading {{name}}…",
|
"uploading_file": "Uploading {{name}}…",
|
||||||
|
|||||||
@@ -369,6 +369,7 @@
|
|||||||
"selected_count": "{{count}} seleccionados",
|
"selected_count": "{{count}} seleccionados",
|
||||||
"uploaded_saved": "Subida completa — {{mb}} MB deduplicados",
|
"uploaded_saved": "Subida completa — {{mb}} MB deduplicados",
|
||||||
"uploaded_partial": "{{ok}} subidos, {{failed}} fallaron",
|
"uploaded_partial": "{{ok}} subidos, {{failed}} fallaron",
|
||||||
|
"uploaded_skipped": "{{ok}} subidos · {{skipped}} omitidos (no son ficheros normales)",
|
||||||
"upload_failed": "La subida falló"
|
"upload_failed": "La subida falló"
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ const config = {
|
|||||||
// All routes are client-rendered; SSR/prerender are disabled in +layout.ts.
|
// All routes are client-rendered; SSR/prerender are disabled in +layout.ts.
|
||||||
alias: {
|
alias: {
|
||||||
$lib: './src/lib'
|
$lib: './src/lib'
|
||||||
|
},
|
||||||
|
// Poll `_app/version.json` so an open tab notices a fresh deploy; the root
|
||||||
|
// layout reloads itself when it does, instead of silently running stale
|
||||||
|
// code after a rebuild (the classic "my fix isn't applied" trap).
|
||||||
|
version: {
|
||||||
|
pollInterval: 60000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user