This is the one fact from building PresenceLock that I would least like to learn twice. It cost more than every logic bug in the project combined, and the fix that finally stuck was not a workaround but a deletion.

The symptom

PresenceLock keeps a webcam open through Windows.Media.Capture.MediaCapture and a MediaFrameReader, and classifies frames with the built-in FaceDetector. Early versions did the obvious thing when the camera misbehaved: stop the reader, dispose the capture, wait a moment, create a new one. The same code path ran when the session unlocked, on the theory that a camera released during lock should be reacquired fresh.

After a few lock and unlock cycles, MediaCapture.InitializeAsync started failing with E_HANDLE (0x80070006, “the handle is invalid”), and kept failing. Every retry in the same process failed the same way. In the milder case, the process’s connection to the camera service was what had wedged, and a fresh process recovered. In the worse case, the Camera Frame Server service itself was wedged, machine-wide, and stayed that way until it was restarted with elevation:

Restart-Service FrameServer

That is the case that matters. The damage lived in a system service and survived the death of the process that caused it.

What I know and what I don’t

I do not have a root cause inside the Frame Server, and I am not going to pretend to. What I have is a pattern that held across every occurrence:

  • Every observed wedge followed an in-process teardown and re-init. A process that initialized the camera once and never touched the pipeline again never wedged it, however long it ran.
  • Every observed wedge followed a re-init triggered from the session-switch handler. SystemEvents.SessionSwitch fires on its own broadcast thread, and MediaCapture wants to be initialized on the STA thread. The shell now marshals that entire handler onto the UI thread before it touches anything, as a second line of defense.
  • A fresh process’s first init is reliable on the affected hardware, as long as the service itself has not been wedged.
  • Hard-disposing an actively streaming reader was a suspected trigger on its own, so the teardown code stopped the reader gracefully before disposing. The wedges continued regardless, which is part of why the whole path went.

The classifier the shell uses is deliberately loose. It matches on the HResult or on the message substring, because the WinRT projection wraps the exception and does not always preserve E_HANDLE:

internal static bool IsHandleInvalid(int hresult, string message) =>
    hresult == unchecked((int)0x80070006) ||
    (message is not null &&
     message.Contains("handle is invalid", StringComparison.OrdinalIgnoreCase));

The rule

Initialize the camera once per process. Never re-initialize in-process. If the pipeline needs to be rebuilt, for any reason, exit and start a new process.

That covers more cases than it sounds like. The core has three restart reasons, and the shell has one of its own:

  • CameraWedged: a live capture died mid-session, or startup failed with E_HANDLE enough times in a row.
  • CameraReevaluation: the signal has been bad for long enough that a different device should be tried.
  • CameraUpgrade: a preferred camera was plugged in after startup.
  • Settings change: the user picked a different camera. This one never reaches the core, because it is a string comparison, not policy.

All four used to be a teardown and re-init. All four are now a process restart.

Making the rule structural

For a while the rule was a comment and a discipline, and the two in-process paths that still violated it were carried along “to be deleted later”. A rule that depends on being remembered is one refactor away from being violated.

The fix was to delete RestartWatchingAsync and TeardownCameraAsync outright and gate every remaining call to camera initialization on there being no reader yet. Acquisition is now reachable from exactly two places: process start, and the retry timer that runs before the first successful init. Nothing after a successful init can reach it. The invariant is in the architecture doc, but the doc is not what enforces it. The absence of the code is.

The decision of when to restart moved into the pure F# core described in the companion post . The core emits a single Action.Restart carrying a reason, and the shell executes it. The reason changes the log line and nothing else. The mechanism is the same every time.

Making the restart safe

Restart-as-recovery has its own failure mode: a crash loop. Three things keep it bounded.

  • Cooldowns on the wall clock, persisted across the restart. Each restart reason has its own wall-clock stamp, written to a small JSON file before the process exits and read back by the new one. The core will not request the same kind of restart again inside the cooldown, ten minutes at the defaults. These are wall-clock rather than monotonic on purpose. A wedged camera does not get better after a reboot, so the cooldown has to survive one.
  • A threshold before the first restart. Before the first successful init, a failed init only counts toward a restart when the streak has reached three and the failure that tipped it was handle-invalid. Below that, retrying in the same process is empirically safe, because nothing has been torn down yet. After a successful init, any capture failure requests a restart on its first occurrence, because the only alternative would be a re-init.
  • A backstop with no new mechanism. If a capture dies while its cooldown is still running, the shell drops the dead reader, keeps sampling, and feeds the core “no frame” on every tick. The ordinary no-signal handling then surfaces a status and, when its own cooldown permits, requests the other kind of restart. Worst case under continuous failure is one restart per reason per cooldown window: at most three restarts every ten minutes at the defaults, plus whatever the user triggers from settings.

The restart itself is mundane: stop the timers, write the stamps and the pause state, release the single-instance mutex, start the same executable, exit. The new process reads its stamps, finds it is inside a cooldown or not, and initializes the camera for the first and only time.

The price

The rule has one consequence every user notices. The camera is initialized once and never released until the process exits, so it stays open while your session is locked and while the tool is paused. Sampling stops in both states, and no frame is looked at, but the device is held, and on most webcams that means the indicator light stays on at the lock screen. Releasing the camera on lock and reacquiring it on unlock is exactly the teardown and re-init that wedges the service, and it was the session-switch handler that produced every wedge I saw. Holding the camera is the lesser evil. The live smoke test for every release includes a lock and unlock cycle that confirms the camera stayed alive across it.

If you are here because of E_HANDLE

If you have a MediaCapture app that works until it doesn’t, and the failure is 0x80070006 on InitializeAsync, and restarting your app does not help, the question to ask is not whether you ever hold two instances at once. It is whether your code ever disposes a MediaCapture and then initializes another one in the same process, sequentially or otherwise. Stopping the reader gracefully before the dispose did not help here. If the answer is yes, restarting the service clears the current wedge, and removing the re-init stops the next one.

Code is at github.com/coreyleavitt/presence-lock . The invariant and the reasoning are in ARCHITECTURE.md, and the recovery policy is specified in docs/rfc/0001-core-brain.md.