Skip to content

fix(telemetry): keep the counts off when another server on the same state directory writes - #2408

Merged
devswha merged 3 commits into
mainfrom
fix/telemetry-optout-race
Oct 29, 2026
Merged

devswha merged 3 commits into
mainfrom
fix/telemetry-optout-race

Conversation

@devswha

@devswha devswha commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Found by the reviewer bench (claim #1 in its report). It was there before #2392, at 73d1826; #2392's usage counts added another writer that can trigger it. To fix before 0.4.21.

Bug

Every telemetry write in server/telemetry.ts reads all of telemetry.json (refresh()) and later writes all of it back (persist()). Two Ovis servers can share one state directory, for example an installed app and a source checkout. If one of them reads the switch as on just before the user turns it off in the other, its next visit or count writes enabled: true back. The opt-out is undone and the next daily message goes out.

Change

  • "Off" in its own file: a switch turned off now also lives in telemetry-off, beside telemetry.json. Only the switch writes or removes it, and no visit or count touches it.
    • The counts are on only when telemetry-off is absent and telemetry.json says on.
    • Off writes the mark before writing telemetry.json and again after it, so an on from another server between the two writes can't leave off unmarked.
    • On removes the mark only after telemetry.json says on.
  • Older builds: an enabled: false written by an older build is copied into the mark whenever telemetry.json is read. A read that can't write it still reads off and tries again next time.
  • Fails closed: the mark is read by offMarked. Only a name that is not there, in a state directory this process can read, counts as no mark. A dangling link, EACCES, ENOTDIR or any other error reads off, so nothing is sent. A first run makes its state directory before looking, so a fresh install still starts on. A state directory that cannot be made now also reads off in Settings; before, it sent nothing but showed on.
  • Sends in progress:
    • Off stops this server's own sends before it reads or writes anything.
    • The mark is checked last, right before each send, with nothing in between: after the daily message's own state write, and in report() before the install or update event. Days still waiting stop once another server turns the counts off.
    • report() clears its in-flight send after the send is stored, as the daily-message path does, so an event held back by that last check is sent once the counts are on again.
  • Untouched: shared/telemetry.ts and the Worker are unchanged, so nothing needs deploying.

Tests (fail first)

Five new tests in server/telemetry.test.ts ("another server on the same state directory") fail on main and pass here. The first two run a real second server process: it reads the state, then pauses on its now clock, which Telemetry calls between reading and writing. The two processes hand off over stdin and stdout, with no sleeps or polling.

  • An off while the other server is midway through a count, or through a visit, stays off. The other server's late write lands, nothing is sent, and a fresh reader also sees off.
  • An off that overlaps an on from another server, and finishes last, stays off against a later stale write.
  • An older build's off stays off against a late write saying on. If it couldn't be marked at start, the next read marks it.
  • Days still to send stop once another server turns the counts off.

A sixth test checks that turning the counts on from either server turns them on.

Known limits

  • A message already leaving: the mark is checked immediately before each send, with no code in between. Only a send that has already started when another server turns the counts off can still land; nothing changes the switch afterwards.
  • An on can be undone, never an off: a server that read off a moment before another server turned the counts on can mark them off again. This is the safe direction.
  • Servers from before this fix: they ignore the mark, so they can still turn themselves back on. The changelog says the fix holds between servers of this version or later.

Reviews

  • omo-native-code-reviewer on 9913b08: APPROVE, LOWs only.

    • Fixed in c8f8c74: an overlapping off/on could leave off unmarked; the changelog said "always"; an unwritable mark left this server's sends going.
    • Not fixed: a start-up that marks off can undo a concurrent on (safe direction), and another server's send already in flight (see Known limits).
  • Codex Terra 6.1 fast xhigh, three rounds:

    • Round 1: three HIGHs and a MEDIUM, each reproduced by Terra's probes.
      • Fixed: the overlapping off/on, and the older-build off left unmarked after a failed write.
      • Declined: the synchronous gap before a send, and an on undone by a stale off (see Known limits).
    • Round 2: two MEDIUMs, both fixed. A read could write the mark before this server's sends were stopped, and the test helper's buffered output could hold back its handshake.
    • Round 3: APPROVE with no findings. Terra's sandbox refuses to let the parent write to the child's stdin (EPERM, reproduced there without any telemetry code), so it could not run the two-process tests; they run here and in check fast.
  • Merge pass @ c8f8c74: BLOCK on a reproduced HIGH: the mark failed open (an inaccessible state directory or a dangling link read as no mark, and report() sent before a write failed). There was also a MEDIUM: move the check right before each send. Both are fixed in d7d3ea3.

  • Codex Terra on d7d3ea3: a LOW (an event held back by the last check stayed stuck until restart), fixed; then APPROVE with no findings. Its probes: modes 000, 100, 400 and ENOTDIR read off; 500, 700 and a first run read on; dangling and self-referencing links read off.

Validation

  • bun run check fast on d7d3ea3: ok (8794 pass, 0 fail, 35 skip; typecheck and build ok).
  • bun test server/telemetry.test.ts: 60 pass, 0 fail. server/api.contract.test.ts -t telemetry: 4 pass. server/conventions.test.ts and scripts/changelog-fold.test.ts: pass.
  • Fail-first logs: all five new tests fail on main's server/telemetry.ts, and the two round-2 tests fail on 9913b08.
  • Not run: browser, Windows and integration suites (CI). No real receiver, Worker or D1 was used.

…tate directory writes

Every telemetry write saves the whole telemetry.json as it was read. A second
server on the same state directory (an installed app beside a source checkout)
that read the switch on a moment before it was turned off wrote it back on with
its visit or count, and the next active message went out.

The switch turned off now lives in a file of its own, telemetry-off, which only
the switch writes or removes; the counts are on only when it is absent and
telemetry.json says on. A switch an older build turned off is copied into it at
start. Each active message checks it before it goes, so days still to send stop
once another server turns the counts off.

Found by the reviewer bench (pre-existing at 73d1826).
…ld's off at any read

Review of the opt-out fix (Codex Terra, omo):
- An off from one server overlapping an on from another could end with
  telemetry.json off and no telemetry-off, so a late write turned it back on.
  Off now marks before and after it writes telemetry.json, and on removes the
  mark only once telemetry.json says on.
- An older build's off was copied into telemetry-off only at start; when that
  write failed it never was. Every read of telemetry.json now copies it.
- Off stops this server's own sends before it reads or writes anything (a read
  may write the mark), so a mark that cannot be written no longer leaves them
  going.
The two-server test helper writes its lines synchronously, so a buffered line
cannot be held back by the child's blocking read.
The changelog says the fix holds between servers of this version or later.
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 8 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 7a6f8b0 and d7d3ea3.

Files selected for processing: 3
  • changelog.d/telemetry-off-holds.md
  • server/telemetry.test.ts
  • server/telemetry.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ight before each send

Merge-pass review of #2408: the telemetry-off lookup used existsSync, which
reads an inaccessible state directory (EACCES) or a dangling telemetry-off link
as no mark, so a server with the counts on in memory still sent its install
event. The lookup (offMarked) now fails closed: only a name that is not there,
in a state directory this process can read, is no mark; anything at the name
or any failure to tell is off. A first run makes its state directory before
looking.

The mark is now checked last, right before each fetch: after the active
message's own state write, and in report() before the install or update event.

report() now clears its in-flight send once that send is stored, as flushActive does:
an off check that returned before the first await left it standing, so an owed
event waited for a restart (Codex Terra).

A state directory that cannot be made now reads as off in Settings, as well as
sending nothing.
@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Merge-pass review @ c8f8c74: fixed in d7d3ea3

Finding Fix
HIGH (reproduced): the telemetry-off lookup failed open. An inaccessible state directory (EACCES) or a dangling telemetry-off link read as no mark, so a cached enabled: true still sent, and report() sent before persistence hit EACCES. offMarked replaces every existsSync: only ENOENT in a state directory this process can read and search is no mark; any other error, or anything at the name, reads off. A first run makes its state directory before looking. Fail-first tests: an unreadable state directory sends neither the install event nor the held daily message and reads off; a dangling link reads off and sends nothing.
MEDIUM: the gap between the off check and the send. The check is the last statement before each fetch: after the daily message's own state write, and in report() before the install or update event. Removing those checks fails the "days still to send stop" test. The known limits now say only a send already started can land.
(Terra LOW on the fix) an event held back by that last check stayed stuck until restart. report() clears its in-flight send in a .finally after the promise is stored. Fail-first test: the install event goes once the counts are on again.

One older expectation changed on purpose: a state directory that cannot be made still sends nothing, and now also reads off in Settings instead of on.

Validation: bun run check fast on d7d3ea3: ok (8794 pass, 0 fail, 35 skip; typecheck and build ok). bun test server/telemetry.test.ts: 60 pass. Fail-first runs for all three new tests on the code before each fix. Codex Terra 6.1 fast xhigh on d7d3ea3: APPROVE, no findings.

@devswha

devswha commented Oct 29, 2026

Copy link
Copy Markdown
Owner Author

Merging d7d3ea3. Privacy fix (follow-up to #2392): telemetry 'off' now lives in its own telemetry-off file that only the switch writes, so another server process on the same state directory can no longer turn counts back on with a late visit or count write; an older build's off is copied into it on read; the lookup fails closed (only ENOENT in a readable state directory means no mark; EACCES, ENOTDIR, a dangling link or any other error read as off), and the mark is checked right before each send. Strict privacy review by the merge pass, two rounds. Round 1 reproduced a HIGH (an unreadable state directory or a dangling telemetry-off link was read as 'no mark', so a cached on still sent); fixed, and the exact repros now send nothing (unreadable directory 0, dangling link 0; a readable ENOENT control still sends when on). Also checked: two processes with stale writes stay off, migration honours an older build's off, a corrupt mark stays off, a denied first-run mkdir stays off, re-enabling revives no off-time counts, a held-back event no longer waits for a restart. Known limit, accepted: no cross-server lock, so the moment between the last check and the fetch call itself is not atomic. Downgrading to an older build after another process wrote enabled=true can send again (noted in the PR). 60 tests + typecheck pass; Worker/shared/deployed.json untouched.

@devswha
devswha merged commit f811a23 into main Oct 29, 2026
20 checks passed
@devswha
devswha deleted the fix/telemetry-optout-race branch October 29, 2026 23:42
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