Give the decision poll a deadline it can actually reach

`pollDecision` bounds itself with a five-minute budget, but it only reads
the clock between iterations and its `fetch` had no timeout of its own. A
request that never settles — a stalled connection, a proxy that accepts
and never answers — parks that `await` forever: the loop never comes
back round to check, `settle` is never called, and the caller's promise
stays pending for the life of the page with the message listener and
interval still attached. It is the only unconditional hang left in the
flow, and the least recoverable one, because the popup is already closed
on this branch and nothing the user does can rescue it. Each attempt now
gets ten seconds — long enough that a slow-but-working connection is
still heard, short enough that the deadline means something.

The request id changes for a related reason. It was `#messageID++`, a
small integer restarting at 1 on every page load, and `event.source` is
not pinned for as long as the no-gesture consent dialog waits for its
Continue click — a stretch of time the user paces. A permission popup
left open from before a reload posts this exact message shape to its
opener on the way out, and its counter value collides with a fresh
request's, settling it with the decision the user made about a different
permission. A random suffix makes the two impossible to confuse; the GUI
echoes the value back verbatim, which the loose comparison still handles.
This commit is contained in:
jelveh
2026-07-26 17:09:08 -07:00
parent bfc61a8c39
commit 59cef9fd31
+38 -3
View File
@@ -1157,8 +1157,19 @@ class UI extends EventListener {
}
return new Promise((resolve) => {
const msg_id = this.#messageID++;
const url = `${gui_origin}/action/request-permission?embedded_in_popup=true&msg_id=${msg_id}&permission=${encodeURIComponent(permission)}`;
// Unique per request, and not reused across page loads. The counter
// alone is a small integer that restarts at 1 on every load, and there
// is a window — the whole time the no-gesture consent dialog waits for
// its Continue click — where no popup exists yet, so `event.source` is
// not pinned and any window on the GUI origin is accepted. A permission
// popup left open from before a reload posts exactly this message shape
// to its opener on the way out, and its counter value would collide
// with a fresh request's, settling it with a decision the user made
// about a different permission. The random suffix is what makes the
// two impossible to confuse. The GUI echoes the value back verbatim as
// a string, which the loose `!=` below compares correctly.
const msg_id = `${this.#messageID++}-${Math.random().toString(36).slice(2, 10)}`;
const url = `${gui_origin}/action/request-permission?embedded_in_popup=true&msg_id=${encodeURIComponent(msg_id)}&permission=${encodeURIComponent(permission)}`;
// Guards against settling more than once across the message,
// popup-closed, and dialog-cancel code paths.
@@ -1285,6 +1296,10 @@ class UI extends EventListener {
const pollDecision = async () => {
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
// Per-attempt budget, generous enough that a slow-but-working
// connection still gets an answer, short enough that the deadline
// below stays meaningful.
const POLL_REQUEST_TIMEOUT_MS = 10000;
// The check needs this site's own token, and a permission popup
// deliberately never hands one over. Without it every iteration
// would skip the request and the loop would just burn its whole
@@ -1298,6 +1313,21 @@ class UI extends EventListener {
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
if ( settled ) return;
if ( ! puter.authToken ) continue;
// Time-box each attempt. The loop only re-reads the clock
// between iterations, so a request that never settles — a
// stalled connection, a proxy that accepts and never replies
// — parks this `await` forever: POLL_TIMEOUT_MS is never
// reached, `settle` is never called, and the caller's promise
// stays pending for the life of the page with the listener
// still attached. The popup is already closed on this branch,
// so nothing the user does can recover it.
const controller = typeof AbortController !== 'undefined'
? new AbortController()
: null;
const attempt_timer = setTimeout(
() => controller?.abort(),
POLL_REQUEST_TIMEOUT_MS,
);
try {
const resp = await fetch(`${puter.APIOrigin}/auth/check-permissions`, {
method: 'POST',
@@ -1306,6 +1336,7 @@ class UI extends EventListener {
'Authorization': `Bearer ${puter.authToken}`,
},
body: JSON.stringify({ permissions: [permission] }),
...(controller ? { signal: controller.signal } : {}),
});
if ( ! resp.ok ) continue;
const data = await resp.json();
@@ -1313,7 +1344,11 @@ class UI extends EventListener {
settle(true);
}
} catch (e) {
// Transient network failure; keep polling.
// Transient network failure, or this attempt's abort; keep
// polling until the deadline above is reached.
} finally {
// Runs on the `continue` paths too.
clearTimeout(attempt_timer);
}
}
settle(false);