Skip to content

Harden orphan-lock detection against false-release on transient failures - #2060

Merged
tyrielv merged 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/orphan-lock-riskreduction
Jul 14, 2026
Merged

Harden orphan-lock detection against false-release on transient failures#2060
tyrielv merged 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/orphan-lock-riskreduction

Conversation

@tyrielv

@tyrielv tyrielv commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Risk-reduction hardening for the orphan-lock detection change merged in #1989, ahead of the GVFS 2.0 stabilization release. #1989 was flagged 🔴 (highest "worse-regression-than-it-fixes" tier) because it is a concurrency fix; this PR removes the one dangerous failure mode it introduced while keeping the fix it delivered.

This does not reopen #1989 (already merged) — it is a focused follow-up off current master.

The regression being removed

#1989 captures the lock holder's process start time at acquire and compares it at each orphan check to distinguish the real holder from an unrelated process that recycled its PID. Correct for the recycle case — but it collapsed every failure to read the start time into a single bool false, which the caller treats as "holder is gone → release the lock."

The dangerous path is a transient OpenProcess(QUERY_LIMITED_INFORMATION) failure (e.g. momentary resource pressure) for a holder that is still alive: the merged code releases the lock, admitting a second writer to the index. That is a corruption-class failure — strictly worse than the flaky hang #1989 fixed.

Note the pre-#1989 code did not have this exposure: its liveness check fell back to Process.GetProcessById when OpenProcess failed. #1989 dropped that fallback on the identity path. This PR restores "never release without positive evidence" without reintroducing the identity-blind GetProcessById check (which would defeat #1989's recycle detection).

The change

TryGetActiveProcessStartTime now returns a ProcessStartTimeResult enum instead of bool:

Result Windows mapping Orphan-check action
Success (time == captured) opened, still active, same creation time Hold (same holder)
Success (time != captured) opened, still active, different creation time ReleasePidRecycled (the #1989 case)
ProcessNotFound ERROR_INVALID_PARAMETER, or opened-but-exited (exit ≠ STILL_ACTIVE) Release — positive evidence gone
Inaccessible ERROR_ACCESS_DENIED Release — see gate argument
Indeterminate any other error (ERROR_NOT_ENOUGH_MEMORY, ERROR_NO_SYSTEM_RESOURCES, …) Hold + telemetry — we don't know, so we never false-release

The Win32 error is read via Marshal.GetLastWin32Error() (the P/Invokes are already SetLastError = true).

Why Inaccessible → Release is safe (acquire-time gate)

We only enter the identity-check path for a holder whose start time we successfully read at acquire — i.e. one this mount could OpenProcess. OpenProcess access is a stable function of the caller token and the target's protection level / DACL, and git/hooks never rewrite their own DACL. So a live original holder we could open before cannot later become inaccessible; an access-denied result means the PID now refers to a different process → releasing is correct. The pre-existing null-start-time fallback (when we couldn't read start time at acquire) is unchanged.

Blast radius

Telemetry

The Indeterminate hold emits ExternalHolderLivenessIndeterminate (Verbose) so we can measure whether these transient failures ever occur in the field.

Tests

Adds unit coverage for the new outcomes and keeps the existing identity tests:

  • WhenHolderProcessNotFound → released
  • WhenHolderInaccessible → released (gated-recycle)
  • WhenHolderIndeterminateKeepsLockheld (the anti-false-release guard)
  • retained: WhenHolderPidRecycled, WhenHolderStartTimeMatches

Validation: dotnet build GVFS.UnitTests clean; full GVFS.UnitTests suite 885 passed, 0 failed (11 skipped by category); all 5 identity tests pass.

Assisted-by: Claude Opus 4.7

PR microsoft#1989 fixed a PID-recycling race in orphan-lock detection by capturing the
holder's process start time at acquire and comparing it at each orphan check.
That fix is correct for the recycling case, but it collapsed every failure to
read the start time into a single bool "false" that the caller treats as "the
holder is gone" and releases the lock.

The dangerous consequence is on the transient-failure path. The Windows
implementation returns false the moment OpenProcess(QUERY_LIMITED_INFORMATION)
yields an invalid handle. Under momentary resource pressure that OpenProcess can
fail for a process that is genuinely still alive, so the orphan check can release
a lock that is still legitimately held -- admitting a second writer to the index.
That is a worse failure class (potential corruption) than the flaky hang microsoft#1989
set out to fix, which is exactly the kind of regression a stabilization release
must not ship. (Note the pre-microsoft#1989 code did not have this exposure: its liveness
check fell back to Process.GetProcessById when OpenProcess failed; microsoft#1989 dropped
that fallback on the identity path.)

This change makes the release decision reason-aware so that a lock is only
released on positive evidence that the holder is gone:

  * TryGetActiveProcessStartTime now returns a ProcessStartTimeResult enum
    (Success / ProcessNotFound / Inaccessible / Indeterminate) instead of bool.
    The Windows implementation classifies an invalid OpenProcess handle by the
    Win32 error: ERROR_INVALID_PARAMETER -> ProcessNotFound (no such PID),
    ERROR_ACCESS_DENIED -> Inaccessible, anything else (e.g.
    ERROR_NOT_ENOUGH_MEMORY / ERROR_NO_SYSTEM_RESOURCES) -> Indeterminate. An
    opened-but-exited process (exit code != STILL_ACTIVE) maps to ProcessNotFound.

  * The orphan check in GVFSLock releases the lock only for:
      - Success with a different start time  (PID recycled -- the microsoft#1989 case)
      - ProcessNotFound                       (positive: holder is gone)
      - Inaccessible                          (see gate argument below)
    and deliberately KEEPS the lock for Indeterminate, letting the existing
    250 ms wait-loop poll re-evaluate. A transient read failure can no longer
    release a live holder's lock.

  * Inaccessible is safe to treat as "holder gone" because of an acquire-time
    gate: we only enter the identity-check path for a holder whose start time we
    successfully read at acquire, i.e. one this mount could open. OpenProcess
    access is a stable function of the caller token and the target's protection
    level / DACL (git and the hooks never rewrite their own DACL), so a live
    original holder we could open before cannot later become inaccessible. An
    access-denied result therefore means the PID now refers to a different
    process, so releasing is correct. The pre-existing null-start-time fallback
    (used when we could not read the start time at acquire) is unchanged.

Telemetry: the Indeterminate hold emits an ExternalHolderLivenessIndeterminate
event so we can measure whether these transient failures ever occur in the field.

This is in-memory mount-side state only; the named-pipe lock protocol is
unchanged, so there is no cross-version or wire-format impact.

Tests: adds unit coverage for the new outcomes -- ProcessNotFound and
Inaccessible each release the orphaned lock, and Indeterminate keeps it (the
anti-false-release guard). Existing PID-recycle and start-time-match tests are
retained. Full GVFS.UnitTests suite passes (885 passed, 0 failed).

Assisted-by: Claude Opus 4.7
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
@tyrielv
tyrielv marked this pull request as ready for review July 10, 2026 17:56
@tyrielv
tyrielv enabled auto-merge July 10, 2026 17:58
@tyrielv
tyrielv merged commit 301e73a into microsoft:master Jul 14, 2026
68 of 69 checks passed
@tyrielv tyrielv mentioned this pull request Jul 15, 2026
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.

2 participants