#16616

Windows: Codebase indexing opens a handle on every changed file in an indexed repo, so other programs' file replaces fail with "Access is denied" (os error 5)

OpenEthanHippsopened 8d ago

Pre-submit Checks

Describe the bug

When Codebase Context is on and a repo is indexed, Warp opens a handle on every file that changes in that repo, including gitignored paths, within about 10 µs to 10 ms of the change. While that handle is open, any other program that replaces the file with MoveFileExW(…, MOVEFILE_REPLACE_EXISTING) gets ERROR_ACCESS_DENIED (os error 5). That is the common "write a temp file, then rename it over the original" save pattern.

I encountered this as intermittent "Access is denied" failures in a Rust project's test suite, which writes and replaces many small files under target/. In a tight loop it reproduces almost every time (196 of 200 trials). With Codebase Context off it does not reproduce at all, and neither VS Code nor RustRover watching the same tree causes it (for comparison).

To reproduce

  1. Windows 11. In Warp, turn Settings → Code → Codebase Context on (I also have auto-indexing on).
  2. Open a Warp tab in the repo and leave it idle. Wait until Settings → Code shows the repo as indexed. The file watcher is registered only when an index build loads the repo's gitignores (GitignoresUpdated → watch_path). Turning Codebase Context off drops all indices (reset_codebase_indexing), so after turning it back on the repo has to be re-indexed before this reproduces.
  3. From another terminal, run the script below against a new folder inside the repo. A gitignored folder still reproduces: pwsh -NoProfile -File .\warp-replace-repro.ps1 -Path <repo>\target\warp-repro
  4. Turn Codebase Context off, keep Warp open, and run it again (or point -Path outside any repo).

My results with this script:

  • Codebase Context on: 176 of 200 trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in C:\repos\foo\target\warp-repro-on (1.9 s)
  • Codebase Context off: 0 of 200 trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in C:\repos\foo\target\warp-repro-off (3.2 s)

Measurements from an equivalent Node script (same algorithm, 200 trials each, same repo, gitignored folder):

ConditionTrials with Access denied
Warp closed0 / 200
Warp open, idle tab in the repo, Codebase Context on196 / 200
Warp closed again0 / 200
Warp open, idle tab in the repo, Codebase Context off0 / 200
Warp open, folder outside any repo0 / 200
Warp closed, VS Code open on the repo0 / 200 (visible folder: 1 / 200)
Warp closed, RustRover open on the repo0 / 200
warp-replace-repro.ps1 (Windows PowerShell 5.1 or PowerShell 7, no elevation)
param(
    [Parameter(Mandatory = $true)][string]$Path,  # a new folder; the script creates it
    [int]$Trials = 200,
    [int]$Attempts = 50,
    [switch]$Keep                                 # keep the folder instead of deleting it
)
$ErrorActionPreference = 'Stop'
# Warp's handle lives only microseconds, so the hot loop is compiled C#; a plain PowerShell loop
# reproduces far less often.
Add-Type -TypeDefinition @'
using System.IO;
using System.Runtime.InteropServices;
public static class WarpReplaceRepro {
    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    static extern bool MoveFileExW(string existingFileName, string newFileName, uint flags);
    const uint MOVEFILE_REPLACE_EXISTING = 1;

    // Returns the number of trials in which a replace was refused with ERROR_ACCESS_DENIED.
    public static int Run(string dir, int trials, int attempts) {
        byte[] payload = new byte[1300];
        int denied = 0;
        for (int t = 0; t < trials; t++) {
            string destination = Path.Combine(dir, "d-" + t + ".json");
            File.WriteAllBytes(destination, payload);  // create, write and close
            for (int a = 0; a < attempts; a++) {
                string temp = Path.Combine(dir, ".t-" + t + "-" + a);
                File.WriteAllBytes(temp, payload);
                if (!MoveFileExW(temp, destination, MOVEFILE_REPLACE_EXISTING)) {
                    int err = Marshal.GetLastWin32Error();
                    if (err != 5) throw new IOException("MoveFileExW failed with Win32 error " + err + " (expected only 5)");
                    denied++;  // another process had the destination open
                    break;
                }
            }
        }
        return denied;
    }
}
'@

if (Test-Path -LiteralPath $Path) { throw "Pass a folder that does not exist yet: $Path" }
New-Item -ItemType Directory -Path $Path | Out-Null
$watch = [Diagnostics.Stopwatch]::StartNew()
try {
    $denied = [WarpReplaceRepro]::Run((Resolve-Path -LiteralPath $Path).ProviderPath, $Trials, $Attempts)
}
finally {
    if (-not $Keep) { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Continue }
}
'{0} of {1} trials got ERROR_ACCESS_DENIED from MoveFileExW(REPLACE_EXISTING) in {2} ({3:N1} s)' -f $denied, $Trials, $Path, $watch.Elapsed.TotalSeconds

Expected behavior

Warp's watcher should not hold handles on files in the watched tree, least of all gitignored ones, so other programs can replace them. Other tools watching the same tree (VS Code, RustRover) don't cause this.

Screenshots, videos, and logs

No response

Operating system (OS)

Windows

Operating system and version

10.0.26300

Shell Version

PowerShell 7.6.7

Current Warp version

v0.2026.10.14.08.27.stable_03

Regression

No, this bug or issue has existed throughout my experience using Warp

Recent working Warp date

No response

Additional context

More Specific OS Details: Windows 11 Pro 10.0.26300. The repo is on a ReFS Dev Drive; NTFS is not tested.

I had an agent examine the source, at a36b76b. It proposed the following as the cause:

  • crates/ai/src/index/full_source_code_embedding/manager.rs#L860-L870: watch_path builds the filter !is_git_internal_path(path) && !matches_gitignores(path, path.is_dir(), …). path.is_dir() is evaluated before the ignore match, so every non-.git path is stat'd, whether ignored or not. .gitignore and .warpindexingignore can't prevent it.
  • warpdotdev/notify notify/src/windows.rs#L376 calls should_emit_event(&path) synchronously in the ReadDirectoryChangesW completion handler, for every raw event, before any debouncing.
  • On Windows, Path::is_dir() is fs::metadata, which opens a handle. rust-lang/rust#130169 proposes a handle-free alternative (GetFileInformationByName).
  • Turning Codebase indexing off goes through reset_codebase_indexing (app/src/ai/persisted_workspace.rs#L645-L648) and removes the problem: 196/200 → 0/200.

Possible fix: stat only when the answer depends on it.

  • is_dir matters only when a directory-only pattern (build/, or a negation like !*/) is the match for the path itself; parent directories are always matched as directories (ignore 0.4.24, gitignore.rs#L234-L262). So evaluate matches_gitignores with is_dir = false and with is_dir = true:
    • If they agree, use that answer with no I/O. That covers everything under target/, and every path no directory-only pattern matches, which includes ordinary source files.
    • If they disagree, fall back to path.is_dir() as today.
  • This gives today's answer for every path, so it needs no downstream change and stays correct for both roles the closure serves (should_watch_directory and should_emit_event). Nothing after the filter re-checks gitignores, so simply emitting the ambiguous paths without a stat would index files excluded by a negation such as parentdir/* + !parentdir/*/, the case in entry_tests.rs.
  • Regression test: extract the predicate, as repo_watch_filter already is in repo_metadata/src/entry.rs, with an injected is_dir probe. Assert that the probe is never called for target/** or for ordinary source paths, that it is called for a build/-matched leaf, and that every answer matches the old predicate, including the entry_tests.rs negation case.

Related, not duplicates:

  • #11464 adopted the current should_watch_directory / should_emit_event filter API (from warpdotdev/notify#3). Before it the predicate made three handle-opening calls per event (exists(), canonicalize, is_dir()); now it makes one. #15240 changed only the type of watch_path's gitignores argument. The is_dir() call is still there at a36b76b.
  • #13153 (closed): the same Windows root cause. ReadDirectoryChangesW delivers target/** events because descend pruning is inotify-only; the symptom there was background thread-pool saturation.
  • #8409: CPU while a busy session runs. A comment there notes that git worktrees nested under the repo get watched too. Open PR #15249 addresses Windows directory re-walks.
  • #13323: the file-tree path ignores nested .gitignore files.
  • The repo watcher (repository.rs:568) and the file tree (local_model.rs:1535, 1541, 1818) also stat changed paths before checking ignores, but only after a 0.5–1.25 s debounce. So turning Codebase indexing off removes the fast holder, not every open: Warp still used about 27 s of CPU per test round with it off, against about 38 s with it on. The impact is lower, and the same "check the patterns first" idea would apply there.

If this gets marked ready-to-implement, I'm happy to send a PR for the indexer predicate.

Does this block you from using Warp daily?

No

Is this an issue only in Warp?

Yes, I confirmed that this only happens in Warp, not other terminals.

Warp Internal (ignore): linear-label:b9d78064-c89e-4973-b153-5178a31ee54e

No response

Activity

1
warp-for-oss[bot]8d ago

@EthanHipps

Here's what I found while triaging this issue:

Thanks for the detailed repro. This looks like a Windows-specific Codebase Context watcher bug with strong reproduction evidence; turning Codebase Context off appears to be a valid temporary workaround based on the report.

Maintainer details

I concluded that windows Codebase Context file watching appears to open transient handles on changed files in indexed repositories, causing atomic replace operations to fail with access denied..

Triage summary

This is a well-specified Windows bug report for Codebase Context/codebase indexing. The reported symptom is that, after a repository has been indexed, Warp's watcher briefly opens handles on changed files in the repo, including gitignored paths such as target/, causing other programs' atomic replace operations to fail with ERROR_ACCESS_DENIED / os error 5.

Evidence supports high reproducibility: the report includes a standalone PowerShell/C# repro, 176/200 and 196/200 failure rates with Codebase Context on, and 0/200 controls when Warp is closed, Codebase Context is off, or the folder is outside an indexed repo.

The current code supports the likely root cause hypothesis. CodebaseIndexManager::watch_path registers a recursive watcher after GitignoresUpdated and its filter calls path.is_dir() before matches_gitignores(...). On Windows that metadata probe can open a transient handle for every raw watcher path before ignored paths are discarded. The shared watcher passes this filter into the notify debouncer registration path, so this can happen before Warp's higher-level debouncing. reset_codebase_indexing also explains why disabling Codebase Context removes the fast repro path.

Relevant areas for maintainers:

  • crates/ai/src/index/full_source_code_embedding/manager.rs for the Codebase Context watcher filter.
  • crates/watcher/src/lib.rs for watcher registration through WatchFilter.
  • app/src/ai/persisted_workspace.rs for Codebase Context enable/disable behavior.
  • crates/repo_metadata/src/entry.rs for existing repo watcher ignore-filter patterns and related tests.

I did not find enough duplicate evidence to mark this as a duplicate. The closest open candidates are broader or different watcher/indexing reports, not the same Windows file-handle/atomic-replace failure mode.

This is my automated analysis and may be incorrect. A maintainer will verify the details.

Powered by Oz