Skip to content

fix(vscode-ext): wait out a half-created peer token instead of adopting an empty one - #420

Merged
nedtwigg merged 3 commits into
mainfrom
fix/peer-token-empty-read
Aug 20, 2026
Merged

fix(vscode-ext): wait out a half-created peer token instead of adopting an empty one#420
nedtwigg merged 3 commits into
mainfrom
fix/peer-token-empty-read

Conversation

@dormouse-bot

Copy link
Copy Markdown
Collaborator

ensureToken returned (await readFile(path)).trim() without checking the result was non-empty, and writeFile(..., { flag: 'wx' }) creates the file before it writes the bytes — so a second window reading in that gap gets ''. If that window then wins the bind, serverToken = '' and onServerFrame's !serverToken rejects every hello — permanently, since a broker never re-reads the token, while every other window retries at 1 Hz and is never served. An empty read is now treated as "not written yet" and waited out; a file that stays empty past the wait takes the existing stand-down path, which logs the reason instead of silently brokering for nobody. Two regression tests, both of which fail on main and pass here.

This is one of the findings from the review of #398 — it was written up when that PR closed mid-review, and it did not carry into the review of its successor #416, so it merged. The other two findings from that comment did land: the inode-reuse one as 2bd97f09, and the stale #lifecycle cross-reference independently in #418.

The failure in full, and how it was verified

Mechanism. The window is microseconds wide and first-run only, but it does not self-heal:

  1. Window A calls ensureToken, finds no file, and starts writeFile(path, uuid, { flag: 'wx' }). The inode exists as soon as the create returns; the bytes land after.
  2. Window B calls ensureToken inside that gap, reads zero bytes, and returns ''.
  3. B wins the bind, and the bind path sets serverToken = '' and B is the broker.
  4. Every hello now fails the !serverToken clause. B logs rejected a client with a bad hello and drops the socket; A and every later window dial B, get dropped, and re-contend at RETRY_MS forever. Nothing re-reads the token, so restarting the other windows does not help — only killing the broker does.

The fix. readSharedToken returns null for absent-or-empty, so both read sites distinguish the two cases. On EEXIST the loser polls for the winner's bytes (10 × 20 ms — the write is one writeFile of a UUID, so this is sized to be unmissable rather than tuned). Past that, it throws, which reaches the caller's existing "an unwritable globalStorageUri is not transient" branch: refused = true, settle(false), and a log line. That is a behavior change only for a token file left zero-length by a crash, where today's outcome is the silent permanent refusal above — same brokenness, no diagnosis.

Verification. pnpm test in vscode-ext is green: tsc -p tsconfig.json clean, 103 tests across 5 files (peer-link.test.ts 46, up from 44). Both new tests fail on main with the source change reverted and the tests kept:

FAIL  waits for the winner’s bytes rather than reading a half-created token as empty
AssertionError: expected true to be false   (isPeerLinkSettled — already brokered on '')

FAIL  stands down rather than brokering on a token file that stays empty
AssertionError: expected [ true ] to deeply equal []

The race test asserts isPeerLinkSettled() === false before it writes the real bytes, so it cannot pass vacuously by having the write land ahead of the read — the same failure mode flagged on #416 for re-binds when the socket it reclaimed is unlinked out from under it.

docs/specs/vscode.md → "Peer surfaces across windows" documented the wx choice but not what an empty read means, so the invariant is recorded alongside it. spec-lint OK (23 specs, 24 files).

…ng an empty one

`ensureToken` returned `(await readFile(path)).trim()` without checking the
result was non-empty. `writeFile(..., { flag: 'wx' })` creates the file before
it writes the bytes, so a second window reading in that gap gets `''`. If that
window then wins the bind, `serverToken = ''` and `onServerFrame`'s
`!serverToken` rejects every hello — permanently, since a broker never re-reads
the token, while every other window retries at 1 Hz and is never served.

An empty read is now treated as "not written yet" and waited out; a file that
stays empty past the wait takes the existing stand-down path, which logs the
reason rather than silently brokering for nobody.

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review, so no approval — one finding plus an observation. CI is green (Build & Test, Standalone Smoketest, Cloudflare Pages), which covers the "both tests fail on main" claim from the other direction.

The new throw misnames the case the existing unreadable-storage test hits. ensureToken's catch covers every writeFile failure, not just EEXIST, and stands down for good when the shared token can be neither read nor written lands in it: a token path that is a directory fails the exclusive create with EEXIST, not EISDIRopen(O_CREAT|O_EXCL) on an existing directory is EEXIST — so it falls into the poll loop, every readSharedToken there returns null on the EISDIR read, and 200 ms later it throws peer link token file is empty. On main that same path reached attempt's log line carrying EISDIR: illegal operation on a directory, read '...'. The stand-down itself is unchanged and the test still passes, but the reason in the log is now false for the one scenario the spec paragraph says this branch exists to name — which is the PR's own argument for throwing instead of brokering on ''. Inline suggestion below.

Observation, not a request: the timeout latches refused = true, i.e. it is classed with an unwritable globalStorageUri, and it is the one arrival at that branch that isn't inherently permanent. A winner whose single writeFile sits behind a saturated libuv threadpool for longer than TOKEN_WRITE_ATTEMPTS × TOKEN_WRITE_POLL_MS fills the file in a moment later, and the loser is then off for the life of that extension host with no route back but a window reload — where returning false from attempt for the timeout specifically would recover on the next round. Unlikely at 200 ms for a UUID, and the crash-left-empty case does argue for latching, so this reads as a deliberate call rather than a miss; flagging it because the spec now documents the wait but not why exhausting it is permanent.

Comment thread vscode-ext/src/peer-link.ts Outdated
…f asserting it

`open(O_CREAT|O_EXCL)` on a token path that is a directory fails with EEXIST,
not EISDIR, so an unreadable globalStorageUri reaches the wait loop and used to
throw "is empty" — swallowing the cause the caller's log line exists to name.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 20, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 04ff87c
Status: ✅  Deploy successful!
Preview URL: https://ebdad4ce.mouseterm.pages.dev
Branch Preview URL: https://fix-peer-token-empty-read.mouseterm.pages.dev

View logs

…error

The re-derived reason had no coverage: the directory case and the
crash-left-empty case share one branch, and only the log tells them
apart, so the tests now read the output channel and assert each one
names its own cause. Reverting the re-derivation fails the directory
assertion with 'peer link token file is empty'.

The read-failure arm also carries the create error now — an unwritable
globalStorageUri fails the create with EACCES and then the read with
ENOENT, and the read alone names the missing file rather than why.
@nedtwigg
nedtwigg merged commit b6fcac7 into main Aug 20, 2026
8 checks passed
@nedtwigg
nedtwigg deleted the fix/peer-token-empty-read branch August 20, 2026 20:19
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.

2 participants