Skip to content

v1.2.6: Watcher notification on an idle Windows checkout clears ready during periodic content verification (unchanged-file filter gaps) #221

Closed

Description

@shengyfu

Summary

On an idle Windows checkout, a managed view lost its ready flag during a periodic content verification, although as far as I can tell no file's content, version or visibility had changed.

  • Between the samples taken 30.8 s and 32.2 s into the pass, four Modify notifications arrived. The unchanged-file filter dropped three.
  • The watcher passed the fourth to ViewSlot::invalidate. The input epoch advanced from 48 to 49, and the sample at 32.2 s reported ready: false. The view kept serving its last publication.

I think the fourth notification hit one of two gaps in WorktreeView::unchanged_file (worktrees.rs:555-571):

  1. Whitelisted dot-files. The filter requires the view's recorded hidden flag to equal the raw is_hidden. The recorded flag is false for a dot-file that an ignore-file whitelist admits, but is_hidden is true, so the filter never matches such a file. This checkout has at least one: a nested .gitignore that contains * followed by !.gitignore.
  2. Files the walk didn't list. Only files the walk listed have evidence, so the filter never matches any other file. This checkout has 3,893 tracked files that ignore rules match.

The daemon doesn't record which paths it invalidates, and it has since exited, so I can't tell which case applied. In an earlier run on the same checkout and binary, the filter dropped all 8 notifications of a burst between 41.9 s and 44.9 s into a content pass, and the view stayed ready.

Environment

  • tgrep v1.2.6, one managed shared daemon. A single host process (the Copilot runtime) held the view's only lease.
  • Windows 11, 16 logical cores.
  • A large private repository with about 299k tracked files in about 41k directories. The last reconcile discovered 293,288 files. The overlay had 3,893 tombstones; all of them are tracked files that are present on disk and matched by ignore rules.
  • A ReFS Dev Drive that isn't marked trusted. Defender real-time protection is on, and an endpoint management agent periodically walks the tree. This is the same host as Managed shared mode on a large Windows repo: 25–32 min cold readiness, watcher input churn, scheduler stall, query cliffs (v1.2.3) #210.
  • The reconciliation policy is in the excerpt below.

Observations

Both runs used the same checkout and tgrep binary. The harness polled views.status throughout: 94 min in run 1 and 69 min in run 2. Times are relative to the start of the content pass, computed from the running work item's elapsed_ms. Line numbers refer to the v1.2.6 tag.

Run 1 (earlier) Run 2 (this failure)
Notifications before the pass None in the preceding 49 min. Earlier, only 4 Git-metadata notifications, which the watcher ignores None in the 68 min observed before the pass
Burst, between the samples at 41.9 s and 44.9 s 30.8 s and 32.2 s
events / by_kind.modify +8 / +8 +4 / +4
unchanged_paths_filtered +8 +3
invalidated_paths / epoch_advances +0 / +0 +1 / +1
Other watcher counters Unchanged Unchanged
Result ready stayed true; the pass finished unchanged after about 266 s ready: false at the next sample (input_epoch 49, authoritative input_epoch 48); the harness stopped there

In run 2's last sample, last_verification still describes the previous metadata pass. content_age_ms exceeds content_delay_ms, so the content pass was due, and the only running work item is that background verification.

After run 2, git status --ignored was empty. No file or directory in the checkout had a modification or creation time in the 98 minutes before the failure, which covers the whole observed window, and none had the Windows hidden attribute.

Run 2's last views.status sample (IDs, paths, hashes and the operation ID omitted)
{
  "ready": false,
  "serving": true,
  "closing": false,
  "error": null,
  "input_epoch": 49,
  "authoritative": {
    "active": true,
    "committed": true,
    "input_epoch": 48,
    "reconciled_epoch": 50,
    "pending": null
  },
  "leases": 1,
  "queries": 0,
  "queued_work": [
    {
      "class": "background",
      "state": "running",
      "elapsed_ms": 32165
    }
  ],
  "last_verification": {
    "age_ms": 152391,
    "content_age_ms": 4768537,
    "content_attempt_age_ms": 4768537,
    "content_delay_ms": 4715836,
    "content_retry_delay_ms": 4715836,
    "metadata_delay_ms": 162631,
    "outcome": "unchanged",
    "tier": "metadata"
  },
  "watch_mode": "native",
  "watch_count": 2,
  "watch_fallback": null,
  "watch_events": {
    "by_kind": {
      "access": 0,
      "create": 4,
      "error": 0,
      "modify": 83,
      "other": 0,
      "remove": 0
    },
    "epoch_advances": 49,
    "events": 87,
    "full_invalidations": 0,
    "git_metadata_ignored": 35,
    "invalidated_paths": 49,
    "tree_invalidations": 0,
    "unchanged_paths_filtered": 3
  },
  "overlay": {
    "tombstones": 3893
  },
  "last_reconcile": {
    "full": false,
    "epoch": 50,
    "files_discovered": 293288,
    "metadata_reused": 293288,
    "base_reused": 287435,
    "overlay_reused": 1,
    "content_reads_avoided": 293288,
    "files_read": 0,
    "provisional_files": 0,
    "peak_workers": 12,
    "peak_private_bytes": 2025108392,
    "timings": {
      "discovery_us": 7582536,
      "admitted_us": 8559562
    }
  },
  "reconciliation_policy": {
    "change_journal": "not-integrated",
    "configured_watch_mode": "auto",
    "content_interval_ms": 1800000,
    "full_repair_triggers": [
      "first-attachment-or-restore",
      "explicit-full-refresh",
      "candidate-read-error",
      "watcher-error-or-overflow",
      "git-or-ignore-change",
      "periodic-verification"
    ],
    "full_verification_reason": "periodic-native-verification",
    "incremental_coverage": "unavailable",
    "interval_ms": 120000,
    "metadata_interval_ms": 120000,
    "native_missed_changes": "repaired-by-periodic-verification",
    "native_periodic_full_verification": true,
    "native_registration_mode": "recursive-roots",
    "native_verification_availability": "serving-until-difference",
    "native_watch_budget_per_view": 256,
    "periodic_full_verification": true,
    "poll_interval_ms": 120000,
    "verification_idle_cost_multiplier": 19
  }
}

How I narrowed it down

  • notify 7.0.0 sends one event per path (windows.rs:359). A rename inside the watched tree produces two Modify(Name) events, one per endpoint (windows.rs:370-382), and FILE_ACTION_MODIFIED becomes Modify(Any) (windows.rs:393-396).
  • In both runs events equals unchanged_paths_filtered + invalidated_paths, and no other watcher counter moved. So the invalidated path wasn't:
    • a directory that was still readable, the checkout root or a path outside it, which set tree;
    • Git metadata or a path under .git or .tgrep;
    • a created or removed file;
    • an endpoint of a rename inside the checkout, which would have invalidated two paths.
  • A rename with only one endpoint inside the checkout can't be ruled out from the counters, but it seems unlikely.
  • ViewSlot::unchanged_file also returns false while a replacement publication is being prepared (views.rs:2149-2158). That's unlikely here: the running work item was the periodic verification (a background reconcile with verify: true), and the other three notifications in the same burst were filtered.

Code path

  1. watch_event treats Modify(Any | Data | Metadata | Other) as a content candidate. The comment there notes that Windows reports attribute-only changes as Modify(Any) (managed.rs:1123-1133).
  2. The watcher stats each candidate. A directory sets tree, and a file is dropped if ViewSlot::unchanged_file returns true. Any other path is pushed and passed to ViewSlot::invalidate (managed.rs:1182-1211).
  3. WorktreeView::unchanged_file (worktrees.rs:555-571) requires a regular file with a trusted version, a recorded hidden flag equal to the raw is_hidden, and evidence with an equal version.
  4. ViewSlot::invalidate (views.rs:2174-2176) calls invalidate_inner (views.rs:2183-2230), which reaches WorktreeView::invalidate_path (worktrees.rs:594-629). A path with a .git, .gitignore, .ignore or .gitattributes component also sets control.tree (worktrees.rs:615-620). So a whitelisted .gitignore would also force a rewalk, which the watcher's tree_invalidations counter doesn't count.
  5. ready requires the publication's input_epoch to equal the slot's epoch (views.rs:1924-1930), so one invalidation clears it. The view keeps serving because managed views favor availability and allow stale reads (managed.rs:2656-2658).
  6. The comment on verify_controlled (worktrees.rs:957-971) says the pass verifies the view "while it keeps serving its published state", and: "A concurrent invalidation leaves its hints queued; an otherwise unchanged pass returns Verification::Superseded and retains verified evidence for the next pass." So ready most likely stays false until the pass ends and a later refresh applies the queued hint.

What I couldn't verify

Both bursts came 30–45 s into a content pass, after at least 49 quiet minutes, so the pass may have triggered them. I couldn't confirm that. In #210 (section 2, same host), an external tree walk produced attribute-only notifications for unmodified files, while plain reads produced none. If the pass did trigger them, case 1 is the better fit: the pass reads the files the walk listed, and case 2 covers only files it didn't list.

Expected

The comment at managed.rs:1182-1184 says: "Drop notifications that left a file's trusted metadata equal to the indexed evidence, so external walkers and attribute churn do not trigger passes." That should also hold for whitelisted dot-files and for files the walk didn't list. Real changes must still invalidate: a version change, a hidden-attribute change that changes visibility, and edits to ignore or attributes files, even ignored ones.

Impact

  • No incorrect results: the view kept serving its last publication.
  • ready most likely stays false until the pass ends, about 4 more minutes here. That's an estimate from run 1's 266 s pass, not a measurement.
  • Hosts that gate on ready stop using the index meanwhile. The Copilot runtime falls back to scanning.
  • If the path was an ignore or attributes file, the invalidation also forces a rewalk. Discovery took 7.6 s on this checkout.

Possible fixes

  1. Compare like with like in unchanged_file: apply the whitelist when computing the expected hidden flag, or keep the raw hidden bit with the evidence.
  2. Drop candidates for regular files that the view's ignore rules exclude. Check the matcher rather than missing evidence, because a new file has no evidence either. Ignore and attributes files keep their rewalk. Alternatively, keep metadata-only evidence for files the walk skips.
  3. Report why a candidate wasn't filtered in views.status, for example as counters for missing evidence, a hidden mismatch, a version mismatch or a preparing slot, and optionally the last few invalidated paths. That would have identified the path here.
  4. Add a Windows regression test with a subdirectory .gitignore containing * and !.gitignore, and a force-added file that ignore rules match. Toggle attrib +R and attrib -R on each, then assert that unchanged_paths_filtered rises, invalidated_paths doesn't, and the view stays ready.

Questions

  1. Is comparing the whitelist-adjusted hidden flag with the raw is_hidden intended?
  2. Would dropping notifications for files the view's ignore rules exclude be acceptable, given that ignore and attributes files keep their rewalk?

I haven't run a standalone reproduction yet; I'll add one here once the host is free. Sanitized excerpts of the views.status samples from both runs are available on request.

Related: #210.

Activity

  1. shengyfu commented on Nov 1, 2026

    @shengyfu
    MemberAuthor

    Standalone reproduction

    Both cases reproduce on a synthetic repository on the same host as runs 1 and 2, with the tgrep v1.2.6 release binary:

    • Toggling the read-only attribute of an unchanged file invalidates the view for a whitelisted dot-file (case 1) and for a tracked file that ignore rules exclude (case 2).
    • The same toggle is filtered for five control files, three of them hidden but not whitelisted.
    • During a content pass, one such toggle kept ready at false until the pass ended, and the pass completed as superseded.

    Method

    The fixture is a new Git repository with core.autocrlf=false, no system Git configuration, and a scratch global configuration that only turns off automatic gc and maintenance. It has 30,000 (Phase A) or 120,000 (Phase B) bulk text files of 8 KiB under bulk/, 200 per directory, plus these files, all tracked:

    Path Contents Listed by the walk Expected
    control.txt text yes filtered
    .editorconfig root = true yes; hidden, not whitelisted filtered
    .gitignore *.log yes; hidden, not whitelisted filtered
    wl/.gitignore !.keep.txt yes; hidden, not whitelisted filtered
    bulk/d000/f00007.txt one of the bulk files yes filtered
    wl/.keep.txt text yes; hidden, whitelisted by wl/.gitignore case 1
    sub/.gitignore *, then !.gitignore yes; hidden, whitelisted by itself case 1
    sub/inner.txt text, force-added no; excluded by sub/.gitignore case 2
    ign/tracked.log text, force-added no; excluded by *.log case 2

    The view listed 30,006 or 120,006 files: the bulk files and the six other files marked yes.

    • Toggle: os.chmod(path, S_IREAD), a 0.2 s pause, then os.chmod(path, S_IREAD | S_IWRITE). This sets and clears FILE_ATTRIBUTE_READONLY, like attrib +R and attrib -R. The script checks that the mtime and size are unchanged and that neither the hidden nor the read-only attribute is left set.
    • Daemon: one tgrep serve --shared daemon with a policy that matches the Copilot runtime's defaults (workers 1, queue_items 256). The view is attached the way the runtime attaches one: owners.prepare, a tgrep shared owner-hold --claim child, owners.register, then views.attach with content raw-git-blob-auto-v1, coverage tracked-regular-files-v1 and content-only eligibility.
    • Sampling: each sample is one tgrep shared manage <repo>/.git views.status process. The median spacing was 0.26–0.27 s at idle and 0.56 s during a content pass.
    • Phase A (30k files, default intervals): two rounds over all nine files. Before each toggle, wait until the view is ready, idle and unchanged for 1 s. After it, sample for at least 4 s and until the view is ready, idle and unchanged for 1 s again.
    • Phase B (120k files, daemon started with --poll-interval 2): wait for a periodic content verification (a running reconcile with verify: true that isn't metadata-only) and time it without toggling. During each of the next two passes, toggle control.txt 5 s in and the target 8 s in, then sample until the operation has finished and the view is ready and idle.

    Phase A: at idle, 30,006 listed files

    The attach took 8.7 s, with native watching. The first full content pass read all 30,006 listed files and produced no notifications. All 18 toggles matched the expectation, none overlapped a verification, and the view kept serving throughout.

    • Each of the 10 control toggles: events +2 and unchanged_paths_filtered +2, nothing else. ready never dropped, and no operation ran.
    • Each of the 8 target toggles: events +2, invalidated_paths +2 and epoch_advances +2, with no tree or full invalidation. Each queued one hinted reconcile (verify: false, full: false), which restored ready about 1 s after the toggle.
    Target Case Round 1 (s): not ready; ready again Round 2 (s): not ready; ready again files_read / metadata_reused
    wl/.keep.txt 1 0.230–0.786; 1.047 0.214–0.777; 1.068 1 / 30,005
    sub/.gitignore 1 0.218–0.760; 1.027 0.218–0.776; 1.035 1 / 30,005
    ign/tracked.log 2 0.224–0.537; 0.836 0.216–0.789; 1.063 0 / 30,006
    sub/inner.txt 2 0.212–0.769; 1.036 0.221–0.827; 1.085 0 / 30,006

    Times are seconds after the toggle returned: the first and last not-ready samples, then the next ready sample. Every hinted reconcile reported files_discovered 30,006 and hint_lookups 90,009, with timings.admitted_us between 0.825 s and 0.892 s.

    Phase B: during a content pass, 120,006 listed files

    The attach took 25.5 s, with native watching. Times are from the start of the content pass, computed from the elapsed_ms that views.status reported for it in queued_work. Toggle times are when the toggle returned.

    Calibration pass Case 2: ign/tracked.log Case 1: wl/.keep.txt
    control.txt toggle – 5.313 s; 2 filtered 5.435 s; 2 filtered
    Target toggle – 8.704 s; 2 invalidated, epoch +2 8.457 s; 2 invalidated, epoch +2
    Last ready sample before the drop – 8.449 s 8.204 s
    Not ready never (36 samples) 9.214–20.670 s, 24 of 43 samples 8.893–20.748 s, 25 of 42 samples
    Operation finished, view ready 20.009 s 20.938 s 21.016 s
    Receipt verified: true, changed: false, full: true, files_read 120,006 verification: "superseded", verified: null, full: false, files_read 0, metadata_reused 120,006 verification: "superseded", verified: null, full: false, files_read 1, metadata_reused 120,005
    timings.admitted_us 19.927 s 20.735 s 20.819 s
    • ready stayed false in every sample from the drop until the operation finished: 12.2 s (case 2) and 12.6 s (case 1) after the target toggle.
    • The calibration pass produced no notifications, and the other two passes only the four from their toggles. The view kept serving throughout.
    • Both superseded passes left last_verification.outcome at superseded. The next content pass started 394 s after case 2's pass ended, which matches the content_delay_ms reported after that pass (393,961 ms).

    What this shows

    • Impact is confirmed. One notification in either gap clears ready until a refresh applies the hint. At idle that refresh is a separate hinted reconcile, about 1 s here. During a content pass, ready stays false until the pass ends. With run 1's pass of about 266 s, that would be about 234 s in run 2 and about 70 s in run 3 (below); both are estimates.
    • Code path, step 6: ready stayed false until the pass ended, but no later refresh was needed. The superseded operation applied the queued hint itself: its receipt describes that hinted step (full: false; files_read 1 for wl/.keep.txt, the hinted path), and the view was ready in the sample where the operation finished.
    • A superseded pass restarts the content-verification clock. Its receipt reports verified: null, but the scheduler handles superseded like unchanged (managed.rs:2201-2206). With run 2's content_delay_ms of 4,715,836 ms, the next content verification on the real checkout would come about 79 min later.
    • For question 1: the three hidden files that aren't whitelisted (.editorconfig, .gitignore, wl/.gitignore), two of them ignore files, were filtered, and the two whitelisted dot-files weren't. So the mismatch comes from the whitelist adjustment, not from hidden files or ignore files in general.
    • For question 2: the case-2 refreshes read no files (files_read 0), yet each kept ready at false until it finished.
    • Rewalk: under Impact, I said an invalidated ignore or attributes file also forces a rewalk. On the fixture that made no measurable difference, because every hinted reconcile walked the whole tree anyway: each reported files_discovered 30,006 and hint_lookups 90,009. The sub/.gitignore refreshes took 0.842 s and 0.853 s (timings.admitted_us), within the 0.825–0.892 s of the other targets.
    • Fix 4: the attribute toggle is a reliable trigger on this host. Each of the 22 toggles produced exactly two Modify notifications.

    Run 3

    A third run on the same checkout and binary failed the same way. Its content pass started after 61.7 minutes without notifications. Between the samples at 195.3 s and 196.3 s into the pass, 36 Modify notifications arrived: unchanged_paths_filtered +31, invalidated_paths +5 and epoch_advances +5 (input epoch 16 to 21; authoritative 16). The tree, full and Git-metadata counters didn't move. The view reported ready: false and kept serving. The harness stopped at that sample, so as in run 2 there's no receipt.

    Still unverified

    • Whether the content passes trigger the real bursts. On the 120k fixture, the three periodic content passes started 6–14 minutes after the previous content pass, and none produced notifications besides the toggles. In runs 1–3 the passes started after at least 49 minutes without notifications, so the fixture neither confirms nor rules this out.
    • Which paths runs 2 and 3 invalidated. Fix 3 would show that.

    Separate observation: notifications during the first pass after a checkout

    The 120k fixture was committed with git fast-import and checked out with git reset --hard, like a fresh clone. During the first full content pass after the attach, which took about 45 s, 237,540 Modify notifications arrived. None were filtered (unchanged_paths_filtered stayed 0). A hinted reconcile then re-read 120,000 of the 120,006 listed files (21.4 s, hint_lookups 720,009). The view first reported ready 65 s after views.attach returned.

    Later content passes over the same files produced no notifications besides the toggles. On the 30k fixture, where git add had read every file after the script wrote it, the first pass produced none either. I haven't identified the source. If it's general, a host that attaches to a fresh clone or worktree reads every file twice before the view is first ready. Would you like a separate issue for this?

    Environment

    • Windows 11 (10.0.26300), 16 logical cores, Python 3.14.7, tgrep 1.2.6 release binary. Same host as runs 1–3.
    • The fixture was on a ReFS Dev Drive that isn't trusted, with Defender real-time protection on. Daemon storage was in a temporary directory on the system drive.

    I can attach the script (standard-library Python only) if it would help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions