Skip to content

fix(rules): terminate findProjectRoot walk at non-cwd drive roots - #19

Merged
code-yeongyu merged 1 commit into
code-yeongyu:mainfrom
lavi-kj:fix/find-project-root-cross-drive-walk
Sep 24, 2026
Merged

code-yeongyu merged 1 commit into
code-yeongyu:mainfrom
lavi-kj:fix/find-project-root-cross-drive-walk

Conversation

@lavi-kj

@lavi-kj lavi-kj commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Windows, findProjectRoot loops forever when the target path is on a different drive than the process cwd (or on a UNC share) and no project marker exists up-tree. This is a synchronous infinite loop, so it freezes the host agent's entire event loop at 100% CPU.

Root cause

const filesystemRoot = resolve("/"); // -> cwd's drive root, e.g. "E:\"
while (true) {
    // ... marker checks ...
    if (currentDirectory === filesystemRoot) return null; // "C:\" === "E:\" never holds
    currentDirectory = dirname(currentDirectory);          // dirname("C:\") === "C:\"
}

The walk terminates only when it reaches resolve("/"), which is cwd's drive root. For a target on another drive, dirname() of that drive's root returns the root itself, so the equality never holds and the loop never exits.

Real-world impact (how this was found)

Discovered while debugging frozen sessions in @code-yeongyu/senpi, which vendors pi-rules as a builtin extension. On a Windows machine with the workspace on E:, every read/edit/write tool call targeting a file on C: (temp screenshots, files under the user profile, etc.) froze the session permanently:

  • 6/6 cross-drive tool calls hung; 26/26 same-drive calls succeeded (session JSONL audit)
  • Hung process burned one full core (53.6s CPU in 60s)
  • V8 inspector stack of the hung process:
#0 findProjectRoot (rules/rules/project-root.js)
#1 fingerprintDynamicTargets (rules/rules/engine.js)
#2 tool_result handler (rules/index.js)
#3 emitToolResult (extension runner)

Because the loop is synchronous, the abort signal is never processed: esc cannot interrupt, and users must kill the process.

Fix

Stop the walk when dirname() stops progressing (parentDirectory === currentDirectory). This preserves the existing resolve("/") early-exit and covers cross-drive roots and UNC share roots uniformly.

Tests

  • New regression test exercising the real cross-drive scenario on multi-drive Windows machines (no-ops on POSIX and single-drive Windows where the scenario cannot occur; pre-fix it hangs, post-fix it returns null instantly).
  • Verified live on Windows 11 (cwd on E:, temp on C:): the new test passes in ~1ms with the fix.
  • Full suite: 244 tests, same 10 failures as the base commit (pre-existing Windows symlink-privilege environment issues in scanner/finder/engine/extension-registration/tool-paths tests); no new failures.

Downstream

senpi vendors this package (packages/coding-agent/src/core/extensions/builtin/rules, MANUAL_PACKAGES). A companion senpi PR applying the same guard to the vendored copy + changes.md entry will follow.


Summary by cubic

Fixes a Windows-only infinite loop in findProjectRoot that froze the agent on cross‑drive or UNC paths without markers. The walk now terminates correctly, preventing 100% CPU hangs on these tool calls.

  • Bug Fixes
    • Stop traversal when dirname() stops changing the path instead of relying only on resolve("/"), covering cross-drive and UNC roots.
    • Add a Windows multi-drive regression test that returns null instead of hanging.

Written for commit 95e16e6. Summary will update on new commits.

Review in cubic

@lavi-kj
lavi-kj requested a review from code-yeongyu as a code owner July 28, 2026 11:11
code-yeongyu pushed a commit to code-yeongyu/senpi that referenced this pull request Jul 30, 2026
…on-cwd drive roots

On Windows, resolve("/") is the cwd drive's root, but dirname() of
another drive's root returns itself. For targets on a different drive
than cwd (or UNC shares), the vendored pi-rules findProjectRoot marker
walk never reaches filesystemRoot and spins forever - a synchronous
infinite loop in the rules extension's tool_result handler that freezes
the agent session (100% CPU, abort signal never processed) on every
read/edit/write call targeting such paths.

Stop the walk when dirname() stops progressing, document the adaptation
in the extension's changes.md, and add a cross-drive regression test.

Upstream fix for the vendored source: code-yeongyu/pi-rules#19
On Windows, resolve("/") is the cwd drive's root, but dirname() of
another drive's root returns itself. For targets on a different drive
than cwd (or UNC shares), the marker walk never reaches
filesystemRoot and spins forever - a synchronous infinite loop that
freezes the host agent's event loop at 100% CPU on every
read/edit/write tool_result for such paths.

Stop the walk when dirname() stops progressing, and add a cross-drive
regression test (real scenario on multi-drive Windows machines).
@code-yeongyu
code-yeongyu force-pushed the fix/find-project-root-cross-drive-walk branch from 2fc5bc4 to 95e16e6 Compare September 24, 2026 06:39

@code-yeongyu code-yeongyu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dirname-stall guard in findProjectRoot is correct and matches the walk already used in getWalkDirectories / widenToRepositoryRoot. On Windows, resolve("/") is the cwd drive root, so a target on another drive (or a UNC share) never equals that sentinel and the old loop spun forever. Rebased onto current main (conflicts were changelog + test imports only).

Nit on the regression test: it returns early on non-Windows instead of skipIf, so CI never exercises it. Acceptable for a Windows-only bug; the production guard is still the right fix.

Local check + 278 unit tests + 46 integration tests passed after rebase. Merging.

Comment thread src/rules/project-root.ts
// Cross-drive / UNC walk: dirname() of a drive root returns the root itself,
// and a root on a different drive than cwd never equals resolve("/"), so the
// walk would spin forever without this guard.
if (parentDirectory === currentDirectory) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct termination: dirname() of a drive root (and of a UNC share root) returns itself, so this is the portable stop condition. filesystemRoot === resolve("/") is not enough when cwd is on a different drive than startPath.

Comment thread test/project-root.test.ts
it("#given startPath on a different drive than cwd (Windows cross-drive) #when finding root #then terminates with null instead of looping forever", () => {
// Cross-drive paths only exist on Windows; this scenario cannot occur elsewhere.
if (process.platform !== "win32") {
return;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This body is a silent no-op on linux/mac CI (bare return rather than it.skipIf). Fine as a Windows-only scenario; not a merge blocker because the guard is a one-line mechanical stop and is already covered in spirit by the no-marker walk returning null.

@code-yeongyu
code-yeongyu merged commit e77280f into code-yeongyu:main Sep 24, 2026
2 checks passed
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.

2 participants