From 59cef9fd313914e383bf49ac75bb8233bfd343e6 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 26 Jul 2026 17:09:08 -0700 Subject: [PATCH] Give the decision poll a deadline it can actually reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/puter-js/src/modules/UI.js | 41 +++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 26c12081d..f6983e99b 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -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);