Skip to content

Protect GitProcess.executingProcess with a single lock protocol - #2132

Draft
Tyrie Vella (tyrielv) wants to merge 3 commits into
microsoft:masterfrom
tyrielv:tyrielv/fix-executingprocess-race
Draft

Tyrie Vella (tyrielv) wants to merge 3 commits into
microsoft:masterfrom
tyrielv:tyrielv/fix-executingprocess-race

Conversation

@tyrielv

Copy link
Copy Markdown
Contributor

Problem and Context

GitProcess.InvokeGitImpl and TryKillRunningProcess share the instance field executingProcess, but they do not agree on how it is locked:

  • The field is written with no lock held — at the using assignment that creates the process, and at the finally that sets it back to null.
  • The field is read in TryKillRunningProcess under processLock.
  • Only the Start() call sits inside processLock.

This inconsistent protocol has two consequences:

  1. Two concurrent InvokeGitImpl calls on the same GitProcess instance clobber each other's executingProcess. A later call's lockless assignment (or the = null cleanup) can hide or orphan the other call's process, so TryKillRunningProcess kills the wrong process or a not-yet-started one. The using of one call can also dispose the Process that the other call still references.
  2. Even with a single invocation, a concurrent TryKillRunningProcess has a visibility and ordering hole: the kill reads under processLock, but the write is lockless, so the kill can observe a stale or null field.

This race is pre-existing and not introduced by any recent change. Credential fill is effectively serialized today, so it is latent, but general git invocations are not guaranteed to be serialized per instance.

Changes

  • Make the executingProcess field obey one consistent protocol: it is published and cleared only under processLock, and only while the invocation holds executionLock. The field is assigned just before Start() inside processLock, and cleared in a finally under processLock before executionLock is released, so a queued invocation cannot clobber it.
  • Keep the Process in a local variable for the using block, so disposal always targets exactly the instance this invocation created, never one another invocation shares.
  • Make GetGitProcess virtual to provide a seam the unit test uses to substitute a controllable process.
  • Add a deterministic regression test (ConcurrentInvocation_DoesNotClobberExecutingProcessField) that runs two InvokeGitImpl calls on one instance, orders them so the second queues on executionLock, and asserts that TryKillRunningProcess observes the running process rather than the queued one. The test fails against the old lockless protocol and passes with the fix.

Stacking

This change is stacked on #2127, which also edits GitProcess.cs (including the InvokeGitImpl region). It must merge after #2127. Until #2127 merges, this PR's diff includes #2127's commits; once #2127 merges to master, the diff reduces to only the race fix. After that, the branch will be rebased onto master.

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>
…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>
InvokeGitImpl wrote the shared executingProcess field outside any lock (at the
using assignment and the finally cleanup), while TryKillRunningProcess reads it
under processLock and only Start() was wrapped in processLock. The inconsistent
protocol let a concurrent invocation on the same instance clobber the field, so
a kill could target a not-yet-started or orphaned process, and the using of one
invocation could dispose the process another still referenced.

Publish and clear the field only under processLock, and only while the
invocation holds executionLock. Keep the Process in a local so the using block
disposes exactly the instance this invocation created.

Add a deterministic regression test that runs two InvokeGitImpl calls on one
instance and asserts TryKillRunningProcess observes the running process.

Assisted-by: Claude Opus 4.8
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
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.

1 participant