diff --git a/PanTS-Demo/src/components/ProcessingSummaryBar.tsx b/PanTS-Demo/src/components/ProcessingSummaryBar.tsx index 64f3ccc..2c3ec05 100644 --- a/PanTS-Demo/src/components/ProcessingSummaryBar.tsx +++ b/PanTS-Demo/src/components/ProcessingSummaryBar.tsx @@ -9,6 +9,8 @@ type Props = { done: number; // scans finished in this batch statusLabel: string; // dominant phase, e.g. "Running…" title?: string; // defaults to "Processing scans" + closeNote?: string; // e.g. "safe to close" - whether the tab is still needed + closeReady?: boolean; // true once nothing is uploading (tints the note green) onViewDetails?: () => void; // per-scan status / view / download for the batch onCancelAll?: () => void; }; @@ -16,7 +18,7 @@ type Props = { const SIZE = 46; const STROKE = 4; -const ProcessingSummaryBar: React.FC = ({ running, done, statusLabel, title = "Processing scans", onViewDetails, onCancelAll }) => { +const ProcessingSummaryBar: React.FC = ({ running, done, statusLabel, title = "Processing scans", closeNote, closeReady, onViewDetails, onCancelAll }) => { const total = running + done; const pct = total > 0 ? Math.round((done / total) * 100) : 0; @@ -59,8 +61,17 @@ const ProcessingSummaryBar: React.FC = ({ running, done, statusLabel, tit
- {statusLabel} - {running > 0 && ` · ${running} in progress`} + {/* One text run, not sibling flex items - otherwise the row's gap + opens a hole before the note and wraps it onto its own line. */} + + {statusLabel} + {running > 0 && ` · ${running} in progress`} + {closeNote && ( + + {" "}· {closeNote} + + )} +
diff --git a/PanTS-Demo/src/helpers/pendingUploads.ts b/PanTS-Demo/src/helpers/pendingUploads.ts index 6e2483e..3fd6df3 100644 --- a/PanTS-Demo/src/helpers/pendingUploads.ts +++ b/PanTS-Demo/src/helpers/pendingUploads.ts @@ -25,6 +25,11 @@ export type PendingUpload = { // over the server's existing chunk-N files and finalize into a corrupt image. // Absent on records written before this field existed - treat those as 256 KiB. chunkSize?: number; + // Set once the file is fully transferred and finalized server-side, but before + // its inference job exists. Its presence flips the meaning of the record from + // "resume the upload" to "just re-issue the inference call" - the bytes are + // already on the server, so `file` is emptied at the same time. + uploadedFilename?: string; }; /** Chunk size to use when resuming `p`, honouring what it was originally cut at. */ @@ -96,6 +101,36 @@ export async function setPendingNextChunk(sessionId: string, nextChunk: number): } } +/** Flag an upload as fully transferred and finalized, awaiting its inference job. + * Drops the file blob at the same time - the bytes live on the server now, so a + * resume only needs to re-issue the inference call, and holding a multi-hundred-MB + * copy until then would be pure quota pressure. */ +export async function setPendingUploaded( + sessionId: string, + uploadedFilename: string, +): Promise { + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + const store = tx.objectStore(STORE); + const getReq = store.get(sessionId); + getReq.onsuccess = () => { + const rec = getReq.result as PendingUpload | undefined; + if (rec) { + rec.uploadedFilename = uploadedFilename; + rec.file = new Blob(); + store.put(rec); + } + }; + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); + } catch (e) { + console.warn("setPendingUploaded failed", e); + } +} + export async function deletePendingUpload(sessionId: string): Promise { try { await withStore("readwrite", store => store.delete(sessionId)); diff --git a/PanTS-Demo/src/routes/UploadPage.css b/PanTS-Demo/src/routes/UploadPage.css index 0eefbd4..dd8b375 100644 --- a/PanTS-Demo/src/routes/UploadPage.css +++ b/PanTS-Demo/src/routes/UploadPage.css @@ -1130,6 +1130,14 @@ height: 12px; border-width: 2px; } +/* "safe to close" hint, tucked onto the status line - lighter than the status + beside it so it reads as an aside, not another piece of state. */ +.proc-close-note { + color: #9a9a9a; +} +.proc-close-note--ready { + color: #15803d; +} .proc-cancel { flex-shrink: 0; } diff --git a/PanTS-Demo/src/routes/UploadPage.tsx b/PanTS-Demo/src/routes/UploadPage.tsx index e868d1f..83ee9e5 100644 --- a/PanTS-Demo/src/routes/UploadPage.tsx +++ b/PanTS-Demo/src/routes/UploadPage.tsx @@ -75,6 +75,7 @@ import { loadPendingUploads, savePendingUpload, setPendingNextChunk, + setPendingUploaded, type PendingUpload, } from "../helpers/pendingUploads"; import { postWithRetry, resolveResumeStart } from "../helpers/chunkUpload"; @@ -91,6 +92,15 @@ const parseApiResponse = async (res: Response): Promise => { ); }; +// Coarse on purpose: a to-the-second countdown on a throughput estimate reads as +// precision that isn't there, and jitters distractingly. +const formatEta = (seconds: number): string => { + if (seconds < 45) return "<1 min"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `~${minutes} min`; + return `~${Math.round(seconds / 360) / 10} h`; +}; + // A selection is either a single NIfTI file or a picked DICOM folder (the series' // raw .dcm slices). Both are previewable individually and runnable through inference. type SelectedItem = @@ -121,6 +131,19 @@ const UploadPage: React.FC = () => { const uploadAbortRef = useRef>(new Map()); // Which session currently drives the foreground upload progress bar. const foregroundUploadSidRef = useRef(null); + // Uploads run ONE FILE AT A TIME through this chain. Total upload time is + // bandwidth-bound either way, but serializing makes the first file land at + // ~T/N instead of ~T - and since each file is dispatched to the server's job + // queue the moment its own upload finishes, the GPU starts chewing through + // the batch while the remaining files are still going up. + const uploadChainRef = useRef>(Promise.resolve()); + // Bytes still to send, per in-flight session. Summed for the "safe to close" + // estimate and dropped when a run ends, so a cancelled or failed file can't + // leave phantom bytes inflating the estimate for its siblings. + const uploadRemainingRef = useRef>(new Map()); + // Monotonic count of bytes actually put on the wire; the ticker below diffs + // it to measure throughput. + const bytesSentRef = useRef(0); const [selectedItems, setSelectedItems] = useState([]); // Which selected item's inline preview is open (null = none). One at a time. @@ -155,10 +178,25 @@ const UploadPage: React.FC = () => { ); // Which batch's "View details" popup is open (null = none). const [detailsBatchId, setDetailsBatchId] = useState(null); - // Sub-state of each Active card: "uploading" | "queued" | "running". + // Sub-state of each Active card: "waiting" | "uploading" | "queued" | "running". const [sessionPhases, setSessionPhases] = useState>( {}, ); + // Drives the "safe to close this tab" line. `active` = bytes still going up + // (the tab is needed); `eta` = seconds until that stops, or null while + // throughput is still being measured. + const [closeInfo, setCloseInfo] = useState<{ + active: boolean; + eta: number | null; + }>({ active: false, eta: null }); + + // Queue a file's upload behind whatever is already uploading. + const enqueueUpload = (task: () => Promise): void => { + uploadChainRef.current = uploadChainRef.current + .catch(() => {}) + .then(task) + .catch(() => {}); + }; const setPhase = (sid: string, phase?: string) => setSessionPhases((prev) => { @@ -381,8 +419,17 @@ const UploadPage: React.FC = () => { const pendingById = new Map(pending.map((p) => [p.sessionId, p])); for (const u of processing) { - if (pendingById.has(u.sessionId)) { - runUpload(pendingById.get(u.sessionId)!, false); // resume the upload + const p = pendingById.get(u.sessionId); + if (p?.uploadedFilename) { + // Fully uploaded, but the tab closed before its job was created. The + // file is already on the server - just replay the inference call. Not + // queued behind the resuming uploads: it costs one POST and getting it + // into the GPU queue now is the whole point. + dispatchInference(p.sessionId, p.model, p.uploadedFilename, false); + } else if (p) { + setPhase(u.sessionId, "waiting"); + uploadRemainingRef.current.set(p.sessionId, p.file.size); + enqueueUpload(() => runUpload(p, false)); // resume the upload } else { startInferencePolling(u.sessionId, u.model); // resume polling } @@ -420,6 +467,36 @@ const UploadPage: React.FC = () => { return () => window.removeEventListener("beforeunload", handler); }, [isUploading]); + // "Safe to close" estimate. Only the upload needs this tab: once a file is + // dispatched it lives in the server's DB-backed job queue behind the GPU lock, + // so the tab becomes disposable the moment the last byte lands. Rather than + // accumulating a fixed total (which a cancel or failure would leave stale), + // both terms are re-derived every tick from live state: remaining bytes from + // the per-session map, throughput from the delta in bytes actually sent. + useEffect(() => { + let lastBytes = bytesSentRef.current; + let rate = 0; // bytes/sec, smoothed - raw per-second deltas are far too jumpy + const timer = setInterval(() => { + const sent = bytesSentRef.current; + const delta = sent - lastBytes; + lastBytes = sent; + rate = rate === 0 ? delta : rate * 0.7 + delta * 0.3; + + let remaining = 0; + uploadRemainingRef.current.forEach((bytes) => { + remaining += bytes; + }); + const active = uploadRemainingRef.current.size > 0; + setCloseInfo({ + active, + // Below ~1 KB/s the estimate is noise (or the connection stalled) - + // show "uploading" with no number rather than an absurd one. + eta: active && rate > 1024 ? Math.max(1, Math.round(remaining / rate)) : null, + }); + }, 1000); + return () => clearInterval(timer); + }, []); + useEffect(() => { if (!modelDropOpen) return; const handler = (e: MouseEvent) => { @@ -470,6 +547,61 @@ const UploadPage: React.FC = () => { // not a client one. const CHUNK_SIZE = 512 * 1024; + // Hand an already-uploaded file to the server's job queue. Split out of the + // upload path because the two halves fail independently: the bytes are on the + // server before this runs, so a tab closed in this window only needs the + // inference call replayed, not the whole file re-sent. Once this returns + // successfully the run is entirely server-side and the tab is free. + const dispatchInference = async ( + sid: string, + model: string, + uploadedName: string, + foreground: boolean, + ) => { + // Reuse the upload's controller when there is one, so a Cancel pressed + // during the upload still aborts this call. + let controller = uploadAbortRef.current.get(sid); + if (!controller) { + controller = new AbortController(); + uploadAbortRef.current.set(sid, controller); + } + try { + if (foreground) setMessage(`Starting ${model} inference...`); + const inferFd = new FormData(); + inferFd.append("session_id", sid); + inferFd.append("model_name", model); + inferFd.append("uploaded_filename", uploadedName); + const res = await fetch(`${API_BASE}/api/run-epai-inference`, { + method: "POST", + body: inferFd, + credentials: "include", + signal: controller.signal, + }); + const data = await parseApiResponse(res); + if (!res.ok) throw new Error(data.error || "Failed to start inference"); + + // Queued server-side now - nothing here is needed to finish the run, so + // drop the resumable record. + await deletePendingUpload(sid); + setSessionId(sid); + setPhase(sid, "queued"); // server queues for the GPU; poll refines this + if (foreground) setMessage(`${model} inference started. Session: ${sid}`); + startInferencePolling(sid, model); + } catch (err) { + if (controller.signal.aborted) return; + console.error(err); + setPhase(sid); + await deletePendingUpload(sid); + setRecentUploads(updateRecentUploadStatus(sid, "Failed")); + // The card already shows "Failed" — don't duplicate it in the status line. + if (foreground) setMessage(""); + } finally { + if (uploadAbortRef.current.get(sid) === controller) { + uploadAbortRef.current.delete(sid); + } + } + }; + // Uploads the file described by `p`, finalizes, then starts inference. // Resumable: the file lives in IndexedDB and the chunk cursor is persisted, so // a reload can call this again to pick up where it left off - starting from @@ -498,6 +630,14 @@ const UploadPage: React.FC = () => { ? await resolveResumeStart(API_BASE, sid, p.nextChunk) : 0; + // Correct the "safe to close" estimate for what the server already holds: + // startScanRun registered the whole file, but a resume only re-sends the + // tail. + uploadRemainingRef.current.set( + sid, + Math.max(0, file.size - startChunk * chunkSizeOf(p)), + ); + if (foreground) { foregroundUploadSidRef.current = sid; setIsUploading(true); @@ -592,6 +732,11 @@ const UploadPage: React.FC = () => { completedCount++; completedIndices.add(i); + bytesSentRef.current += chunk.size; + uploadRemainingRef.current.set( + sid, + Math.max(0, (uploadRemainingRef.current.get(sid) ?? 0) - chunk.size), + ); await advanceWatermark(); if (foreground) setUploadProgress(Math.round((completedCount / totalChunks) * 100)); @@ -633,33 +778,18 @@ const UploadPage: React.FC = () => { if (!finalizeRes.ok) throw new Error(finalizeData.error); const uploadedName = finalizeData.uploaded_filename || filename; - // File is fully on the server now - drop the IDB copy before we kick off - // inference so a later reload resumes by polling, not re-uploading. - await deletePendingUpload(sid); + // The bytes are on the server but no job exists yet. Keep the IDB record + // (minus the now-pointless file blob) flagged as uploaded, so a tab closed + // in this window resumes by dispatching rather than by re-uploading a file + // the server already has - or worse, failing it. dispatchInference clears it. + await setPendingUploaded(sid, uploadedName); if (foreground) { foregroundUploadSidRef.current = null; setUploadProgress(100); setIsUploading(false); } - if (foreground) setMessage(`Starting ${model} inference...`); - const inferFd = new FormData(); - inferFd.append("session_id", sid); - inferFd.append("model_name", model); - inferFd.append("uploaded_filename", uploadedName); - const res = await fetch(`${API_BASE}/api/run-epai-inference`, { - method: "POST", - body: inferFd, - credentials: "include", - signal: controller.signal, - }); - const data = await parseApiResponse(res); - if (!res.ok) throw new Error(data.error || "Failed to start inference"); - - setSessionId(sid); - setPhase(sid, "queued"); // server queues for the GPU; poll refines this - if (foreground) setMessage(`${model} inference started. Session: ${sid}`); - startInferencePolling(sid, model); + await dispatchInference(sid, model, uploadedName, foreground); } catch (err) { // A user cancel aborts our fetches - cancelRun already did the cleanup // and set the card to Cancelled, so don't overwrite that with Failed. @@ -680,6 +810,9 @@ const UploadPage: React.FC = () => { // The card already shows "Failed" — don't duplicate it in the status line. if (foreground) setMessage(""); } finally { + // Whatever happened, this file is no longer contributing bytes - drop it + // so a cancel/failure can't leave its unsent bytes inflating the estimate. + uploadRemainingRef.current.delete(sid); if (uploadAbortRef.current.get(sid) === controller) { uploadAbortRef.current.delete(sid); } @@ -720,6 +853,11 @@ const UploadPage: React.FC = () => { ); const data = await parseApiResponse(res); if (!res.ok) throw new Error(data.error || "DICOM slice upload failed"); + bytesSentRef.current += files[i].size; + uploadRemainingRef.current.set( + sid, + Math.max(0, (uploadRemainingRef.current.get(sid) ?? 0) - files[i].size), + ); setUploadProgress(Math.round(((i + 1) / files.length) * 100)); } @@ -738,24 +876,7 @@ const UploadPage: React.FC = () => { setUploadProgress(100); setIsUploading(false); - setMessage(`Starting ${model} inference...`); - const inferFd = new FormData(); - inferFd.append("session_id", sid); - inferFd.append("model_name", model); - inferFd.append("uploaded_filename", uploadedName); - const res = await fetch(`${API_BASE}/api/run-epai-inference`, { - method: "POST", - body: inferFd, - credentials: "include", - signal: controller.signal, - }); - const data = await parseApiResponse(res); - if (!res.ok) throw new Error(data.error || "Failed to start inference"); - - setSessionId(sid); - setPhase(sid, "queued"); // server queues for the GPU; poll refines this - setMessage(`${model} inference started. Session: ${sid}`); - startInferencePolling(sid, model); + await dispatchInference(sid, model, uploadedName, true); } catch (err) { // A user cancel aborts our fetches - cancelRun already set the card to // Cancelled, so don't overwrite that with Failed. @@ -768,6 +889,7 @@ const UploadPage: React.FC = () => { // Card already shows "Failed"; don't duplicate it in the status line. setMessage(""); } finally { + uploadRemainingRef.current.delete(sid); if (uploadAbortRef.current.get(sid) === controller) { uploadAbortRef.current.delete(sid); } @@ -775,13 +897,13 @@ const UploadPage: React.FC = () => { }; /* ── Run inference ── */ - // Kick off one scan's upload/inference. Shared by single and batch runs. - // `foreground` drives the top progress bar (only the first scan of a run - // claims it; the rest upload in the background). + // Queue one scan's upload/inference. Shared by single and batch runs. The + // upload itself waits its turn on uploadChainRef - only one file is on the + // wire at a time - so every scan can drive the foreground progress bar when + // it gets there, without two of them fighting over it. const startScanRun = async ( item: SelectedItem, model: string, - foreground: boolean, batch?: { batchId: string; batchLabel: string }, ) => { const sid = crypto.randomUUID(); @@ -799,13 +921,17 @@ const UploadPage: React.FC = () => { batchLabel: batch?.batchLabel, }), ); + // Sits behind other files on the upload chain until its turn. + setPhase(sid, "waiting"); // The caller clears the whole selection before looping, so there's nothing to // consume here. A DICOM folder uploads its slices and converts server-side; a // NIfTI file rides the resumable path (stashed in IndexedDB so an interrupted // upload can resume). if (item.kind === "dicom") { - runDicomUpload(sid, item.files, model); + const bytes = item.files.reduce((sum, f) => sum + f.size, 0); + uploadRemainingRef.current.set(sid, bytes); + enqueueUpload(() => runDicomUpload(sid, item.files, model)); return; } @@ -821,8 +947,13 @@ const UploadPage: React.FC = () => { chunkSize: CHUNK_SIZE, }; const resumable = await savePendingUpload(pending); - if (foreground) uploadResumableRef.current = resumable; - runUpload(pending, foreground); + uploadRemainingRef.current.set(sid, file.size); + enqueueUpload(() => { + // Set when this file actually starts, not when it was queued - otherwise + // the last file in a batch would decide the unload warning for all of them. + uploadResumableRef.current = resumable; + return runUpload(pending, true); + }); }; const handleRunEpaiInference = async () => { @@ -859,10 +990,12 @@ const UploadPage: React.FC = () => { ? { batchId: crypto.randomUUID(), batchLabel: `${items.length} scans` } : undefined; - // Snapshot then clear the selection, and start every scan's run. + // Snapshot then clear the selection, and queue every scan's run. Each lands + // on the upload chain in selection order and is dispatched to the GPU queue + // as soon as its own upload finishes. setSelectedItems([]); - for (let i = 0; i < items.length; i++) { - await startScanRun(items[i], model, i === 0, batch); + for (const item of items) { + await startScanRun(item, model, batch); } }; @@ -1471,6 +1604,15 @@ const UploadPage: React.FC = () => { const inFlight = groups.filter(isGroupInFlight); const finished = groups.filter(g => !isGroupInFlight(g)); + // Only the upload needs this tab open; past that the job lives in the + // server's queue. Rides along on each card's status line rather than as + // its own banner - it's reassurance, not a state the user must act on. + const closeNote = closeInfo.active + ? closeInfo.eta === null + ? "keep tab open" + : `safe to close in ${formatEta(closeInfo.eta)}` + : "safe to close"; + const canView = (u: RecentUpload) => u.status !== "Failed" && u.status !== "Cancelled"; const openSession = (u: RecentUpload) => { if (!canView(u)) return; @@ -1550,7 +1692,10 @@ const UploadPage: React.FC = () => { // ── A single in-flight scan (not part of a batch) ── const ProcessingCard = ({ u }: { u: RecentUpload }) => { const phase = sessionPhases[u.sessionId]; - const phaseLabel = phase === "uploading" ? "Uploading…" : phase === "queued" ? "Queued for GPU" : "Running…"; + const phaseLabel = + phase === "waiting" ? "Waiting to upload…" : + phase === "uploading" ? "Uploading…" : + phase === "queued" ? "Queued for GPU" : "Running…"; return (
{
{u.label}
{u.model ? `${u.model} · ` : ""}{formatRelativeTime(u.timestamp)} + + {" "}· {closeNote} +
@@ -1652,6 +1800,7 @@ const UploadPage: React.FC = () => { return ( setDetailsBatchId(g.batchId)} onCancelAll={() => running.forEach(u => cancelRun(u))} /> ); diff --git a/PanTS-Demo/src/test/uploadScheduling.test.tsx b/PanTS-Demo/src/test/uploadScheduling.test.tsx new file mode 100644 index 0000000..4347ab9 --- /dev/null +++ b/PanTS-Demo/src/test/uploadScheduling.test.tsx @@ -0,0 +1,147 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider } from "../contexts/authContext"; +import UploadPage from "../routes/UploadPage"; + +// How a multi-file run is scheduled. Two properties matter and they pull in +// opposite directions, so both are pinned here: +// +// 1. Files upload ONE AT A TIME. Uploading them concurrently doesn't create +// bandwidth - it just makes every file finish at the same late moment, +// leaving the GPU idle for the whole upload. +// 2. Each file is dispatched to the server's job queue the instant its OWN +// upload finishes, NOT after the batch. The server queues behind a +// one-at-a-time GPU lock, so dispatching early means scan 1 is already +// segmenting while scan 2 is still going up. + +const CHUNK_SIZE = 512 * 1024; +const USER = { id: "u1", email: "test.user@example.com", name: null }; + +// Comfortably more chunks than the uploader's in-flight concurrency (6), so a +// concurrent schedule would visibly interleave the two files rather than +// happening to drain one before the other. +const CHUNKS_PER_FILE = 10; +const makeFile = (name: string) => + new File([new Uint8Array(CHUNK_SIZE * CHUNKS_PER_FILE)], name, { + type: "application/gzip", + }); + +/** Ordered log of the upload-relevant requests, tagged with their session. */ +let log: { kind: "chunk" | "finalize" | "infer"; sid: string }[] = []; + +const json = (body: unknown) => ({ + ok: true, + status: 200, + json: async () => body, + text: async () => "", + headers: { get: () => "application/json" }, +}); + +const sessionOf = (body: unknown): string => { + if (body instanceof FormData) return String(body.get("session_id")); + if (body instanceof URLSearchParams) return String(body.get("session_id")); + return ""; +}; + +beforeEach(() => { + log = []; + global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const u = String(url); + const sid = sessionOf(init?.body); + + if (u.includes("/api/auth/me")) return json({ user: USER }); + if (u.includes("/api/auth/oauth/providers")) return json({ google: true }); + + if (u.includes("/api/upload-inference-chunk")) { + log.push({ kind: "chunk", sid }); + // A real chunk takes time on the wire. Yielding here means a concurrent + // scheduler would interleave the two files' chunks and fail the test, + // rather than passing by accident on instant resolution. + await new Promise((r) => setTimeout(r, 5)); + return json({ ok: true }); + } + if (u.includes("/api/finalize-upload")) { + log.push({ kind: "finalize", sid }); + return json({ uploaded_filename: "ct.nii.gz" }); + } + if (u.includes("/api/run-epai-inference")) { + log.push({ kind: "infer", sid }); + return json({ message: "Segmentation started", session_id: sid }); + } + if (u.includes("/api/inference-status/")) return json({ status: "queued" }); + + return json({ items: [], total: 0, ids: [] }); + }) as unknown as typeof fetch; + + localStorage.clear(); +}); + +afterEach(() => vi.restoreAllMocks()); + +const runTwoFiles = async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + , + ); + // Inference requires an account; wait for the session probe to land first. + await waitFor(() => + expect(screen.queryByText(/to run inference on the server/)).not.toBeInTheDocument(), + ); + + const input = container.querySelector('input[accept=".nii,.gz"]')!; + await user.upload(input, [makeFile("scan-a.nii.gz"), makeFile("scan-b.nii.gz")]); + + // Default model is "None" (view only, nothing uploads) - pick a real one. + await user.click(screen.getByRole("button", { name: /None \(view scan\)/ })); + await user.click(screen.getByText("ePAI")); + await user.click(screen.getByRole("button", { name: "Run" })); + + await waitFor( + () => expect(log.filter((e) => e.kind === "infer")).toHaveLength(2), + { timeout: 5000 }, + ); +}; + +describe("multi-file upload scheduling", () => { + it("uploads one file at a time instead of interleaving them", async () => { + await runTwoFiles(); + + const chunks = log.filter((e) => e.kind === "chunk"); + expect(chunks).toHaveLength(CHUNKS_PER_FILE * 2); + + // Serialized means the session id changes exactly once across the whole + // chunk stream; an interleaved schedule would flip back and forth. + const switches = chunks.filter((e, i) => i > 0 && e.sid !== chunks[i - 1].sid); + expect(switches).toHaveLength(1); + }); + + it("dispatches each file to the GPU queue before the next file uploads", async () => { + await runTwoFiles(); + + const firstSid = log[0].sid; + const firstInfer = log.findIndex((e) => e.kind === "infer" && e.sid === firstSid); + const secondFileStarts = log.findIndex((e) => e.kind === "chunk" && e.sid !== firstSid); + + expect(firstInfer).toBeGreaterThan(-1); + // The barrier alternative would put both infers at the very end; this + // asserts scan 1 is already queued for the GPU before scan 2 starts uploading. + expect(firstInfer).toBeLessThan(secondFileStarts); + }); + + it("gives each file its own session so results don't collide", async () => { + await runTwoFiles(); + + const infers = log.filter((e) => e.kind === "infer").map((e) => e.sid); + expect(new Set(infers).size).toBe(2); + // Every file that uploaded also got dispatched - nothing stranded on the + // server without a job. + expect(new Set(log.filter((e) => e.kind === "finalize").map((e) => e.sid))) + .toEqual(new Set(infers)); + }); +});