Repository navigation
Fix regression causing credential prompts to hide behind other windows more often - #2127
Conversation
Background GVFS processes such as GVFS.Mount.exe run with a hidden console window. When git launches the Git Credential Manager from such a process, the credential manager parents its interactive sign-in prompt to the hidden console. The prompt then has no foreground right and sinks behind other windows, so the user never sees it. Launch git detached from the console (CreateNoWindow=true) for commands that may require authentication when the current process has no visible console window. With no console, git starts the credential manager detached, so the credential manager parents its prompt to a topmost stub window instead of the hidden console. A process with a visible console window (e.g. gvfs.exe run from a classic console) keeps the current behavior, so the credential manager parents the prompt to that window. Detection keys on the console window, not an arbitrary GUI window. A process whose console is hidden, or a non-displayed pseudo-console such as a foreground gvfs.exe run from Windows Terminal, is treated as having no visible window and also detaches git. For the Git Credential Manager this is equivalent or better, because the detached prompt is topmost instead of owned by a hidden host window. Add GVFSPlatform.CurrentProcessHasVisibleConsoleWindow. The Windows implementation checks GetAncestor(GetConsoleWindow(), GA_ROOTOWNER) and IsWindowVisible. Add a mayRequireAuth option to GitProcess.GetGitProcess, which detaches git from a hidden console only when set. Only credential fill can trigger an interactive prompt, so only the url and certificate fills set mayRequireAuth. Credential approve and reject store or erase an already-obtained credential and never prompt, so they keep the default. Every other git command GVFS runs is local and keeps the default, so the per-child conhost cost stays avoided for every non-authenticating call. Log hasVisibleConsoleWindow from TryGetCredential so a credential fill that does not surface a prompt stays diagnosable from telemetry. Guard the native console probe so a binding failure keeps the previous behavior instead of aborting the fill. Add a certificate-fill test for the mayRequireAuth wiring. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Thanks for chasing this down — the background-mount case is correctly detected and the fix does address the reported bug. Two things I would like to resolve before it merges, both stemming from one claim in the commit message.
The Windows Terminal claim does not match the code's actual behavior
The commit message states that "a non-displayed pseudo-console such as a foreground gvfs.exe run from Windows Terminal, is treated as having no visible window and also detaches git." That is not what happens. Under a ConPTY, GetAncestor(GetConsoleWindow(), GA_ROOTOWNER) walks from the PseudoConsoleWindow up to the hosting CASCADIA_HOSTING_WINDOW_CLASS window, which is visible — so CurrentProcessHasVisibleConsoleWindow() returns true and git is not detached there.
I measured all three cases on Windows 11, replicating the exact probe logic from this PR (GetConsoleWindow -> GetAncestor(..., GA_ROOTOWNER) -> IsWindowVisible):
| scenario | GetConsoleWindow() |
GA_ROOTOWNER |
probe |
|---|---|---|---|
Start-Process -WindowStyle Normal |
PseudoConsoleWindow (visible) |
WindowsTerminal / CASCADIA_HOSTING_WINDOW_CLASS (visible) |
True |
| hidden console (Mount-like) | ConsoleWindowClass (hidden) |
self (hidden) | False |
| Windows Terminal tab | PseudoConsoleWindow (visible) |
WindowsTerminal / CASCADIA_HOSTING_WINDOW_CLASS (visible) |
True |
Note the first row: Windows Terminal is the default terminal host on Windows 11, so even -WindowStyle Normal is hosted by it. The classic-conhost branch the description leans on is rarer than it implies, and the only case that actually yields False is a genuinely hidden ConsoleWindowClass — which is to say GVFS.Mount, exactly the bug being fixed.
Two consequences:
-
The permanent record is wrong about the most common interactive path. Worth correcting the commit message and PR description before merge, since the XML doc on
GVFSPlatform.CurrentProcessHasVisibleConsoleWindowis accurate and the two will otherwise disagree. -
The non-detached ConPTY path looks unevaluated. Because the description assumed Windows Terminal detaches, that branch was presumably not analyzed. There, git and GCM inherit the pseudo-console, and GCM parents its prompt to whatever it derives from
GetConsoleWindow()— which is a zero-size, off-screenPseudoConsoleWindowthat nonetheless reportsIsWindowVisible == true. If GCM does not perform its ownGA_ROOTOWNERwalk, the prompt ends up owned by a window the user is not looking at: the same class of problem this PR is fixing, for interactivegvfs clone/gvfs mountunder the default Windows 11 terminal.Could you confirm what GCM actually parents its prompt to in the ConPTY case? This is not a regression from this change —
CreateNoWindowis unchanged on that path — but it determines whether the fix is complete or only covers the service scenario.
I am happy to share the probe script if it is useful for verifying the ConPTY question.
…og it on failure Address review feedback on the credential-prompt console-detach change. - Probe CurrentProcessHasVisibleConsoleWindow once per credential-fill call and thread the value through InvokeGitAgainstDotGitFolder, InvokeGitImpl, and GetGitProcess so the logged value and the CreateNoWindow decision are the same probe. GetGitProcess treats a supplied value as authoritative and only probes when none is passed. - Log hasVisibleConsoleWindow on the credential-fill failure path (including the timeout a hidden, unanswered prompt produces), not only on success. The success-path metadata and the certificate-fill success and failure paths log the same value. - Remove the misnamed intermediate local in GetGitProcess; compute CreateNoWindow directly so the && short-circuits the probe when auth cannot be required. - Restore the original pre-command-hook comment wording. - MockPlatform gains ThrowOnConsoleProbe to simulate a native probe that cannot run; add tests for the safe fallback, the caller-supplied authoritative value, and console-visibility telemetry on the URL and certificate credential-fill failure paths. The telemetry tests assert the logged value, not just the key. - MockGitProcess records each git invocation (command, mayRequireAuth, and hasVisibleConsoleWindow) instead of keeping only the last value per command, so the wiring assertions cannot be fooled if a command runs twice with different flags. The failure-telemetry tests also assert the probed console-visibility value threads down to the git invocation. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Nathan Baird (ShiningMassXAcc)
left a comment
There was a problem hiding this comment.
Re-review of 58edd7c8. I checked each of the eight inline points against the code rather than against the replies — all eight are genuinely fixed, and a few go further than I asked.
| # | Original point | Verified |
|---|---|---|
| 1 (High) | hasVisibleConsoleWindow was logged only after the ExitCodeIsFailure early return, so it never fired on the timeout path it was added for |
Probe hoisted above the invoke in both fills, and errorData is built before the Operation timed out branch, so both failure branches carry it |
| 2 | Success-path log re-probed, so telemetry could diverge from the value GetGitProcess acted on |
Probed once and threaded through InvokeGitAgainstDotGitFolder → InvokeGitImpl → GetGitProcess; the second P/Invoke is gone |
| 3 | TryGetCertificatePassword is mayRequireAuth: true with no logging |
Logged on both paths, with a test |
| 4 | The "untestable" catch branch is reachable via MockPlatform |
ThrowOnConsoleProbe added, and the test asserts CreateNoWindow == false when the probe throws |
| 5 | hasVisibleConsoleWindow intermediate was untrue at its point of use |
Removed |
| 6 | Pre-existing comment rewrapped at two sites | Reverted |
| 7 | Three LF→CRLF lines in MockPlatform.cs |
Gone — --numstat and --numstat --ignore-all-space are now byte-identical across all six files |
| 8 | MockGitProcess was last-write-wins per command string |
InvocationsRun records every call, and MayRequireAuthFor asserts repeated runs agree |
Beyond the asks: the test proving a caller-supplied false forces detach without probing the platform is a good addition, and the "Known gaps" section now matches the code.
I built and ran the unit suite at 58edd7c8 locally — 985 passed, 0 failed (the 11 skips are the native-hook tests that need the C++ build). All seven new tests pass in both fixture parameterizations, and CI is green across 35 checks.
That leaves the two points from my review body, which were the blocking part of the last pass.
1. The commit message still carries the claim I measured as incorrect
You corrected the PR description — its probe bullet now accurately says the check keys on the console window and its root owner. But a6c33d23 was not amended, so its message body still reads:
Detection keys on the console window, not an arbitrary GUI window. A process whose console is hidden, or a non-displayed pseudo-console such as a foreground gvfs.exe run from Windows Terminal, is treated as having no visible window and also detaches git.
Measured on Windows 11, a foreground gvfs.exe under Windows Terminal is not detached. GetConsoleWindow() returns a PseudoConsoleWindow, and the GA_ROOTOWNER walk the probe performs resolves to the visible CASCADIA_HOSTING_WINDOW_CLASS host window, so IsWindowVisible returns true and CreateNoWindow stays false:
| Scenario | GetConsoleWindow() |
GA_ROOTOWNER |
Probe |
|---|---|---|---|
Start-Process -WindowStyle Normal |
PseudoConsoleWindow (visible) |
CASCADIA_HOSTING_WINDOW_CLASS (visible) |
true |
| Windows Terminal tab | PseudoConsoleWindow (visible) |
CASCADIA_HOSTING_WINDOW_CLASS (visible) |
true |
| Hidden console (Mount-like) | ConsoleWindowClass (hidden) |
self (hidden) | false |
The only false case is a genuinely hidden ConsoleWindowClass — the GVFS.Mount case this PR fixes. Since Windows Terminal is the default host on Windows 11, that paragraph describes the most common interactive path and gets it backwards. The code and its XML doc are correct; only this paragraph is wrong.
This is worth an amend rather than letting it ride, because this repo merges with merge commits rather than squashing, so a6c33d23's body lands on master verbatim and is what git log and git blame will surface. Someone reading it later would conclude the Windows Terminal path is already handled by detaching, and could build on that.
2. Withdrawing my ConPTY question as blocking
I asked what the credential manager parents its prompt to on the non-detached pseudo-console path. Revisiting it: I have no measurement showing an actual defect there, it is not a regression from this change (CreateNoWindow is unchanged on that path), and the reports motivating this fix are all the hidden-mount case. A foreground terminal run also holds foreground rights, which is the real reason the hidden-mount case fails and an interactive run does not. So I am not holding the PR on it — noting it only as something to revisit if interactive-clone prompt reports ever appear.
Approving on that basis: the change is correct, well covered by tests, and green. Please fold the commit-message correction in before merging.
Problem and Context
Background GVFS processes such as
GVFS.Mount.exerun with a hidden console window. When git launches the Git Credential Manager (GCM) from such a process, GCM parents its interactive sign-in prompt to the hidden console. The prompt then has no foreground right and sinks behind other windows, so the user never sees it and the mount appears to hang waiting for credentials that were never visibly requested.This is a recent regression. The change that set
CreateNoWindow=falseto eliminate the per-childconhost.exeallocation (commit6c01575f, "Eliminate conhost.exe from git.exe and hooks process launches") also stopped detaching git from the parent's hidden console. Before that change, auth-capable git commands ran withCreateNoWindow=true, which gave GCM no console to parent to, so the prompt surfaced on top. Since the change, background mounts inherit the hidden console and the prompt is hidden behind other windows. We have since seen an uptick in both user reports and telemetry of mounts stuck in this hidden-prompt state.Those indefinite waits are also the direct cause of the auth-timeout and contention work in #2082 — a hidden prompt that no one answers holds the shared prefetch lock and HTTP capacity until it times out. This PR addresses the root cause by making the prompt visible again for the two commands that can raise it; #2082 bounds the blast radius for the case where a prompt still goes unanswered.
Changes
GVFSPlatform.CurrentProcessHasVisibleConsoleWindow(). The Windows implementation checksGetAncestor(GetConsoleWindow(), GA_ROOTOWNER)andIsWindowVisible— it keys on the console window and its root owner, not an arbitrary GUI window.mayRequireAuthparameter toGitProcess.GetGitProcess. When set and the current process has no visible console window, git is launched detached from the console (CreateNoWindow=true). GCM then parents its prompt to a topmost stub window instead of the hidden console. A process with a visible console (e.g.gvfs.exerun from a classic console) keeps the current behavior so the prompt parents to that window.mayRequireAuth: trueto exactly the twocredential fillsites (TryGetCredentialurl fill andTryGetCertificatePasswordcert fill).credential approve/rejectand every other git command GVFS runs keep the defaultfalse, so the per-childconhostcost eliminated in6c01575fstays avoided for every non-authenticating call. See the verb audit below.hasVisibleConsoleWindowfromTryGetCredentialso a credential fill that does not surface a prompt stays diagnosable from telemetry.DllNotFoundException/EntryPointNotFoundException) so a binding failure keeps the previous behavior (CreateNoWindow=false) instead of aborting the fill.credential fill(url and cert) is flaggedmayRequireAuth, plus a directGetGitProcesstruth-table test for the detach formula across console-visibility andmayRequireAuthcombinations.Why only
credential fillis flagged — GetGitProcess verb auditgit credential fillis the only command run throughGitProcess.GetGitProcessthat can raise an interactive GCM prompt.GetGitProcessalso setsGIT_TERMINAL_PROMPT=0andGCM_VALIDATE=0unconditionally on every spawned process, so no git verb can raise a terminal prompt; the GUI GCM prompt is the only possibility, and onlyfilltriggers it. Nofetch/pull/push/clone/ls-remoteruns throughGitProcess— GVFS transfers objects through its own read-object → named-pipe → HTTP protocol, not git transport.GetGitProcess)credential fill(repo url)TryGetCredentialget(interactive)mayRequireAuth: truecredential fill(protocol=cert)TryGetCertificatePasswordget(interactive)mayRequireAuth: truecredential approveTryStoreCredentialstorecredential rejectTryDeleteCredentialeraseinit,--version,config(all forms),rev-parse,name-rev,diff-tree,branch,checkout -f,reset,status,diff --cached,checkout-index,unpack-objects,pack-objects,commit-graph write/verify,index-pack,multi-pack-index write/verify/expire/repack,verify-pack,remote add,cat-file,ls-tree,ls-files,symbolic-ref,update-ref,read-tree,prune-packedremote addwrites local config)Some local verbs run with the read-object hook enabled; a missing object can make that hook ask GVFS to hydrate over HTTP, and if that path needs credentials GVFS starts a separate
credential fill— itself covered above.GVFS.Hooksspawns git viaProcessHelper.Run(notGetGitProcess) for local verbs that do not use GCM.Conclusion: wiring
mayRequireAuth: trueto the twocredential fillsites covers every path that can surface a hidden prompt.Known gaps and deliberate decisions
ParseValue) — a missing prefix is not guarded for-1, so malformedcredential filloutput can mis-parse. Pre-existing; not touched here. No follow-up planned yet.CreateNoWindowdecision, so the two cannot diverge. This adds a nullable parameter to three methods; a deliberate choice over re-probing at the decision site. No follow-up planned.activity.Stopdiscards its metadata, so only the failure path is asserted for the certificate flow. The runtime stamps the same value on both paths. Follow-up possible if the mock gains Stop-metadata capture.executingProcessassignment races under concurrency — the field is assigned before the lock inInvokeGitImpl, so concurrent invocations can race. Pre-existing; credential fill is effectively serialized today. Addressed by a separate follow-up change.