Skip to content

fix(copilot): a lookup while Copilot moves its log copies up waits for the rotation - #3122

Merged
devswha merged 2 commits into
mainfrom
fix/copilot-rotation-gap
Nov 6, 2026
Merged

devswha merged 2 commits into
mainfrom
fix/copilot-rotation-gap

Conversation

@devswha

@devswha devswha commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #3109. Copilot Chat stayed unavailable (Terminal fallback) until Copilot registered a session again when a lookup landed while Copilot CLI 1.0 was moving its log copies up for a rotation.

How Copilot 1.0.105 rotates

Traced with strace -f on its own native logger (runnerCreateFileLogger in runtime.node), per rotation:

  1. Moves the copies up by rename, oldest first: .4 to .4.log.rotation-backup (then unlinked), .3 to .4, .2 to .3, .1 to .2.
  2. Opens the open log read-only and writes the new copy to .1.log.rotation-staging.
  3. Renames the staging file to .1.log.
  4. Opens the open log with O_TRUNC.

So during steps 1-3 there is a hole in the copy numbers (no .1.log, or a missing .2/.3 while the shift runs) while an older copy is still there. Copilot loses no line across rotations (60,005 numbered lines through its logger, 6 runs: none missing).

What went wrong

A lookup whose checkpoint predates the previous rotation and lands in that hole stopped at the first missing copy, found nothing continuing the bytes it had read, and stored copilot_log_truncated/copilot_log_rewritten as the selection. Later lookups kept that until a new registration line.

The change

In rotatedLifecycle, a missing copy while an older copy still exists returns a new ROTATION_GAP result. The caller then reads the open log as it does on main when no copy continues (main's rotated === null path):

  • If the open log's complete lines carry a lifecycle event (a registration or a release), the result and checkpoint are exactly main's.
  • If they carry none, the lookup returns the existing LOG_READ_CHANGED: it answers copilot_log_read_raced and stores nothing (it only sets readConflict on the existing checkpoint, which makes the next lookup take the full check instead of the unchanged-file shortcut).

A missing copy with nothing older (no rotation yet, or the chain pruned) is still the lasting rejection it was, as is every other path.

Safety argument. Each lookup on the new path either does exactly what main does for that lookup (a lifecycle line in the open log) or stores nothing beyond readConflict (no lifecycle line). So every state the reader can reach with the fix is a state main reaches when that one lookup simply does not happen (for example, nobody looked during the hole), except that readConflict forces the next lookup to rehash instead of trusting unchanged timestamps, which can only find more. The fix therefore cannot return a session that main would not return for some timing of lookups; it only removes main's lasting truncated/rewritten outcome for lookups that land in the hole.

Validation

  • New tests waits out a rotation moving its copies up after output / after another registration, then follows it fail on main (copilot_log_truncated where copilot_log_read_raced is expected, and the later lookups stay unavailable) and pass here. The registration case checks that the hole never shows the session the copies already left behind.
  • New test binds a registration in the open log while a log copy stays missing (review follow-up, below) fails on the first head 5d15085 and passes here; it passes on main too.
  • bun test ./server/copilot.test.ts: 99 pass.
  • bun run check fast: green (workflow syntax, generated types/themes, typecheck, build, unit tests 12585 pass).

Real-logger harness (scratch, not committed)

Copilot 1.0.105's own logger writes and rotates 6 times while the reader looks up the session (A) continuously; then B registers (waiting until B's line is on disk) and 2 more rotations follow. "Wrong" counts every lookup that returned B before B registered, or A after B's line was on disk. "Lasting" counts runs that ended the rotations on truncated, rewritten, metadata_changed or ambiguous (a one-off read_raced sample is not counted).

Setup Runs main / fix Wrong main / fix Lasting after rotations main / fix
Lookups back to back, 5% pauses of 50-450 ms, serial 30 / - 0 / - 3 / -
Same, interleaved main/fix, serial 35 / 35 0 / 0 16 / 11
Same, in-memory trace, 6 workers interleaved 120 / 120 0 / 0 36 / 24
Same + stderr logging of failed chain lookups, 6 workers 150 / 150 0 / 0 31 / 16
Exact conditions of the odd run below, rerun 30 / 90 0 / 0 7 / 10
Lookups back to back, no pauses, 6 workers 60 / 60 0 / 0 6 / 4
Bursty writes (500 lines, 20 ms pause), 6 workers 60 / 60 0 / 0 0 / 0

Traces of the remaining fix-side lasting cases show they are not the hole. At about 70 MB/s of continuous writes, the read of the open log after a rotation keeps racing with appends (copilot_log_read_raced), so no new checkpoint is stored, and after four more rotations the copy that continues the checkpoint is deleted. That is the designed fail-closed answer; with bursty writes neither side fails. Every hole event in the traces was followed by a lookup that found the chain again.

1000 interleaved runs each (same harness with 5% pauses, stderr logging of failed chain lookups as in the early odd run below, plus an in-memory trace of every lookup, 10 workers):

Runs Runs with a wrong session Lasting after rotations
main 1000 0 210
fix (5d15085) 997 (3 lost to a harness read error) 1 124

The wrong-session runs come from Copilot dropping B's line, and main gives the same answer. The trace of the fix-side run above has no hole event, so every lookup took main's own code path. What the files show:

  • The reader's checkpoint was the open log at 1,535,130 bytes (selection A).
  • reg B was appended to the open log, where the harness saw it on disk.
  • The new .1 copy is exactly 1,535,130 bytes (the log before B's line), and the open log (same inode) was then emptied.
  • At the first wrong lookup, B's registration line was in no file at all (open log and all four copies).

Replaying that file sequence in a fixture (checkpoint A; append B; write a .1 copy that ends before B; empty the open log; append output) returns A on both origin/main and this head (scratch test, not committed). The reader follows what is on disk, and the line is gone. An early diagnostic run of the fix, before the trace existed, showed the same shape (A on 7 lookups after B's line was on disk, ending on A), and I take it to be the same Copilot-side loss. Not every attempt to show the loss directly caught it: marks written right after write bursts lost none in 8 runs, so the exact timing inside Copilot's logger is not pinned down.

Reviews. omo on 5d15085: CLEAR. Codex (Terra, xhigh), asked to break the safety claim on 5d15085 and again on a527984: "CLAIM HOLDS" both times. The one reproduced MEDIUM from the first pass (a copy missing for good kept answering read_raced after a fresh registration in the open log) is fixed in a527984, and Codex confirmed the fix against main and 5d15085. Known LOWs, not changed:

  • If a copy stays missing for good with no lifecycle line in the open log, each lookup reports copilot_log_read_raced instead of the lasting copilot_log_truncated and re-hashes the checkpoint (up to 64 MiB). It is unavailable either way, and a registration in the open log still binds.
  • At a real hole the open log has not been emptied yet, so the chain could in principle be followed through .2; this PR conservatively answers unavailable for that one lookup.

Checks skipped: the integration and browser suites (no client, HTTP or WebSocket change).

…r the rotation

Copilot CLI 1.0.105 rotates its process log by renaming the copies up one by one,
oldest first (.3 to .4, .2 to .3, .1 to .2), then renaming the new copy into .1 and
emptying the open log. A lookup whose checkpoint predates the previous rotation and
lands while the copies move found no .1.log (or a hole further up), stopped looking,
and stored copilot_log_truncated/rewritten: Copilot Chat stayed unavailable until
Copilot registered a session again, even after the new copy was in place.

A missing copy while an older one is still there now answers that lookup alone as
copilot_log_read_raced and stores nothing, so the next lookup compares with the same
checkpoint and follows the rotation as before. A missing copy with nothing older
(no rotation yet, or the chain pruned) is still the lasting rejection it was.

Fixes #3109
@coderabbitai

coderabbitai Bot commented Nov 6, 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 1 minute.

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 9a0edb2 and a527984.

Files selected for processing: 4

  • changelog.d/copilot-rotation-gap.md
  • docs/copilot-chat.md
  • server/copilot-store.ts
  • server/copilot.test.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

…copy is missing

Review follow-up. With a copy missing for good (for example .1 deleted while .2
remains), every lookup answered copilot_log_read_raced, even after the open log
registered a session that main binds. Inside the gap the open log now decides as it
does on main: a lifecycle line there is applied and stored as before; only a lookup
without one is skipped, so the next lookup compares with the same checkpoint.
@devswha

devswha commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 and the 1000-run batch

Reviews on 5d15085. omo: CLEAR. Codex (Terra, xhigh), asked to break the safety claim: "CLAIM HOLDS" (no sequence where the fix returns a session main would not), with one reproduced MEDIUM: a copy missing for good (.1 deleted, .2 kept) kept answering copilot_log_read_raced even after the open log registered a session that main binds. Fixed in a527984: inside the gap, a lifecycle line in the open log is applied and stored exactly as on main; only a lookup without one is skipped. New test binds a registration in the open log while a log copy stays missing fails on 5d15085 and passes here. The docs and changelog now say recovery comes once the new copy is in place (Codex LOW).

1000 interleaved runs each (real Copilot 1.0.105 logger, lookups with 5% pauses, stderr logging of failed chain lookups as in the odd run, plus an in-memory trace of each lookup, 10 workers):

Runs Runs with a wrong session Lasting after rotations
main 1000 0 210
fix (5d15085) 997 (3 lost to a harness read error) 1 124

The one wrong run, explained. This time the trace caught it, and it is not the new path: the run's trace has no hole event at all, so every lookup took main's own code. What the files show:

  • The reader's checkpoint was the open log at 1,535,130 bytes (selection A).
  • reg B was appended to the open log; the harness saw B's line on disk (in the open log).
  • The newest copy .1 is exactly 1,535,130 bytes, the log before B's line, and the open log (same inode) was then emptied.
  • At the first wrong lookup, B's registration line was in no file (open log and all four copies checked).

So Copilot's rotation dropped B's line (its copy ended before the line, and the truncate removed it), and the reader followed what was on disk: the copy continued the checkpoint exactly, with no B after it. main takes the same path for that sequence. The early unexplained run had the same shape (A after B, ending on A), so I take it to be the same Copilot-side loss. Not every attempt to show the loss directly caught it: marks written right after bursts lost none in 8 runs, so the exact timing in Copilot's logger is not pinned down. Nothing on the reader side can see a line that is no longer in any file.

@devswha

devswha commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Reviews at a527984

  • omo: CLEAR (no HIGH or MEDIUM). Its MEDIUM on the first pass of this head only asked for the 1000-run results in the description; they are there now.
  • Codex (Terra, xhigh): "CLAIM HOLDS"; its earlier MEDIUM (a copy missing for good blocked a fresh registration) is fixed and confirmed against main and 5d15085.
  • CodeRabbit did not review this head (rate limited).
  • GitHub checks: all green, browser regressions included.

LOWs left as they are:

  • A copy missing for good, with no lifecycle line in the open log, reports copilot_log_read_raced on every lookup instead of the lasting copilot_log_truncated, and re-hashes up to the 64 MiB cap (twice, with the retry). Unavailable either way.
  • The early LOG_READ_CHANGED return in a gap stops the descriptor scan, so a second log of the same process shows as copilot_log_ambiguous one lookup later, as with the existing early returns.
  • olderLogCopy uses lstat, so a symlink or folder named .N.log counts as an older copy; the copy reader still refuses such files (O_NOFOLLOW, isFile), and the lookup is only unavailable.
  • At a real hole the open log has not been emptied yet, so the chain could be followed through .2; this PR answers unavailable for that one lookup instead.

@devswha

devswha commented Nov 6, 2026

Copy link
Copy Markdown
Owner Author

Merge note: all GitHub checks green at this head (browser included), omo CLEAR at this head, Codex Terra xhigh twice could not break the safety claim (every state the fix reaches is one main reaches by not doing that lookup; its MEDIUM about a permanent gap blocking a new registration is fixed here), threads resolved, merges cleanly. CodeRabbit did not review (rate limit). Real Copilot 1.0.105 logger, 1000 interleaved runs each: lasting unavailable after a rotation 210/1000 on main -> 124/997 here; wrong session 0 on main, 1 here, and that run did not take the new gap path: B's registration line was in no file (Copilot's copy ended just before it and the open log was emptied), and replaying the same file order as a fixed test gives A on main too, so it is a line lost on Copilot's side, not a state only this fix reaches. Fixes #3109.

@devswha
devswha merged commit b995c00 into main Nov 6, 2026
22 of 23 checks passed
@devswha
devswha deleted the fix/copilot-rotation-gap branch November 6, 2026 04:28
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.

Copilot Chat stays unavailable after a lookup lands in the gap of a log rotation

1 participant