Skip to content

fix(core): abandon a provider request that never settles - #8

Open
danielbanariba wants to merge 1 commit into
AltairInglorious:mainfrom
danielbanariba:fix/stalled-request-deadlock
Open

danielbanariba wants to merge 1 commit into
AltairInglorious:mainfrom
danielbanariba:fix/stalled-request-deadlock

Conversation

@danielbanariba

Copy link
Copy Markdown
Contributor

Closes #7

Summary

  • A provider request that is still in flight when the next attempt arrives, and older than DEFAULT_STALLED_REQUEST_MS, is abandoned: state.queue is reset so the chain can advance.
  • inFlight is now cleared only by the newest requestId, so an abandoned request that eventually settles cannot clear the flag out from under its successor.
  • Adds a regression test.

No new timer is introduced. The poll is already the clock, and the whole check is Date.now() arithmetic, so it holds for the GNOME (Soup) runtime as well as QML.

Independent of #5 — this branch is cut from main and applies on its own.

Changes

File Change
shared/core/scheduler.js DEFAULT_STALLED_REQUEST_MS; nowMs / stalledRequestMs injectable alongside the existing setIntervalFn; the abandonment check at the top of queueProviderRequest; inFlightSinceMs stamped when a request starts and cleared only by the newest request
shared/core/state.js createProviderState gains inFlightSinceMs: null
test/unit/scheduler.test.js new test: abandons a request that never settles instead of stalling the queue

Why not a request timeout

That was the first thing I tried. It is not available in this runtime — verified on Qt 6.11.2 rather than assumed:

capability present
xhr.timeout no
xhr.ontimeout no
setTimeout() in the QML JS engine no
xhr.abort() yes

So a deadline would have to be built from a QML Timer and injected down into runtime.js, which is a .pragma library with no access to QML types. Abandoning the chain achieves the recovery with none of that plumbing. It does leak the dangling request until Qt gives up on the socket, which seemed the better trade than a cross-layer timer — happy to do the abort() version instead if you would rather reclaim it.

Test plan

  • New test drives the real failure: the first getUsage() returns a promise that never settles, then a second attempt inside the grace period is correctly left queued (calls stays at 1), and a third past the threshold abandons it and reaches the provider (calls becomes 2). It then asserts the fresh result actually lands in the summary.
  • Full suite green: 53 passed, 0 failed.
  • Negative control — with the abandonment block removed, the new test does not merely fail, it hangs, and Node exits with code 13 (event loop emptied with an unsettled top-level await). That is a mechanical demonstration of the deadlock rather than an assertion about it. With the block restored, exit 0.

bun was not available in my environment, so the suite ran on Node 26 through a small local bun:test shim backed by node:test mock timers. No test file or import was modified to accommodate it, and the shim is not part of this PR. Worth re-running under bun test before merge.

Note on the threshold

DEFAULT_STALLED_REQUEST_MS is 120s against a 180s poll, so a request gets a full grace period and is only abandoned once a subsequent poll finds it still hanging. If you would rather express it relative to pollIntervalMs than as its own constant, say the word.

state.queue is a promise chain, so a request that neither resolves nor
rejects blocks every later refresh for that provider. A socket left dangling
by a suspend outlives the resume, and the widget is stuck until the shell is
restarted.

The request cannot be given a deadline where it runs: Qt's QML
XMLHttpRequest implements neither timeout nor ontimeout, and the QML JS
engine has no setTimeout (verified on Qt 6.11.2 -- only abort() exists). The
poll is the only clock available, so a request still in flight when the next
attempt arrives, and older than DEFAULT_STALLED_REQUEST_MS, is abandoned and
the chain restarted.

Only the newest request clears inFlight, so an abandoned one that finally
settles cannot clear the flag out from under its successor.
@danielbanariba

Copy link
Copy Markdown
Contributor Author

Interaction with #4, which I found after opening this: that PR also touches shared/core/scheduler.js, so these will conflict textually — it rewrites the line directly above where this one adds DEFAULT_STALLED_REQUEST_MS. Trivial to resolve either way round.

More substantively, #4 raises DEFAULT_POLL_INTERVAL_MS from 180s to 600s because the usage endpoint rate-limits aggressive polling. That changes the reasoning behind the threshold here, so the note at the bottom of this description is now out of date:

Still correct, just slower — 120s remains a safe floor because it only has to exceed a healthy request's duration. If you merge #4 first I am happy to express this relative to pollIntervalMs instead of as its own constant, which would keep the two in step automatically.

Worth saying that #4 independently corroborates the failure this PR addresses: the 429 with retry-after ~340s it describes is exactly the condition that leaves a request outstanding long enough to matter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A provider request that never settles deadlocks that provider's queue

1 participant