You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
collapse-remote uploads the bytes, then polls GET /jobs/{id} until the job
leaves queued/compressing. Nothing bounds that loop: no deadline, no maximum
number of polls, no per-request timeout. A server that keeps answering compressing keeps the client waiting for as long as the process lives.
Observed against a stub server that answers 202 to POST /compress and then
reports compressing on every poll, driven with the release binary at v0.7.0:
still polling after 60s, killed by the harness
polls issued: 293
client output so far: b''
The client printed nothing, wrote nothing, and exited only because the harness
killed it. 293 polls is the 200 ms interval doing its job correctly; the loop
itself simply never ends.
The same is true one layer down. Against a server that accepts the TCP
connection and then never answers at all, the CLI was still waiting when the
harness gave up:
still waiting after 45s, killed by the harness: b''
ureq is used with its defaults, and it sets no read timeout, so a connection
that stalls mid-response waits as long as the peer holds it open.
Reproduction (the whole stub, minus the assertions):
How bad this is depends entirely on which front end is waiting.
CLI: Ctrl+C works, and nothing was written, so the cost is a puzzled user
and a job left on the server.
Desktop: there is no way out. compress() sets processing.value = true
and awaits the invoke (apps/desktop/src/App.vue:138), the command is a
synchronous Tauri command blocking a worker thread
(apps/desktop/src-tauri/src/commands.rs:92), and the UI offers no cancel
button. Quitting the app is the only exit.
Web app: same loop in JavaScript, same absence of a bound; the tab has to
be reloaded.
The trigger is uncommon but real, and it is not only "a hostile server". The
most reachable one is on the server itself: when the worker cannot record a
status change, it logs and moves on (apps/server-backend/src/queue.rs:31), so
a registry write that fails (a full disk, for example) leaves the row saying compressing after the archive is finished. Nothing later corrects it: the
reaper only collects terminal jobs, and reconciliation only runs at startup. A
genuinely enormous tree at level 5 produces the same experience without anything
being wrong at all, which is the harder half of the problem: the client cannot
tell a slow job from a dead one, because the server reports no progress beyond
the status name.
Nothing is lost when it happens. The archive, if there is one, stays on the
server until the TTL collects it.
Where it is
apps/remote/src/client.rs:126
/// Poll `GET /jobs/{id}` until the job is ready (Ok) or gives up (Err).fnwait_for_completion(base:&str,job_id:&str) -> Result<(),RemoteError>{loop{let response = ureq::get(&format!("{base}/jobs/{job_id}")).call().map_err(|e| remote_error(base, e))?;match protocol::progress_of(&parse_json(response)?)? {Progress::Ready => returnOk(()),Progress::Waiting => std::thread::sleep(POLL_INTERVAL),}}}
POLL_INTERVAL is 200 ms (apps/remote/src/client.rs:18). Every ureq call in
the file (create_job, wait_for_completion, download, check_health) uses
the crate defaults, so none of them carries a read timeout either.
The web app repeats the shape independently, apps/server-frontend/src/api.js:78:
The decision that was made here was the narrow one, and it was right: only queued and compressing mean "wait" (apps/remote/src/protocol.rs:36), so a
server answering an unknown status or no status at all is an error rather than a
reason to keep polling. That closed the case where a foreign server made the
client spin forever on nonsense. What it deliberately did not do is put a limit
on the legitimate case, because there is no defensible constant: compressing a
large tree at level 5 can take hours, and a client that gives up at ten minutes
would be wrong more often than the hang it prevents.
docs/architecture.md lists "an upper bound on how long a client waits for a
job" under Roadmap as remaining work, so this is known and unresolved rather
than overlooked. The roadmap says it is tracked in the issues, which it is not
yet.
What a fix looks like
The pieces are separable, and the first two are worth doing regardless of what
is decided about the third.
Per-request timeouts. Build one ureq::Agent with timeout_read/timeout_write (and a connect timeout) instead of the bare ureq::get/ureq::post helpers, and reuse it across the flow. That bounds a
stalled socket without saying anything about how long a job may take. The
download needs a generous value or none: a large archive over a slow link is
a legitimate long read, and this is the same tail the container stop already
costs (see docs/server.md).
Cancellation, which is what the desktop actually needs. Even a perfect
deadline leaves a user with a frozen window for its duration. The shape that
fits: compress_path takes something it can check between polls (an AtomicBool, or a channel), the Tauri command owns it, and the UI grows a
cancel button that also issues DELETE /jobs/{id}. This changes collapse-remote's public signature, so it wants doing at the same time as
any other change to it.
A bound on the wait itself. Three candidates, in rising order of effort:
A flat deadline, off by default: --job-timeout-minutes on the CLI, a
field per server in the desktop's settings. Honest and predictable, but the
user has to guess a number for work whose duration they do not know.
A stall detector: give up only when the status has not changed for N
minutes. Better shaped, but with today's protocol compressing is a single
state with no progress inside it, so a two hour compression looks exactly
like a stuck one. This only becomes meaningful if the server starts
reporting progress (bytes read, entries done), which is a server change and
a wire contract change.
Bound only the states the server cannot legitimately sit in for long: queued behind a single-consumer worker is one of them, and a job that
stays queued for an hour on a server nobody else is using means something
is wrong. Narrow, and it catches the failed-status-write case above only
when it happens before the flip to compressing.
Whatever is chosen, keep it in collapse-remote so the CLI and the desktop
inherit it together, and mirror it in apps/server-frontend/src/api.js, which is
a separate implementation of the same loop.
How to know it is fixed
apps/remote/tests/client.rs gains a test that serves a stub answering compressing forever and asserts compress_path returns an error within the
configured bound instead of blocking (the suite already serves a real backend
in-process, so a stub handler is a small addition).
A test that a server which accepts and never answers produces a RemoteError::Unreachable rather than a hang.
apps/cli/tests/remote.rs asserts the user-facing message names the timeout
and that no partial archive is written.
apps/server-frontend/tests/ covers the same decision in api.js.
If cancellation lands: a desktop test that a cancelled compression leaves no
output file, and the Vitest suite covers the button.
docs/architecture.md's Roadmap line is updated or removed.
What happens
collapse-remoteuploads the bytes, then pollsGET /jobs/{id}until the jobleaves
queued/compressing. Nothing bounds that loop: no deadline, no maximumnumber of polls, no per-request timeout. A server that keeps answering
compressingkeeps the client waiting for as long as the process lives.Observed against a stub server that answers 202 to
POST /compressand thenreports
compressingon every poll, driven with the release binary at v0.7.0:The client printed nothing, wrote nothing, and exited only because the harness
killed it. 293 polls is the 200 ms interval doing its job correctly; the loop
itself simply never ends.
The same is true one layer down. Against a server that accepts the TCP
connection and then never answers at all, the CLI was still waiting when the
harness gave up:
ureqis used with its defaults, and it sets no read timeout, so a connectionthat stalls mid-response waits as long as the peer holds it open.
Reproduction (the whole stub, minus the assertions):
Why it matters
How bad this is depends entirely on which front end is waiting.
and a job left on the server.
compress()setsprocessing.value = trueand awaits the
invoke(apps/desktop/src/App.vue:138), the command is asynchronous Tauri command blocking a worker thread
(
apps/desktop/src-tauri/src/commands.rs:92), and the UI offers no cancelbutton. Quitting the app is the only exit.
be reloaded.
The trigger is uncommon but real, and it is not only "a hostile server". The
most reachable one is on the server itself: when the worker cannot record a
status change, it logs and moves on (
apps/server-backend/src/queue.rs:31), soa registry write that fails (a full disk, for example) leaves the row saying
compressingafter the archive is finished. Nothing later corrects it: thereaper only collects terminal jobs, and reconciliation only runs at startup. A
genuinely enormous tree at level 5 produces the same experience without anything
being wrong at all, which is the harder half of the problem: the client cannot
tell a slow job from a dead one, because the server reports no progress beyond
the status name.
Nothing is lost when it happens. The archive, if there is one, stays on the
server until the TTL collects it.
Where it is
apps/remote/src/client.rs:126POLL_INTERVALis 200 ms (apps/remote/src/client.rs:18). Everyureqcall inthe file (
create_job,wait_for_completion,download,check_health) usesthe crate defaults, so none of them carries a read timeout either.
The web app repeats the shape independently,
apps/server-frontend/src/api.js:78:Why it is like that
The decision that was made here was the narrow one, and it was right: only
queuedandcompressingmean "wait" (apps/remote/src/protocol.rs:36), so aserver answering an unknown status or no status at all is an error rather than a
reason to keep polling. That closed the case where a foreign server made the
client spin forever on nonsense. What it deliberately did not do is put a limit
on the legitimate case, because there is no defensible constant: compressing a
large tree at level 5 can take hours, and a client that gives up at ten minutes
would be wrong more often than the hang it prevents.
docs/architecture.mdlists "an upper bound on how long a client waits for ajob" under Roadmap as remaining work, so this is known and unresolved rather
than overlooked. The roadmap says it is tracked in the issues, which it is not
yet.
What a fix looks like
The pieces are separable, and the first two are worth doing regardless of what
is decided about the third.
ureq::Agentwithtimeout_read/timeout_write(and a connect timeout) instead of the bareureq::get/ureq::posthelpers, and reuse it across the flow. That bounds astalled socket without saying anything about how long a job may take. The
download needs a generous value or none: a large archive over a slow link is
a legitimate long read, and this is the same tail the container stop already
costs (see
docs/server.md).deadline leaves a user with a frozen window for its duration. The shape that
fits:
compress_pathtakes something it can check between polls (anAtomicBool, or a channel), the Tauri command owns it, and the UI grows acancel button that also issues
DELETE /jobs/{id}. This changescollapse-remote's public signature, so it wants doing at the same time asany other change to it.
--job-timeout-minuteson the CLI, afield per server in the desktop's settings. Honest and predictable, but the
user has to guess a number for work whose duration they do not know.
minutes. Better shaped, but with today's protocol
compressingis a singlestate with no progress inside it, so a two hour compression looks exactly
like a stuck one. This only becomes meaningful if the server starts
reporting progress (bytes read, entries done), which is a server change and
a wire contract change.
queuedbehind a single-consumer worker is one of them, and a job thatstays
queuedfor an hour on a server nobody else is using means somethingis wrong. Narrow, and it catches the failed-status-write case above only
when it happens before the flip to
compressing.Whatever is chosen, keep it in
collapse-remoteso the CLI and the desktopinherit it together, and mirror it in
apps/server-frontend/src/api.js, which isa separate implementation of the same loop.
How to know it is fixed
apps/remote/tests/client.rsgains a test that serves a stub answeringcompressingforever and assertscompress_pathreturns an error within theconfigured bound instead of blocking (the suite already serves a real backend
in-process, so a stub handler is a small addition).
RemoteError::Unreachablerather than a hang.apps/cli/tests/remote.rsasserts the user-facing message names the timeoutand that no partial archive is written.
apps/server-frontend/tests/covers the same decision inapi.js.output file, and the Vitest suite covers the button.
docs/architecture.md's Roadmap line is updated or removed.Related
touch
wait_for_completion, so doing them together is cheaper than twice.docs/architecture.md, Roadmap: "an upper bound on how long a client waitsfor a job".
docs/server.md, "Known limitations": a job can outlive its client'spatience, from the server's side of the same coin.
apps/remote/tests/protocol.rspinsprogress_of's decision table, includingthat an unknown status is an error; that behaviour stays as it is.