Skip to content

fix: stop spawning a child process per file, and bound the codex version probe - #582

Merged
zzet merged 2 commits into
mainfrom
fix/churn-file-exists-subprocess-fanout
Aug 15, 2026
Merged

fix: stop spawning a child process per file, and bound the codex version probe#582
zzet merged 2 commits into
mainfrom
fix/churn-file-exists-subprocess-fanout

Conversation

@zzet

@zzet zzet commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Investigated #581 and fixed the two real defects it surfaced.

On the reported findstr.exe

Gortex never invokes findstr. It is not in the source, and there is no grep/ripgrep search fallback to degrade into: search_text is a trigram index inside the daemon, and every code-search path is in-process. The one rg shell-out in the tree is a benchmark baseline adapter under bench/, which the daemon does not link.

So the report's specific attribution does not hold. But the shape it describes — a daemon fanning out short-lived children with no console suppression and no deadline — is real, and two instances of it are fixed here.

1. churn spawned test -f once per indexed file

stripRepoPrefix answered "does this path exist" by shelling out:

var fileExists = func(path string) bool {
	cmd := exec.Command("test", "-f", path)
	return cmd.Run() == nil
}

The identical helper in internal/blame was already converted to os.Stat, and its comment says so explicitly — "without spawning a process for every lookup". This copy was missed, so internal/churn kept the old behaviour.

Consequences:

  • Process fan-out. One child process per indexed file per enrichment pass. On the ~105k-node workspace in the issue that is thousands of spawns to answer a question os.Stat answers inline. The call site never went through platform.ConfigureBackgroundCommand, so on Windows each child also gets its own console window — matching the reported symptom of console windows appearing during activity, concurrent with the git fan-out.
  • Silently wrong on Windows. There is no test executable on Windows, so every lookup failed, multi-repo paths were never stripped, and the enricher produced no churn data at all.

os.Stat + Mode().IsRegular() reproduces the test -f semantics exactly (regular files only, symlinks followed) and needs nothing on PATH. The exec.LookPath("git") gate goes with it — path stripping does not depend on git being installed, and EnrichGraph already fails at its rev-parse before reaching this code when git is absent.

2. codex --version probe could hang forever

The Codex adapter ran exec.Command(path, "--version").Output() with no deadline. A version banner is instant, so any delay means a wedged binary — and the unbounded call blocks the install indefinitely while leaving a child that never exits. That is the issue's suggested fix #2, and the process signature it describes (zero CPU since start, never
exits).

Now bounded by a 5s CommandContext deadline and routed through ConfigureBackgroundCommand. The failure posture is unchanged and already correct: a probe error reads as "unknown install" and leaves direct tool exposure on, so the deadline degrades into the existing safe default rather than silently downgrading the install.

Not changed

  • CREATE_NO_WINDOW on every spawn site. Already handled — 17 call sites route through platform.ConfigureBackgroundCommand, including every git invocation (gitcmd.Run), LSP servers, and the LLM subprocess providers. The two sites fixed here were the daemon-side gaps.
  • Fail loudly when ripgrep is missing. Not applicable: Gortex does not require or use ripgrep at runtime.
  • The 10 concurrent gortex.exe processes. Left alone — no defect identified, and the reporter flagged it without claiming one. Worth a separate issue if it recurs with process-tree evidence.

Verification

Both tests were confirmed to fail against the pre-fix code:

  • TestStripRepoPrefix_NeedsNoExternalBinaries fails with got "myrepo/internal/foo.go", want "internal/foo.go" — the exact Windows breakage, reproduced on any platform by emptying PATH rather than by relying on test happening to exist on POSIX CI.
  • TestCodexVersionOutput_TimesOut does not compile pre-fix; there was no deadline to assert on.

go build ./..., go vet, golangci-lint run (0 issues), and
go test -race across internal/churn, internal/blame,
internal/agents/..., and cmd/gortex all pass. internal/churn,
internal/blame, and internal/platform also cross-compile for
GOOS=windows.

Closes #581

zzet added 2 commits August 15, 2026 09:04
churn.stripRepoPrefix answered "does this path exist" by shelling out to
`test -f`, once for every indexed file on every enrichment pass, and
gated the whole helper on `exec.LookPath("git")` for good measure. The
identical helper in internal/blame was already converted to os.Stat; its
comment even says "without spawning a process for every lookup". This
copy was missed.

Two consequences:

  - Process fan-out. A large workspace pays thousands of child-process
    spawns per pass to answer a question os.Stat answers inline. On
    Windows each of those children is also handed a fresh console
    window, because the call site never went through
    platform.ConfigureBackgroundCommand.

  - Silently wrong on Windows. There is no `test` executable there, so
    every lookup failed, multi-repo paths were never stripped, and the
    enricher walked away with no churn data at all.

os.Stat + Mode().IsRegular reproduces the `test -f` semantics exactly
(regular files only, symlinks followed), and needs nothing on PATH.

The LookPath gate goes with it: path stripping has nothing to do with
git being installed, and EnrichGraph already fails at its rev-parse
before reaching this code when git is absent.

The new test pins the property that broke on Windows — resolution works
with an empty PATH — rather than the incidental fact that `test` happens
to exist on POSIX CI.
The adapter resolved `codex` on PATH and ran `--version` with no
timeout. A version banner is instant, so any delay means a wedged
binary — and an unbounded Output() call blocks the install forever
while leaving behind a child process that never exits and, on Windows,
a console window the user has to close by hand.

Give the probe a 5s deadline via CommandContext so a stuck binary is
killed rather than waited on, and route it through
platform.ConfigureBackgroundCommand so it does not allocate a console
of its own when the caller has none.

The failure posture is unchanged and already correct: a probe error
reads as "unknown install" and leaves direct tool exposure on, so the
deadline degrades to the existing safe default instead of silently
downgrading the install. Covered by a test so it stays that way.
@zzet
zzet merged commit 7e1458b into main Aug 15, 2026
12 checks passed
@zzet
zzet deleted the fix/churn-file-exists-subprocess-fanout branch August 15, 2026 08:44
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.

findstr.exe child processes hang forever (stdin never wired) on Windows hosts without ripgrep

1 participant