From 3d55aedb7e3aa43859c464742e26e12852064a3f Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 22 May 2026 13:39:03 +0200 Subject: [PATCH] fix(agent/v2): defer RealmJoin package-watcher arming until DeploymentPhase >= 100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the HKLM\SOFTWARE\RealmJoin (+ HKU\\...) subtree watchers attached the moment the Services\realmjoin gate fired. The window between "RJ installed" and "RJ actually deploying" is exactly when IME may still be writing to the same SOFTWARE\RealmJoin subtree as part of unrelated package installs — those writes were being misattributed to RJ's package lifecycle (realmjoin_package_started / _completed events for packages RJ never deployed). Defer the package watchers until RJ has moved out of Blank, i.e. DeploymentPhase >= PhaseRunningThresholdMin (100 = RunningFirstDeployment, RunningFirstDeploymentAuto, RunningDeployment, ManualDeployment, NestedFromShim, or terminal CompletedFirstDeployment). The realmjoin_detected event still fires on service-key appearance (no change to the detection gate); only the package-tracking surface stays disarmed for the pre-running window. - New const RealmJoinInfo.PhaseRunningThresholdMin = 100. - Watcher state flag _packageWatchersArmed (set-once); old _realmJoinServiceDetected removed (no remaining readers). - AttachServiceRealmjoinSubtreeWatcher no longer arms the package watchers — it only attaches the Services\realmjoin DeploymentPhase watcher and fires Detected. - NotifyRealmJoinPresence arms HKLM + (if SID known) HKU package watchers on the first phase >= 100 observation; set-once so a later regressed reading does not disarm. - ArmHku now defers on _packageWatchersArmed instead of the dropped service-detected flag. Tests: +3 (disarmed-during-blank, set-once-after-running, immediate-arm-on-boot-at-110). 14/14 RealmJoin adapter suite green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RealmJoinWatcherAdapterTests.cs | 58 ++++++++++++++++ .../Enrollment/SystemSignals/RealmJoinInfo.cs | 8 +++ .../SystemSignals/RealmJoinWatcher.cs | 67 +++++++++++++------ 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs index 4ad27ab0..5d9ec9a8 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -224,6 +224,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Equal("0", info.Payload["lastExitCode"]); } + [Fact] + public void Package_watchers_stay_disarmed_until_phase_reaches_RunningThreshold() + { + // Pre-RJ window: IME may still be writing to HKLM\SOFTWARE\RealmJoin while the + // RJ service key is already present but DeploymentPhase is still Blank (0). + // Package watchers must NOT arm yet — otherwise IME's in-flight package writes + // would be misattributed to RJ's lifecycle. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + // Service-appearance fires Detected without a phase value — must NOT arm. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: null); + Assert.False(f.Watcher.PackageWatchersArmedForTest); + + // Phase = 0 (Blank) — still no arm; RJ is installed but not deploying. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.False(f.Watcher.PackageWatchersArmedForTest); + + // Phase transitions to 100 (RunningFirstDeployment) — package watchers MUST arm now. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 100); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + } + + [Fact] + public void Package_watchers_stay_armed_once_phase_subsequently_drops_back_below_threshold() + { + // Set-once semantics — even if a later phase reading were below threshold + // (defensive against partial / racy registry writes), the watcher does not + // disarm and re-introduce a window where post-running events could be lost. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 100); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + + // Hypothetical regression: phase reads as 0 again — must not flip the flag. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + } + + [Fact] + public void Package_watchers_arm_immediately_when_first_phase_observed_is_completed() + { + // Agent boots into a session where RJ is already at CompletedFirstDeployment + // (110) — e.g. pre-installed on an image, or recovered after a long absence. + // The first phase observation is already past threshold, so package watchers + // arm in that same notify pass; the historic package rows enumerated by + // CheckMachinePackages then surface as started+completed pairs. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 110); + + Assert.True(f.Watcher.PackageWatchersArmedForTest); + // Resolved fires on the same pass. + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved); + } + [Fact] public void Watcher_fires_PackageStarted_even_when_DisplayName_is_missing() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs index d133b5df..23c7e4e6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs @@ -66,6 +66,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals // Resolved-phase value per the RJ enum (RealmJoin.Core.SoftwarePackaging.DeploymentPhase). public const int PhaseCompletedFirstDeployment = 110; + /// + /// Minimum DWORD value indicating RJ has moved out of Blank (0) into an active + /// or terminal deployment phase. Below this the SOFTWARE\RealmJoin subtree may still + /// be written by adjacent IME packages — package watchers stay disarmed until RJ owns + /// the keys. + /// + public const int PhaseRunningThresholdMin = 100; + /// /// Map a raw DeploymentPhase DWORD to its RJ-internal enum name. Mirrors /// RealmJoin.Core.SoftwarePackaging.DeploymentPhase as documented at feature diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs index b8e1440f..ea166329 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs @@ -118,10 +118,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private readonly HashSet _hkuPackagesCompleted = new HashSet(StringComparer.OrdinalIgnoreCase); private string? _hkuSid; - // Set once the Services\realmjoin gate fires. Until then, HKLM\SOFTWARE\RealmJoin and - // HKU\\SOFTWARE\RealmJoin remain unarmed — RJ presence is the prerequisite for - // looking at those keys at all. - private bool _realmJoinServiceDetected; + // Set once the first non-Blank DeploymentPhase (>= 100) is observed. Package watchers + // (HKLM\SOFTWARE\RealmJoin + HKU\\SOFTWARE\RealmJoin) only arm at that point — + // earlier observation would mix IME's in-flight package writes (which share the same + // subtree) into RJ's lifecycle. + private bool _packageWatchersArmed; private bool _disposed; public event EventHandler? RealmJoinDetected; @@ -151,10 +152,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// resolves a real user owner and /// produces the SID. Idempotent. /// - /// The actual HKU watcher only arms once the Services\realmjoin gate fires — - /// either it has already fired (immediate arm) or it fires later - /// (deferred arm from ). On devices - /// without RJ the SID is recorded but no HKU watcher is ever created. + /// The actual HKU watcher only arms once the package-watcher arming threshold has + /// fired (RJ observed at or + /// higher). Either it has already fired (immediate arm) or fires later + /// (deferred arm from ). On devices without RJ + /// or where RJ never leaves Blank, the SID is recorded but no HKU watcher is created. /// /// public void ArmHku(string sid) @@ -167,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { if (_hkuSid != null) return; _hkuSid = sid; - armNow = _realmJoinServiceDetected; + armNow = _packageWatchersArmed; } _logger.Info($"RealmJoinWatcher: recorded SID {sid} for HKU watching (armNow={armNow})"); @@ -337,7 +339,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private void AttachServiceRealmjoinSubtreeWatcher() { - bool armUserToo; lock (_stateLock) { if (_serviceRealmjoinWatcher != null) return; @@ -361,23 +362,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { _logger.Warning($"RealmJoinWatcher: failed to attach Services\\realmjoin watcher: {ex.Message}"); } - - _realmJoinServiceDetected = true; - armUserToo = _hkuSid != null; } // The appearance of Services\realmjoin is itself the RJ-detected signal — - // surface it even if Parameters\DeploymentPhase isn't populated yet. + // surface it even if Parameters\DeploymentPhase isn't populated yet. The package + // watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here: + // NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin + // is observed. Before that the same subtree may still receive in-flight IME + // package writes that are not part of RJ's lifecycle. NotifyRealmJoinPresence(phase: null); CheckParameters(); - - // RJ is now confirmed installed — arm the package watchers. - EnsureMachineRealmJoinStage(); - if (armUserToo) - { - CheckUserPackages(); - EnsureUserRealmJoinStage(); - } } private void AttachMachineRealmJoinSubtreeWatcher() @@ -469,6 +463,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals bool firePhaseChange = false; bool fireResolved = false; int? prevPhase = null; + bool armPackageWatchers = false; + bool armUserPackageWatcherToo = false; lock (_stateLock) { @@ -488,6 +484,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals fireResolved = true; _resolvedFired = true; } + // Arm the package watchers once RJ leaves Blank (phase >= 100). Before that, + // IME may still be writing to HKLM\SOFTWARE\RealmJoin during the pre-RJ + // install window and we'd misattribute those rows to RJ's lifecycle. + if (!_packageWatchersArmed && phase.Value >= RealmJoinInfo.PhaseRunningThresholdMin) + { + _packageWatchersArmed = true; + armPackageWatchers = true; + armUserPackageWatcherToo = _hkuSid != null; + } } } @@ -515,6 +520,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals try { RealmJoinResolved?.Invoke(this, new RealmJoinResolvedEventArgs(phase.Value)); } catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinResolved handler threw", ex); } } + + if (armPackageWatchers) + { + _logger.Info($"RealmJoinWatcher: phase={phase} crossed RunningThreshold — arming package watchers (HKLM + HKU={armUserPackageWatcherToo})"); + EnsureMachineRealmJoinStage(); + if (armUserPackageWatcherToo) + { + CheckUserPackages(); + EnsureUserRealmJoinStage(); + } + } } private void CheckMachinePackages() @@ -649,5 +665,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals internal void TriggerUserPackageObservationFromTest(RealmJoinPackageSnapshot snap) => MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted); + + internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase); + + internal bool PackageWatchersArmedForTest + { + get { lock (_stateLock) { return _packageWatchersArmed; } } + } } }