From bd279e1d13be1aadd6aaa487796d2b5622cd9f59 Mon Sep 17 00:00:00 2001 From: Javier Parada Date: Thu, 27 Aug 2026 11:14:47 +0200 Subject: [PATCH] server-frontend: back off from a short first wait, like the Rust client Issue #48 names two targets and only one was fixed in v0.9.0. Its body says `apps/server-frontend/src/api.js` "has its own polling loop with the same shape and deserves the same treatment", and it did: a flat `POLL_INTERVAL` of 400 ms before the second question, so a job the server had already finished cost the browser that much. Worse, after the Rust half landed the browser was waiting twice as long as the CLI for the same job. Same schedule, deliberately: 10 ms doubling to a 200 ms ceiling, mirroring `apps/remote/src/waiting.rs`. `keeps_the_same_schedule_the_rust_client_uses` fails if the two ever drift, which is the failure mode that made this half worth doing rather than leaving as a nicety. Three tests. The first two spy on `setTimeout` to record what the loop asks for and fire it at once, so a schedule spanning seconds of nominal waiting is checked instantly and with no wall clock in the assertions, which is the same property the Rust side gets from its injected `Sleeper`. All three mutations are caught: starting at the ceiling, never growing, and drifting from the Rust ceiling. --- README.md | 2 +- apps/server-frontend/src/api.js | 43 +++++++++++++- apps/server-frontend/tests/api.test.js | 81 +++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 81b82d1..4afce6d 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Requires **Rust 1.88+** (2021 edition). ```bash make build # build the Rust crates -make test # run every suite (604 Rust tests + 113 Vitest cases) +make test # run every suite (604 Rust tests + 116 Vitest cases) make test/rust # only the Rust tests that need no Node toolchain (489) ``` diff --git a/apps/server-frontend/src/api.js b/apps/server-frontend/src/api.js index 738b14f..a5a0e28 100644 --- a/apps/server-frontend/src/api.js +++ b/apps/server-frontend/src/api.js @@ -4,8 +4,43 @@ // backend, and in development Vite does. That is deliberate, because the // backend ships no CORS layer and should not need one. -/** How often a running job is polled. */ -export const POLL_INTERVAL = 400 +/** + * How long to wait before the FIRST re-poll of a job the server has not + * finished yet. + * + * Short on purpose. Nearly every archive is done in less time than a person + * notices, and this loop used to sleep a flat 400 ms before asking a second + * time, so a tiny file spent almost all of its wall clock waiting on the + * client rather than on the server (issue #48). + * + * Not zero: the point is to stop making a finished job wait, not to spin on a + * server that is genuinely busy. These mirror `apps/remote/src/waiting.rs`, + * which fixed the same shape on the Rust side; keep them in step. + */ +export const FIRST_POLL_DELAY = 10 + +/** + * The ceiling the wait grows to, and the interval a long job settles into. + * + * Deliberately below the old flat 400 ms, and equal to the Rust client's + * ceiling, so the browser no longer waits longer than the CLI for the same + * job. + */ +export const MAX_POLL_DELAY = 200 + +/** + * The wait before the next poll, given the wait before the last one. + * + * Doubles until it reaches the ceiling: 10, 20, 40, 80, 160, 200, 200, ... + * + * It is not uniformly faster, and that is worth knowing rather than glossing: + * a job that finishes just after the ramp is asked again a whole ceiling + * later, where the flat schedule might have caught it sooner. The band is + * narrow and bounded by one ceiling. + */ +export function nextDelay(previous) { + return Math.min(previous * 2, MAX_POLL_DELAY) +} /** Read the backend's error shape, falling back to the status line. */ async function failure(response) { @@ -75,6 +110,7 @@ export async function compress( if (!job?.job_id) throw new Error('malformed server response: no job_id') let last = null + let delay = FIRST_POLL_DELAY for (;;) { const polled = await fetcher(`/jobs/${job.job_id}`) if (!polled.ok) throw await failure(polled) @@ -85,7 +121,8 @@ export async function compress( onStatus(current.status) } if (progressOf(current) === 'ready') break - await sleep(POLL_INTERVAL) + await sleep(delay) + delay = nextDelay(delay) } onStatus('downloading') diff --git a/apps/server-frontend/tests/api.test.js b/apps/server-frontend/tests/api.test.js index d209765..05982c0 100644 --- a/apps/server-frontend/tests/api.test.js +++ b/apps/server-frontend/tests/api.test.js @@ -1,5 +1,12 @@ import { describe, it, expect, vi } from 'vitest' -import { compress, health, progressOf } from '../src/api.js' +import { + compress, + health, + progressOf, + nextDelay, + FIRST_POLL_DELAY, + MAX_POLL_DELAY, +} from '../src/api.js' /** A fetch stub driven by a list of canned responses, in order. */ function fetcherFrom(responses) { @@ -184,3 +191,75 @@ describe('compress', () => { ).rejects.toThrow(/no job_id/) }) }) + +describe('the poll schedule', () => { + /** + * Record every delay the loop asks for and fire it at once, so a schedule + * spanning seconds of nominal waiting is tested in no time at all and with + * no wall clock in the assertions. + */ + function captureDelays() { + const waits = [] + const real = globalThis.setTimeout + vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { + waits.push(ms) + return real(fn, 0) + }) + return waits + } + + /** A job that answers `compressing` `n` times before it completes. */ + function jobTaking(n) { + const responses = [json({ job_id: 'j' })] + for (let i = 0; i < n; i += 1) responses.push(json({ status: 'compressing' })) + responses.push(json({ status: 'completed' })) + responses.push(json({})) // download + responses.push(json({})) // delete + return fetcherFrom(responses) + } + + /** + * Issue #48, the half this file owns. The loop slept a flat 400 ms before + * asking a second time, so a job the server had already finished cost the + * browser that much, and twice what the CLI cost after the Rust half was + * fixed. + */ + it('does not make a job that finishes at once wait out the ceiling', async () => { + const waits = captureDelays() + const { fetcher } = jobTaking(1) + + await compress( + { body: new Blob(['x']), name: 'a.txt', algorithm: 'zip', level: 3 }, + { fetcher }, + ) + + expect(waits).toEqual([FIRST_POLL_DELAY]) + expect(FIRST_POLL_DELAY).toBeLessThan(400) + }) + + it('doubles to the ceiling and then holds', async () => { + const waits = captureDelays() + const { fetcher } = jobTaking(8) + + await compress( + { body: new Blob(['x']), name: 'a.txt', algorithm: 'zip', level: 3 }, + { fetcher }, + ) + + expect(waits).toEqual([10, 20, 40, 80, 160, 200, 200, 200]) + expect(Math.max(...waits)).toBe(MAX_POLL_DELAY) + }) + + /** + * The two clients must not drift. The browser waiting longer than the CLI + * for the same job is exactly what this issue was about. + */ + it('keeps the same schedule the Rust client uses', () => { + expect(FIRST_POLL_DELAY).toBe(10) + expect(MAX_POLL_DELAY).toBe(200) + expect(nextDelay(FIRST_POLL_DELAY)).toBe(20) + expect(nextDelay(160)).toBe(MAX_POLL_DELAY) + expect(nextDelay(MAX_POLL_DELAY)).toBe(MAX_POLL_DELAY) + expect(nextDelay(10_000)).toBe(MAX_POLL_DELAY) + }) +})