Skip to content

fix(watch): drop events on paths the OS cannot stat - #959

Closed
Chang-Jin-Lee wants to merge 1 commit into
tirth8205:stagingfrom
Chang-Jin-Lee:fix/watch-unstattable-path
Closed

Chang-Jin-Lee wants to merge 1 commit into
tirth8205:stagingfrom
Chang-Jin-Lee:fix/watch-unstattable-path

Conversation

@Chang-Jin-Lee

Copy link
Copy Markdown
Contributor

Pull Request

Linked issue

Closes #897

What & why

WatchBatchProcessor._relative_path walks up to the nearest existing ancestor with a bare Path.exists(). That call answers False for a path that is merely absent, but it raises OSError for a path the OS cannot stat at all. A component past NAME_MAX is the easy case:

>>> Path('/tmp/' + 'a'*300 + '/x.py').exists()
OSError: [Errno 36] File name too long

The exception escaped _relative_path, was caught by process()'s except BaseException into self.failure, and the next raise_if_failed() ended the watch loop with RuntimeError: watch update failed. Under the daemon the watcher then restarted with backoff, paid for a full initial update, and died again the next time the same event arrived — so any tool writing a too-long name inside a watched repo takes the watcher down repeatedly.

collect_all_files already skips paths past the OS limits, so the concern is acknowledged elsewhere in this file; the watch path just did not carry the guard.

The exists() walk, the resolve() call and the symlink scan now sit in one guarded block. OSError drops the event the way an ignored path is dropped and logs at debug, matching the sibling guard in collect_all_files. The pre-existing ValueError branch keeps its meaning, that a path resolving outside the root is not ours; widening it over the walk costs nothing, since a path Windows rejects as malformed should be dropped for the same reason.

I took the OSError route rather than reusing the length check because it also covers the cases a length check cannot see — a broken mount, a parent that has become unreadable — and every one of them wants the same answer.

The two tests are the reproduction from the issue. The first asserts the loop survives the event; the second puts a real file in the same batch and asserts it is still indexed, so the guard drops the bad event rather than the whole batch.

How it was tested

$ uv run pytest tests/test_watch_robustness.py::TestUnstattablePaths -q
2 passed

$ uv run pytest tests/test_watch_robustness.py -q
58 passed

$ uv run pytest tests/ --tb=short -q
2987 passed, 9 skipped, 2 xpassed in 35.00s

$ uv run ruff check code_review_graph/
All checks passed!

$ uv run mypy code_review_graph/incremental.py --ignore-missing-imports --no-strict-optional
Success: no issues found in 1 source file

Both new tests fail on main with RuntimeError: watch update failed, which is the symptom in the issue.

ruff format reports incremental.py and test_watch_robustness.py as unformatted, but it reports the same on unmodified main, so I left formatting alone rather than bury this change in an unrelated reflow.

Verified on Linux (Python 3.12). The issue reports macOS/3.13; the failing call is Path.exists() raising ENAMETOOLONG, which is POSIX behaviour rather than platform-specific, and the repro reproduces here unchanged.

One note in case it saves someone else the detour: I first ran the suite with TMPDIR on a tmpfs path that happened to contain the substring test, and test_parser.py::TestCodeParser::test_non_test_file_describe_not_special failed because the temp file it writes then sits under a test-looking path. Nothing to do with this change, but the test is sensitive to where TMPDIR points.

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest tests/ --tb=short -q
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Docs updated where behavior changed (README, docs/, docstrings)

docs/TROUBLESHOOTING.md describes watcher stalls in terms of a dead observer thread; this failure ended the loop through raise_if_failed instead, and now does not happen at all, so I did not find a line there that became wrong. Happy to add one if you would rather it be named.

WatchBatchProcessor._relative_path walked up to the nearest existing
ancestor with a bare Path.exists(), which propagates OSError rather than
answering False when the OS cannot stat the path at all. A component past
NAME_MAX is the easy case:

    >>> Path('/tmp/' + 'a'*300 + '/x.py').exists()
    OSError: [Errno 36] File name too long

That escaped _relative_path, was caught by process()'s except BaseException
into self.failure, and the next raise_if_failed ended the watch loop with
RuntimeError: watch update failed. Under the daemon the watcher restarted,
paid for a full initial update, and died again on the next such event.

collect_all_files already skips paths past the OS limits, so the concern is
acknowledged elsewhere in the file; the watch path just lacked the guard.

The exists() walk, the resolve() call and the symlink scan now share one
guarded block. OSError drops the event the way an ignored path is dropped
and logs at debug, matching the sibling guard in collect_all_files. The
pre-existing ValueError branch keeps its meaning: a path outside the root
is not ours.

Closes tirth8205#897
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.55 (MEDIUM) — 7 changed function(s)/class(es), 2 affected flow(s), 3 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.55 medium code_review_graph/incremental.py::_create_watch_handler code_review_graph/incremental.py:2154 yes
0.40 medium code_review_graph/incremental.py::WatchBatchProcessor._relative_path code_review_graph/incremental.py:2174 no
0.35 low code_review_graph/incremental.py::WatchBatchProcessor code_review_graph/incremental.py:2168 no
0.30 low tests/test_watch_robustness.py::TestUnstattablePaths._overlong tests/test_watch_robustness.py:1446 yes
0.05 low tests/test_watch_robustness.py::TestUnstattablePaths tests/test_watch_robustness.py:1434 no
0.05 low tests/test_watch_robustness.py::TestUnstattablePaths.test_path_the_os_cannot_stat_is_dropped_not_raised tests/test_watch_robustness.py:1451 (test)
0.05 low tests/test_watch_robustness.py::TestUnstattablePaths.test_an_overlong_event_does_not_hide_a_real_one tests/test_watch_robustness.py:1464 (test)

Affected execution flows

  • _run — criticality 0.36, 68 node(s) across 1 file(s)
  • process — criticality 0.35, 41 node(s) across 1 file(s)

Test gaps

  • code_review_graph/incremental.py::WatchBatchProcessor (code_review_graph/incremental.py:2168)
  • code_review_graph/incremental.py::WatchBatchProcessor._relative_path (code_review_graph/incremental.py:2174)
  • tests/test_watch_robustness.py::TestUnstattablePaths (tests/test_watch_robustness.py:1434)

Token savings: this graph-backed report used ~29,354 fewer tokens (~73%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205

Copy link
Copy Markdown
Owner

Integrated on integration/token-efficiency-hardening as 33a96b4. Unstattable watch events are contained to the offending path, allowing a valid source event in the same batch to update the graph. Both path/batch regressions fail on main and pass with the change; the native FSEvents whole-module run remained unavailable in the sandbox.

@Chang-Jin-Lee

Copy link
Copy Markdown
Contributor Author

Thanks for taking it.

On the FSEvents gap: the guard sits in _relative_path, which every backend feeds after the fact, so FSEvents changes which paths arrive rather than whether an unstattable one is dropped. What stays untested there is the batching shape — a whole-module event whose path stats fine is a different question from this one, and nothing here claims to answer it.

@tirth8205

Copy link
Copy Markdown
Owner

Merged into staging through the integration PR #988 (merge commit 3030ca8). Your change is carried by commit 33a96b4, which keeps you as the author (Chang-Jin-Lee). It will be promoted staging → testing → main and released from there. Thank you for the contribution! Closing this PR since its content is now on staging.

@tirth8205 tirth8205 closed this Sep 15, 2026
Bakul2006 pushed a commit to Bakul2006/code-review-graph that referenced this pull request Sep 15, 2026
…annot stat

Port the reviewed token-efficiency implementation with targeted regression coverage.

Source-PR: tirth8205#959
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.

watch: an event on a path the OS cannot stat (name too long) kills the watch loop

2 participants