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
- 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).
- 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}`).
- 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.
Title
Windows: hook invocations leak orphaned
conhost.exewindows (nowindowsHideon internalgitsubprocess spawns; several go throughexecSync's implicit shell unnecessarily)Summary
On Windows, a single Verity hook firing (e.g. the
Stophook'sverity analyze) spawns a dozen or more OS console-host windows internally.@codacy/verity-cli/bin/verity.jsshells out togitat 27 separate call sites, none of which passwindowsHide: true. 24 of those 27 go throughexecSyncwith a command string, which unconditionally runs throughcmd.exeon Windows (an extra shell process per call that the other 7 call sites, which already useexecFileSync("git", [args]), avoid). Under the resulting volume, some of these short-lived consoles fail to tear down cleanly and are left behind as empty, invisibleconhost.exeprocesses that accumulate over a session.Environment
pwsh.exe)@codacy/verity-clibundle:node_modules/@codacy/verity-cli/bin/verity.js, 29,594 lines (confirmed viawc -l; confirmed plain ASCII text viafile, despitegrepflagging 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 plaingrepforexecSync(etc. undercounts; searching for the actual bundler calling convention gives an exact count: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.mjsviaprocess.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 ownmemory pullsubcommand for background sync (1 site, see point 3 below) — plus one genericsafeExec(cmd)helper whose call sites weren't individually enumerated.Neither
windowsHidenorCREATE_NO_WINDOWappears anywhere in the file (grep -na "windowsHide"/grep -na "CREATE_NO_WINDOW"both return zero matches). None of the 36 call sites setshell:either — the oneshell:match in the file is an unrelated string label. This matters becauseexecSync/execunconditionally run through a shell on Windows (cmd.exe) regardless of any option — it cannot be turned off — so each of the 24execSync-based git calls spawns acmd.exeintermediary in addition togit.exeitself, where the 7execFileSync-based ones invokegit.exedirectly with no shell in between.2. Live capture. Polled
Win32_Processevery 250ms for 12s spanning oneStophook firing (node verity.mjs analyze, confirmed via.verity/.logs/cli.logtimestamps). Observed:claude.exe→cmd.exe→ (conhost.exe,pwsh.exe), carrying the literalCLAUDE_CODE_SHELL_LAUNCHER_SCRIPTpayload. 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.conhost.exeprocesses created in the same ~7-second window: 2 withgit.exeas the direct parent, the remaining 9 already had exited parents by the very next poll tick (≤250ms lifetime) — consistent withexecSync's extracmd.exehop plusgit.exeitself, 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 theSessionStart/"baseline capture" path when the session is authenticated) spawnsnode <cli> memory pull --quietwithdetached: true, stdio: "ignore", then immediately callschild.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 nowindowsHide. 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.exeprocesses 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 (anotherconhost.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 unlessCREATE_NO_WINDOWis set, which Node'schild_processonly does whenwindowsHide: trueis passed.bin/verity.jsnever passes it, across all 36 spawn call sites. Verity's hooks fire on 6 lifecycle events per conversation (SessionStart, everyUserPromptSubmit, everyPreToolUsematchingBash, everyStop,PostCompact,SessionEnd), and several of those (at minimumanalyzeandguard) 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 24execSync-based calls, some of the resulting short-lived consoles appear to race Windows' teardown and are left orphaned.Suggested fix
windowsHide: trueto thechild_processoptions at all 36 call sites (or centrally, if they route through shared helpers — several already do, e.g.execGit,safeExec,runGitSync/runGit).execSync(gitCommandString)call sites could be converted toexecFileSync("git", [...args])the way the other 7 already are — this removes the unconditionalcmd.exeintermediary Windows forces onexecSync, 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}`).spawnBackgroundPullspecifically:detached+unref()background processes are exactly the casewindowsHideexists 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.logshowed 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.