Skip to content

renderer: token bootstrap is single-flight only while in flight, the dev-token route never re-enters the interceptor, and a transport failure consults the live port at once - #156

Draft
kai-openswarm wants to merge 8 commits into
openswarm-ai:devfrom
kai-openswarm:c10/b-config

Conversation

@kai-openswarm

Copy link
Copy Markdown

Draft, stacked on #147 and #142 (both touch shared/config.ts; the branch merges #142 into #147's tip so the diff shows only this change once those two merge). Renderer hardening in the auth/fetch layer every request goes through; no product behaviour changes.

Four small changes to shared/config.ts:

Change Why
Single-flight, in flight only ensureAuthToken() shares its promise only while a refresh is in flight; once settled the slot clears and the cache is the source of truth (a non-empty cache short-circuits). The resolved-forever memo it replaces either poisoned retries (an empty result) or shadowed a later forced refreshAuthToken() failure — the WebSocket 4401 path — with the stale token. The boot race (ENG-207) stays covered: an empty resolve is never memoized because the slot clears on settle.
Non-OK clears the cache refreshAuthToken() treats a non-OK dev-token response like a thrown transport error. A forced refresh that gets a 500 no longer returns the token it was asked to doubt.
The dev-token route bypasses the interceptor /api/dev/token goes out as raw transport (no bearer, no dedupe) and is awaited, so a rejection lands in the catch below. Without it, refreshAuthToken's own fetch re-enters the interceptor before _authTokenPromise is assigned (an async fn suspends only at its first await), ensureAuthToken starts another refresh, and the renderer recurses synchronously until RangeError whenever there is no preload bridge — Electron always has one; a plain browser tab on run.sh does not.
Heal at the first failure A GET transport failure consults the live backend port on the first failed attempt (one-shot, reloads only if the port differs); a TypeError reaching the outer catch does the same. Instead of retrying against a dead port for seconds first.

Tests: shared/config.test.ts under node:test — one module instance over a dispatching transport stub (the module installs the interceptor and preloads the token at import, so each case steers the stub and the token state): the import-time acquisition is recursion-free (stack depth pinned) and one request; concurrent callers share one request; a forced refresh failure or a 500 is never shadowed by a stale token; a failed refresh is not poisoned; bearer injection and foreign-origin passthrough; a caller's Authorization is kept; the 1 s GET dedupe; mutations never deduped; the heal hook is consulted at once and the bounded retry recovers; a rejected token transport heals in the catch path with no hidden retry and no bearer; a preload bridge wins over the HTTP route. Against the previous config.ts the file's own import-time bootstrap overflows the stack.

Proof: tsc 0 errors; renderer runner 157/157; upstream's linter identical to baseline.

Not in this change (deliberately): an Idempotency-Key retry for mutations — the desktop backend has no idempotency admission (client_message_id is echoed for the optimistic bubble, not deduped), so an automatic retry could run a turn twice; the renderer keeps its one-shot POSTs.

kai-openswarm and others added 8 commits August 17, 2026 16:59
…on, no longer answers from the 1s GET dedupe cache

The fetch interceptor dedupes identical GETs through a 1s response cache. Two
cases must never be served from it: a caller that said cache: 'no-store' /
'reload' (it wants the network), and any GET after a mutation that may have
changed what it reads. Neither was honoured, so Settings > Memory could add a
fact and immediately refetch the list into the pre-save copy cached by its own
mount fetch, showing "Nothing saved yet" for a fact the store already held
until the panel was reopened.

Now no-store/reload bypasses both the cache and an in-flight join, and every
successful mutation clears the GET cache (it is a burst dedupe, not a store;
the cost is at most one extra round trip per URL). Policy lives in a pure
module with a node test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… a window, so reducer tests run under node:test

Both modules touched window at import time (port/host derivation, the fetch
interceptor install, the debug handle), so any node:test file that imports a
reducer importing API_BASE died with 'window is not defined' before its first
assertion; fetchSessionsStrip.test.ts has been red that way since the
resilience work landed, unnoticed because nothing runs these tests in CI. In a
renderer (window present) nothing changes: same port/host, same interceptor,
same handle. Without one the module answers with the defaults and installs
nothing.
Nothing ran any of them in CI: the 235-file backend pytest suite, the 22
renderer node:test files and the edge suite were run by hand, one file at a
time, so a regression only surfaced when someone happened to run the right
one. Three small workflows, hosted ubuntu, path-filtered, read-only token:

- backend-tests: pytest on Python 3.13 from the locked requirements, plus a
  completion assertion (junit testcase count == collect-only count) so a test
  process that dies mid-run can never read as green
- frontend-tests: tsc --noEmit + node:test via tsx over src/**/*.test.ts(x),
  through frontend/scripts/run-tests.mjs (the runner the tests already name)
- edge-tests: pytest for openswarm-edge

All three are green on the current tree: 2951 backend tests, 143 frontend
tests across 22 files, 14 edge tests.
Two of five hosted runs of the backend suite stalled at 99% until the job cap
with no summary and no junit: one test blocked forever on a bare
ws.receive_json() (fixed on its own in a separate change). A CI lane should
never depend on every test being unable to hang, so add pytest-timeout to the
dev requirements and run the suite with --timeout=300. On Linux the default
signal method fails just the offending test and the run continues, so the
report and the "every collected test ran" assertion stay meaningful.
test_ws_endpoint_streams_a_full_turn_end_to_end read the socket with a bare
ws.receive_json() in a 40-iteration loop and broke only on the assistant reply.
When the loop ends early for any reason (fewer than 40 events, no reply), the
next receive blocks forever and the whole pytest run stalls at 99% until the
job cap. On hosted runners it does exactly that intermittently, on Linux and
Windows alike: the turn path's configure_provider_env decides whether 9Router
needs reviving from provider evidence earlier tests may leave behind, and that
revival spawns/installs the router behind a module-level asyncio.Lock; the
background turn-label aux call reaches the same machinery. Neither is part of
this test's contract ("SDK and WS auth mocked, everything else real").

Pin both out with monkeypatch, bound every receive at 5s (a regression now
fails this test instead of hanging the runner), and wait for the turn's
completed status before asserting on session.messages so the assertion cannot
race the loop's tail.
Same class as the config/backendConnection change: safeMode.ts read `window`
at import, and dashboardLayoutSlice imports it, so any reducer test that
imports the slice died under node:test before it ran. Guard the read; in a
renderer nothing changes.
…dev-token route never re-enters the interceptor, and a transport failure consults the live port at once

Four small changes to shared/config.ts, all in the auth/fetch layer every request
goes through:

- ensureAuthToken() shares its promise only while a refresh is IN FLIGHT; once
  settled the slot clears and the cache is the source of truth, and a non-empty
  cache short-circuits. The resolved-forever memo it replaces either poisoned
  retries (an empty result) or shadowed a later forced refreshAuthToken() failure
  — the WebSocket 4401 path — with the stale token. The boot race (ENG-207) stays
  covered: an empty resolve is never memoized because the slot clears on settle.
- refreshAuthToken() treats a non-OK dev-token response like a thrown transport
  error and clears the cache; a forced refresh that gets a 500 no longer returns
  the token it was asked to doubt.
- The interceptor lets /api/dev/token through as raw transport (no bearer, no
  dedupe) and awaits it, so a rejection lands in the catch below. Without the
  bypass, refreshAuthToken's own fetch re-enters the interceptor before
  _authTokenPromise is assigned (an async fn suspends only at its first await),
  ensureAuthToken starts another refresh, and the renderer recurses synchronously
  until RangeError whenever there is no preload bridge — Electron always has one,
  a plain browser tab on run.sh does not.
- A GET transport failure consults the live backend port on the first failed
  attempt (one-shot, reloads only if the port differs) instead of retrying
  against a dead port for seconds first, and a TypeError reaching the outer catch
  does the same.

Tests (node:test, one module instance over a dispatching transport stub): the
import-time acquisition is recursion-free (stack depth pinned) and one request;
concurrent callers share one request; a forced refresh failure or a 500 is
never shadowed by a stale token; a failed refresh is not poisoned; bearer
injection and passthrough; the 1s GET dedupe; mutations never deduped; the heal
hook is consulted at once and the bounded retry recovers; a rejected token
transport heals in the catch path with no hidden retry and no bearer; a preload
bridge wins over the HTTP route. Against the previous config.ts the file's own
import-time bootstrap overflows the stack.
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.

1 participant