Skip to content

Windows: hook invocations leak orphaned conhost.exe windows (no windowsHide on internal git subprocess spawns) #1

Description

@valinmalach

Title

Windows: hook invocations leak orphaned conhost.exe windows (no windowsHide on internal git subprocess spawns; several go through execSync's implicit shell unnecessarily)

Summary

On Windows, a single Verity hook firing (e.g. the Stop hook's verity analyze) spawns a dozen or more OS console-host windows internally. @codacy/verity-cli/bin/verity.js shells out to git at 27 separate call sites, none of which pass windowsHide: true. 24 of those 27 go through execSync with a command string, which unconditionally runs through cmd.exe on Windows (an extra shell process per call that the other 7 call sites, which already use execFileSync("git", [args]), avoid). Under the resulting volume, some of these short-lived consoles fail to tear down cleanly and are left behind as empty, invisible conhost.exe processes that accumulate over a session.

Environment

  • OS: Windows 11 Pro (10.0.26200)
  • Shell: PowerShell 7 (pwsh.exe)
  • Verity plugin version: 0.33.1 (Claude Code plugin install)
  • @codacy/verity-cli bundle: node_modules/@codacy/verity-cli/bin/verity.js, 29,594 lines (confirmed via wc -l; confirmed plain ASCII text via file, despite grep flagging it "binary" on a non-ASCII middle-dot byte elsewhere in the file — text-mode re-checks below are unaffected)

Evidence

1. Static analysis of the bundle. Node bundles calls as (0, obj.method)(...), so a plain grep for execSync( etc. undercounts; searching for the actual bundler calling convention gives an exact count:

grep -acE "\.execFileSync\)\(" bin/verity.js   -> 7
grep -acE "\.execFile\)\("      bin/verity.js  -> 1
grep -acE "\.execSync\)\("      bin/verity.js  -> 24
grep -acE "\.spawnSync\)\("     bin/verity.js  -> 3
grep -acE "\.spawn\)\("         bin/verity.js  -> 1
                                          total: 36 child_process invocation call sites

Of these 36, 27 directly invoke git (rev-parse, remote get-url, ls-files, rm, mv, diff, diff --no-index, log, show, merge-base, check-ignore, --version, add, diff --cached — spread across at least 13 distinct functions). The other 9 invoke: validate-patterns.mjs via process.execPath (2 sites), ripgrep (1 site, runRg), the installed static-analysis tool binary (1 site), npm rm -g (1 site), a generic tool installer used during self-heal (1 site), claude /verity-setup (1 site), and the CLI's own memory pull subcommand for background sync (1 site, see point 3 below) — plus one generic safeExec(cmd) helper whose call sites weren't individually enumerated.

Neither windowsHide nor CREATE_NO_WINDOW appears anywhere in the file (grep -na "windowsHide" / grep -na "CREATE_NO_WINDOW" both return zero matches). None of the 36 call sites set shell: either — the one shell: match in the file is an unrelated string label. This matters because execSync/exec unconditionally run through a shell on Windows (cmd.exe) regardless of any option — it cannot be turned off — so each of the 24 execSync-based git calls spawns a cmd.exe intermediary in addition to git.exe itself, where the 7 execFileSync-based ones invoke git.exe directly with no shell in between.

2. Live capture. Polled Win32_Process every 250ms for 12s spanning one Stop hook firing (node verity.mjs analyze, confirmed via .verity/.logs/cli.log timestamps). Observed:

  • The hook's own launch: claude.exe → cmd.exe → (conhost.exe, pwsh.exe), carrying the literal CLAUDE_CODE_SHELL_LAUNCHER_SCRIPT payload. This part is Claude Code's own Windows hook-launcher mechanism, not Verity's code — included here only as ground truth for where the chain starts.
  • 11 further conhost.exe processes created in the same ~7-second window: 2 with git.exe as the direct parent, the remaining 9 already had exited parents by the very next poll tick (≤250ms lifetime) — consistent with execSync's extra cmd.exe hop plus git.exe itself, both short-lived enough to often vanish between polls.

3. A second, architecturally distinct contributor. spawnBackgroundPull() (bin/verity.js, defined ~line 24972, called once from the SessionStart/"baseline capture" path when the session is authenticated) spawns node <cli> memory pull --quiet with detached: true, stdio: "ignore", then immediately calls child.unref(). This is the only call site in the file designed to intentionally outlive its parent and never be waited on or reaped. It also has no windowsHide. Because nothing ever tracks this child again, if its console isn't torn down when the underlying script exits, it has no mechanism to ever be cleaned up — architecturally the strongest single candidate for a console that persists for the rest of the session (matching "5 terminals still open" specifically) as opposed to the git-call volume, which better explains rapid flashing/disappearing. We could not retroactively confirm which mechanism produced the 5 orphans found in this session, since Windows does not retain a dead process's command line after it exits — both are plausible and both share the same missing option as their fix.

4. End-state observed this session. After a normal working session (session start, several turns, a couple of commits), 5 orphaned conhost.exe processes were found still resident: each had a parent PID that no longer resolved to any running process, and each had exactly one child of its own (another conhost.exe, itself with no further children) — i.e. genuinely inert, empty windows with nothing left running inside them. Confirmed safe to terminate; killing them had no effect on Verity's behavior in the same session afterward.

Root cause

On Windows, a console-subsystem process (cmd.exe, git.exe) that does not inherit a console gets a new one allocated unless CREATE_NO_WINDOW is set, which Node's child_process only does when windowsHide: true is passed. bin/verity.js never passes it, across all 36 spawn call sites. Verity's hooks fire on 6 lifecycle events per conversation (SessionStart, every UserPromptSubmit, every PreToolUse matching Bash, every Stop, PostCompact, SessionEnd), and several of those (at minimum analyze and guard) invoke multiple of the 27 git call sites per firing to compute changed files, branch, baseline, etc. Under that volume, plus the unconditional extra shell hop on the 24 execSync-based calls, some of the resulting short-lived consoles appear to race Windows' teardown and are left orphaned.

Suggested fix

  1. Add windowsHide: true to the child_process options at all 36 call sites (or centrally, if they route through shared helpers — several already do, e.g. execGit, safeExec, runGitSync/runGit).
  2. Independently of (1): the 24 execSync(gitCommandString) call sites could be converted to execFileSync("git", [...args]) the way the other 7 already are — this removes the unconditional cmd.exe intermediary Windows forces on execSync, cutting the per-call process count roughly in half for those sites, and also removes a class of shell-quoting concerns for any interpolated arguments (e.g. `git check-ignore -q -- "${path}"`, `git add -- "${keep}"`, `git ls-files --error-unmatch ${relPath}`).
  3. For spawnBackgroundPull specifically: detached + unref() background processes are exactly the case windowsHide exists for on Windows — worth verifying this one in particular actually cleans up its console on exit, since nothing else in the process ever checks on it again.

Impact

Cosmetic, not correctness — analysis results are unaffected; .verity/.logs/cli.log showed every hook run in this session completing normally (200-range HTTP statuses throughout). But it accumulates silently: a normal work session leaves several invisible, empty terminal windows behind with no UI indication of why, and no way for the user to tell them apart from something legitimately still running.

Activity

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