Skip to content

fix(v2): keep refresh dedup alive across the host persistence gap - #92

Merged
jumski merged 1 commit into
mainfrom
fix-v2-refresh-dedup
Oct 30, 2026
Merged

jumski merged 1 commit into
mainfrom
fix-v2-refresh-dedup

Conversation

@jumski

@jumski jumski commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

 refresh: (credential) => ...
-  const key = `${config.brokerBaseUrl}:${credential.refresh}`
+  const key = `${normalizeBrokerBaseUrl(config.brokerBaseUrl)}:${credential.refresh}`
   if (!pending) {
     pending = refreshTokenThroughBroker(...)
-    .finally(() => refreshes.delete(key));
+    .catch((error) => { refreshes.delete(key); throw error; });

Two bugs from the #87 final auth review, one fix site in the V2 refresh handler:

  • V2 refresh deduplication ends before host credential persistence #90 (p2): .finally deleted the dedup entry when the broker responded — before refresh() resolved and before the OpenCode host persisted the rotated credential. A second resolve in that window re-read the still-stored old single-use token, missed the map, re-sent it, and got a 401 (transient tool failure). Now the entry is deleted on failure only; successful entries stay cached under the key of the consumed old token, so every late caller converges on the rotated result. Mirrors V1, which persists to its own store before its delete runs — V2 can't, the host owns persistence. Ceiling: one entry per rotation per account for the process lifetime (ponytail: comment marks the LRU upgrade path).
  • Normalize broker URL before V2 refresh deduplication #91 (p3): the dedup key used the raw brokerBaseUrl, while makeBrokerRequest() strips one trailing slash, so https://host/broker and https://host/broker/ were the same endpoint with two keys — two aliased Locations sent the credential twice. The exact replace(/\/$/, "") normalization is now exported from broker.ts and shared by the request URL and the key. new URL().href semantics deliberately avoided (keeps the trailing slash).

Evidence

  • Before: reverting the src changes makes 2 new tests fail (9 tests total in file):
    2 fail / Ran 9 tests — the persistence-gap test fails with the mock broker's 401 (refresh token already used), the alias test sees two requests for alias-A.
    After: bun run test test/v2-server.test.ts → 9 pass / 0 fail; full suite bun run test → 222 pass / 0 fail across 18 files; bun run lint and bun run typecheck clean.
  • New tests in test/v2-server.test.ts:
    • a second resolve in the host persistence gap converges on the completed refresh — first refresh fully resolves (broker response landed, nothing persisted), second resolve with the same old credential: exactly one broker request, both converge on gap-new-access/gap-new-refresh.
    • failed refreshes release waiters, drop the pending entry, and retry hits the broker again — both waiters get the rejection, one broker call, retry after failure reaches the broker.
    • trailing-slash broker aliases share one refresh; distinct brokers and tokens stay isolated — …/broker + …/broker/ share one request; different origin and different path (…/broker2) and different token stay isolated.
  • Changeset: .changeset/fix-v2-refresh-dedup.md (patch).

Fixes #90, fixes #91.

Merge Danger

Door: two-way

Revert the commit and the old behavior returns; no data or schema changes.

Blast Radius: V2-only OAuth refresh path. V1 (src/server/tools.ts) untouched. Worst case if the retention logic were wrong: a stale cached success served for a token the broker already rotated — bounded by process lifetime and per-old-token keys.

The V2 refresh handler deleted its dedup entry in a .finally attached to
the broker promise, so the entry vanished when the broker responded --
strictly before refresh() resolved and before the OpenCode host persisted
the rotated credential. A second concurrent resolve in that window
re-read the still-stored old refresh token, missed the map, and re-sent
the single-use token, which the broker rejects with 401 and surfaces as a
transient tool failure. V1 does not have this race because it persists to
its own store before its delete runs; V2 cannot, the host owns
persistence, so the entry must outlive the settle. Delete on failure only
(.catch with rethrow); successful entries stay cached under the key of
the consumed old token so every late caller converges on the rotated
result. Ceiling: one entry per rotation per account for the process
lifetime, LRU-cap it if that ever matters.

The dedup key used the raw brokerBaseUrl, but makeBrokerRequest() strips
a trailing slash before hitting /refresh, so "https://host/broker" and
"https://host/broker/" are the same endpoint with two different keys --
two Locations with these alias forms sent the credential twice. Export
the exact replace(/\/$/, "") normalization from broker.ts and use it in
both the request URL and the dedup key so aliases collapse to one entry.
new URL().href semantics deliberately avoided: it keeps the trailing
slash and would re-split the aliases.

Fixes #90, fixes #91.
@jumski
jumski merged commit bb490c8 into main Oct 30, 2026
8 checks passed
@jumski
jumski deleted the fix-v2-refresh-dedup branch October 30, 2026 09:18
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.

Normalize broker URL before V2 refresh deduplication V2 refresh deduplication ends before host credential persistence

1 participant