fix(vscode-ext): wait out a half-created peer token instead of adopting an empty one - #420
Conversation
…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
left a comment
There was a problem hiding this comment.
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 EISDIR — open(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.
…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.
Deploying mouseterm with
|
| 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 |
…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.
ensureTokenreturned(await readFile(path)).trim()without checking the result was non-empty, andwriteFile(..., { 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 = ''andonServerFrame's!serverTokenrejects 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 onmainand 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#lifecyclecross-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:
ensureToken, finds no file, and startswriteFile(path, uuid, { flag: 'wx' }). The inode exists as soon as the create returns; the bytes land after.ensureTokeninside that gap, reads zero bytes, and returns''.serverToken = ''and B is the broker.!serverTokenclause. B logsrejected a client with a bad helloand drops the socket; A and every later window dial B, get dropped, and re-contend atRETRY_MSforever. Nothing re-reads the token, so restarting the other windows does not help — only killing the broker does.The fix.
readSharedTokenreturnsnullfor absent-or-empty, so both read sites distinguish the two cases. OnEEXISTthe loser polls for the winner's bytes (10 × 20 ms — the write is onewriteFileof a UUID, so this is sized to be unmissable rather than tuned). Past that, it throws, which reaches the caller's existing "an unwritableglobalStorageUriis 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 testinvscode-extis green:tsc -p tsconfig.jsonclean, 103 tests across 5 files (peer-link.test.ts46, up from 44). Both new tests fail onmainwith the source change reverted and the tests kept:The race test asserts
isPeerLinkSettled() === falsebefore 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 forre-binds when the socket it reclaimed is unlinked out from under it.docs/specs/vscode.md→ "Peer surfaces across windows" documented thewxchoice but not what an empty read means, so the invariant is recorded alongside it.spec-lintOK (23 specs, 24 files).