mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-11 04:09:51 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7055a6f0fc | |||
| 587de860dd | |||
| 409794c037 | |||
| 337b3b012a | |||
| c2ae5ddd83 | |||
| 60baf5317c | |||
| a5d5a37dc1 | |||
| d4b56c417d | |||
| a5bbdf9f80 | |||
| 389d4f27dc | |||
| ce5f22bd19 | |||
| 85fadc8ac4 |
+1
-1
Submodule .claude/commands updated: 9e518b0caf...3699da8230
+1
-1
Submodule internal updated: c4c3655cd0...e0913c34d2
@@ -44,7 +44,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds = null,
|
||||
TimeSpan? drainInterval = null,
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null) =>
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
TimeSpan? collectorHostStopTimeout = null) =>
|
||||
new EnrollmentOrchestrator(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval ?? TimeSpan.FromDays(1),
|
||||
terminalDrainTimeout: terminalDrainTimeout ?? TimeSpan.FromSeconds(2),
|
||||
agentMaxLifetime: agentMaxLifetime);
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
collectorHostStopTimeout: collectorHostStopTimeout);
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
+51
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -13,6 +14,8 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
@@ -137,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
public void Dispose()
|
||||
{
|
||||
Watcher.Dispose();
|
||||
ReleaseProcessesAndFiles();
|
||||
}
|
||||
|
||||
/// <summary>Teardown that leaves the watcher alone — for a test that found its Dispose blocked.</summary>
|
||||
public void ReleaseProcessesAndFiles()
|
||||
{
|
||||
foreach (var p in Running)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
@@ -210,6 +219,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal(new object[] { first.Id, second.Id }, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_the_attached_process_is_being_delivered()
|
||||
{
|
||||
var f = new Fixture();
|
||||
var child = f.Spawn(trusted: true);
|
||||
|
||||
// The watcher gets a Process object that already carries a handler. .NET runs that one
|
||||
// first and holds its own lock on the Process object for as long as it delivers Exited.
|
||||
var delivering = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
f.Watcher.ProcessSource = () =>
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
return new[] { handle };
|
||||
};
|
||||
f.Watcher.TryAttachNow();
|
||||
Assert.Equal(child.Id, f.Watcher.AttachedProcessId);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(TimeSpan.FromSeconds(10)), "the exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => f.Watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = dispose.Wait(TimeSpan.FromSeconds(10));
|
||||
// A blocked Dispose holds the watcher's lock for good; disposing the watcher again would
|
||||
// hang the test run instead of failing this test.
|
||||
f.ReleaseProcessesAndFiles();
|
||||
Assert.True(returned,
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_spoof_and_service_both_running_the_service_wins_regardless_of_order()
|
||||
{
|
||||
|
||||
+32
@@ -2,11 +2,14 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // one test waits on tasks on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
@@ -148,6 +151,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(afterDispose, Volatile.Read(ref raises)); // no raises after dispose
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_the_initial_scan_handler_is_running()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
LayDownCoreBinary(tmp.Path); // the initial scan in Start() finds it and raises
|
||||
var watcher = new OfficeBinaryWatcher(tmp.Path, CoreBinaries, NewLogger(tmp.Path));
|
||||
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.BinaryAppeared += (_, __) => { entered.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
|
||||
|
||||
var start = Task.Run(() => watcher.Start());
|
||||
try
|
||||
{
|
||||
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)), "initial scan did not raise");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while BinaryAppeared is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
start.Wait(TimeSpan.FromSeconds(10));
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SpinUntil(Func<bool> condition, int timeoutMs)
|
||||
{
|
||||
var deadline = Environment.TickCount + timeoutMs;
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeProcessWatcher"/> with stand-in worker processes handed in through the
|
||||
/// startup probe (the WMI start trace is never armed here, as in the sibling watchers' tests).
|
||||
/// Covers the worker window (started / stopped) and the two ways a worker's exit used to go
|
||||
/// wrong: disposing the watcher while the exit is being delivered, and a worker that was
|
||||
/// already gone when the watcher subscribed.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // Process.Exited is delivered on the shared ThreadPool
|
||||
public sealed class OfficeProcessWatcherTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<Process> _spawned = new List<Process>();
|
||||
|
||||
public OfficeProcessWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var p in _spawned)
|
||||
{
|
||||
try { if (!p.HasExited) p.Kill(); } catch { }
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>A stand-in worker that blocks on stdin until killed.</summary>
|
||||
private Process Spawn()
|
||||
{
|
||||
var p = Process.Start(new ProcessStartInfo("cmd.exe", "/c pause")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Assert.NotNull(p);
|
||||
_spawned.Add(p!);
|
||||
return p!;
|
||||
}
|
||||
|
||||
/// <summary>A second handle on the same process — what the watcher would have opened itself.</summary>
|
||||
private static Process HandleOn(Process child)
|
||||
{
|
||||
var handle = Process.GetProcessById(child.Id);
|
||||
handle.EnableRaisingEvents = true;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Starts the watcher with <paramref name="handle"/> as the one running worker.</summary>
|
||||
private static void StartWith(OfficeProcessWatcher watcher, Process handle)
|
||||
{
|
||||
watcher.WorkerSource = () => new[] { handle };
|
||||
watcher.Start(armWmi: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_running_worker_is_reported_before_Start_returns_and_its_exit_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var started = new ManualResetEventSlim(false);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Started += (_, __) => started.Set();
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
var child = Spawn();
|
||||
|
||||
StartWith(watcher, HandleOn(child));
|
||||
|
||||
Assert.True(started.IsSet, "Started was not raised by the startup probe");
|
||||
Assert.True(watcher.IsActive);
|
||||
|
||||
child.Kill();
|
||||
|
||||
Assert.True(stopped.Wait(Timeout), "Stopped was not raised after the last worker exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_returns_while_the_exit_of_a_tracked_worker_is_being_delivered()
|
||||
{
|
||||
var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
|
||||
// Subscribed before the watcher, so .NET runs it first — and .NET holds its own lock on
|
||||
// the Process object for as long as it delivers Exited.
|
||||
using var delivering = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => { delivering.Set(); release.Wait(Timeout); };
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Task dispose;
|
||||
try
|
||||
{
|
||||
child.Kill();
|
||||
Assert.True(delivering.Wait(Timeout), "the worker's exit was not delivered");
|
||||
|
||||
dispose = Task.Run(() => watcher.Dispose());
|
||||
Thread.Sleep(300); // let Dispose reach the Process object
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.True(dispose.Wait(Timeout),
|
||||
"Dispose and the exit delivery blocked each other: the Process is disposed under the watcher's lock");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_worker_that_was_already_gone_when_the_watcher_subscribed_still_closes_the_window()
|
||||
{
|
||||
using var watcher = new OfficeProcessWatcher(_logger, settleSeconds: 0);
|
||||
using var stopped = new ManualResetEventSlim(false);
|
||||
watcher.Stopped += (_, __) => stopped.Set();
|
||||
|
||||
var child = Spawn();
|
||||
var handle = HandleOn(child);
|
||||
using var exitDelivered = new ManualResetEventSlim(false);
|
||||
handle.Exited += (_, __) => exitDelivered.Set();
|
||||
child.Kill();
|
||||
// .NET raises Exited once per Process object. After this, a later subscriber never hears it.
|
||||
Assert.True(exitDelivered.Wait(Timeout), "the stand-in's exit was not delivered");
|
||||
|
||||
StartWith(watcher, handle);
|
||||
|
||||
Assert.True(stopped.Wait(Timeout),
|
||||
"the window never closed: the worker exited before the watcher subscribed to Exited");
|
||||
Assert.False(watcher.IsActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RegistryChangeWatcher"/> against a real key under HKCU (no elevation needed).
|
||||
/// The watcher's lock is a leaf lock: <c>Changed</c> is raised with the lock free, so a
|
||||
/// handler can take other locks or dispose the watcher, and <c>Dispose</c> never waits for a
|
||||
/// handler. One handler runs at a time, and a change that arrives while it runs is reported
|
||||
/// after it returns.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // callbacks arrive on the shared ThreadPool
|
||||
public sealed class RegistryChangeWatcherTests : IDisposable
|
||||
{
|
||||
private const string RootSubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeRegistryChangeWatcher";
|
||||
private const string TargetSubKey = RootSubKey + @"\Target";
|
||||
private const uint RegNotifyThreadAgnostic = 0x10000000;
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public RegistryChangeWatcherTests()
|
||||
{
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
DeleteTestKeys();
|
||||
using (var root = Registry.CurrentUser.CreateSubKey(RootSubKey)) Assert.NotNull(root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKeys();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKeys()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RootSubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void CreateTarget()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TargetSubKey)) Assert.NotNull(key);
|
||||
}
|
||||
|
||||
private static void WriteValue(string name)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TargetSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue(name, Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private RegistryChangeWatcher NewWatcher() =>
|
||||
new RegistryChangeWatcher(RegistryHive.CurrentUser, TargetSubKey, _logger);
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_when_a_value_is_written_under_the_target()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire for a value write under the target key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_fires_once_a_missing_target_key_appears()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
|
||||
watcher.Start(); // target absent: watches the parent for a new child key
|
||||
CreateTarget();
|
||||
|
||||
Assert.True(fired.Wait(Timeout), "Changed did not fire when the target key appeared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_from_another_thread_returns_while_a_handler_is_running()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
watcher.Changed += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
watcher.Start();
|
||||
WriteValue("Probe");
|
||||
Assert.True(entered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
var dispose = Task.Run(() => watcher.Dispose());
|
||||
|
||||
Assert.True(dispose.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Dispose waited for the running handler: the watcher's lock is held while Changed is raised");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
watcher.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_handler_may_dispose_its_own_watcher()
|
||||
{
|
||||
CreateTarget();
|
||||
var watcher = NewWatcher();
|
||||
using var disposedInHandler = new ManualResetEventSlim(false);
|
||||
int calls = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
watcher.Dispose();
|
||||
disposedInHandler.Set();
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(disposedInHandler.Wait(Timeout), "handler did not return from disposing its watcher");
|
||||
|
||||
WriteValue("Second");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // a disposed watcher stays quiet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handlers_never_overlap_and_a_change_during_a_handler_is_reported_after_it()
|
||||
{
|
||||
CreateTarget();
|
||||
using var watcher = NewWatcher();
|
||||
using var firstEntered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
using var second = new ManualResetEventSlim(false);
|
||||
int calls = 0, running = 0, maxRunning = 0;
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref running);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxRunning)))
|
||||
Interlocked.CompareExchange(ref maxRunning, now, seen);
|
||||
|
||||
if (Interlocked.Increment(ref calls) == 1)
|
||||
{
|
||||
firstEntered.Set();
|
||||
release.Wait(Timeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
second.Set();
|
||||
}
|
||||
Interlocked.Decrement(ref running);
|
||||
};
|
||||
|
||||
watcher.Start();
|
||||
WriteValue("First");
|
||||
Assert.True(firstEntered.Wait(Timeout), "handler was not invoked");
|
||||
|
||||
WriteValue("DuringHandler");
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(1, Volatile.Read(ref calls)); // no second handler while the first runs
|
||||
|
||||
release.Set();
|
||||
|
||||
Assert.True(second.Wait(Timeout), "the change that arrived during the handler was never reported");
|
||||
Assert.Equal(1, Volatile.Read(ref maxRunning));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_watch_is_not_tied_to_the_arming_thread()
|
||||
{
|
||||
using var watcher = NewWatcher();
|
||||
|
||||
// Re-arming happens on rotating ThreadPool threads. Without this flag Windows signals
|
||||
// the event when such a thread ends, which looks like a change that never happened.
|
||||
Assert.True((watcher.EffectivePrimaryFilter & RegNotifyThreadAgnostic) != 0,
|
||||
"the target watch must carry REG_NOTIFY_THREAD_AGNOSTIC");
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the test waits on a task on purpose: a blocked call is the failure it looks for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="RealmJoinWatcher.Stop"/> while the "RealmJoin key appeared" callback is in
|
||||
/// flight. The callback runs on a real registry watcher over the signed-in user's
|
||||
/// <c>HKU\<sid>\SOFTWARE</c> (writable without elevation); the key probes are injected.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry callbacks arrive on the shared ThreadPool
|
||||
public sealed class RealmJoinWatcherStopTests : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
// A new direct child of HKCU\SOFTWARE is what the parent watch reacts to.
|
||||
private readonly string _probeKey = @"SOFTWARE\AutopilotMonitor.Tests.RealmJoinStop." + Guid.NewGuid().ToString("N");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(_probeKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_the_key_appeared_callback_is_running()
|
||||
{
|
||||
var sid = WindowsIdentity.GetCurrent().User!.Value;
|
||||
var userRealmJoinPath = sid + "\\" + RealmJoinInfo.UserRealmJoinSubPath;
|
||||
|
||||
var appearedProbe = new ManualResetEventSlim(false);
|
||||
var release = new ManualResetEventSlim(false);
|
||||
int armed = 0;
|
||||
bool KeyExists(RegistryHive hive, string subPath)
|
||||
{
|
||||
// Everything is "absent" while the watcher arms. Afterwards the user-scope
|
||||
// RealmJoin key "exists": the callback sees it and goes on to switch watchers.
|
||||
if (hive != RegistryHive.Users || subPath != userRealmJoinPath || Volatile.Read(ref armed) == 0)
|
||||
return false;
|
||||
appearedProbe.Set();
|
||||
release.Wait(Timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
var watcher = new RealmJoinWatcher(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
enumeratePackageIds: (_, __) => Array.Empty<string>(),
|
||||
tryReadPackage: (RegistryHive _, string __, string ___, out RealmJoinPackageSnapshot snapshot) =>
|
||||
{
|
||||
snapshot = default!;
|
||||
return false;
|
||||
},
|
||||
keyExists: KeyExists,
|
||||
readVersionInfo: () => default,
|
||||
readUninstallDisplayVersion: () => null,
|
||||
readDeploymentPhase: () => null);
|
||||
|
||||
// Production order: the SID is recorded, then RealmJoin reaches the running phase and
|
||||
// the watcher arms its parent watch on HKU\<sid>\SOFTWARE.
|
||||
watcher.ArmHku(sid);
|
||||
watcher.TriggerNotifyRealmJoinPresenceFromTest(RealmJoinInfo.PhaseRunningThresholdMin);
|
||||
Volatile.Write(ref armed, 1);
|
||||
|
||||
Task stop;
|
||||
try
|
||||
{
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(_probeKey)) Assert.NotNull(key);
|
||||
Assert.True(appearedProbe.Wait(Timeout), "the parent watch did not report the new key");
|
||||
|
||||
stop = Task.Run(() => watcher.Stop());
|
||||
Thread.Sleep(300); // let Stop reach the registry watcher whose callback is parked
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
var returned = stop.Wait(Timeout);
|
||||
// A blocked Stop holds the watcher's lock for good; disposing it would hang the run.
|
||||
if (returned) watcher.Dispose();
|
||||
Assert.True(returned,
|
||||
"Stop and the key-appeared callback blocked each other: watchers are disposed under the state lock");
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a call that never returns is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Stopping the collector hosts is bounded per host: termination reaches the diagnostics
|
||||
/// upload and the cleanup whatever a collector does. A host that hangs is left behind, named
|
||||
/// in one <c>agent_trace</c>, and never called again; every other host is stopped and disposed
|
||||
/// as before.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class EnrollmentOrchestratorCollectorStopTests
|
||||
{
|
||||
private static readonly TimeSpan Bound = TimeSpan.FromMilliseconds(400);
|
||||
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(10);
|
||||
|
||||
private sealed class Host : ICollectorHost
|
||||
{
|
||||
private readonly List<string> _order;
|
||||
private readonly ManualResetEventSlim? _hangUntil;
|
||||
private readonly Exception? _throwOnStop;
|
||||
private int _stopCalls;
|
||||
private int _disposeCalls;
|
||||
|
||||
public Host(string name, List<string> order, ManualResetEventSlim? hangUntil = null, Exception? throwOnStop = null)
|
||||
{
|
||||
Name = name;
|
||||
_order = order;
|
||||
_hangUntil = hangUntil;
|
||||
_throwOnStop = throwOnStop;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int StopCalls => Volatile.Read(ref _stopCalls);
|
||||
public int DisposeCalls => Volatile.Read(ref _disposeCalls);
|
||||
|
||||
public void Start() { }
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Interlocked.Increment(ref _stopCalls);
|
||||
lock (_order) _order.Add(Name);
|
||||
if (_throwOnStop != null) throw _throwOnStop;
|
||||
_hangUntil?.Wait();
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Increment(ref _disposeCalls);
|
||||
}
|
||||
|
||||
private sealed class Factory : IComponentFactory
|
||||
{
|
||||
private readonly IReadOnlyList<ICollectorHost> _hosts;
|
||||
public Factory(IReadOnlyList<ICollectorHost> hosts) { _hosts = hosts; }
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
ITelemetrySpool? telemetrySpool,
|
||||
TimelineEventStream? timelineEvents = null,
|
||||
Func<DecisionState?>? decisionStateProbe = null) => new CollectorSurfaces(_hosts);
|
||||
}
|
||||
|
||||
private static JObject? StopTimeoutEvent(EnrollmentOrchestrator sut) =>
|
||||
sut.SpoolForTests!.Peek(1000)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event && i.PayloadJson.Contains("collector_host_stop_timeout"))
|
||||
.Select(i => JObject.Parse(i.PayloadJson))
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_host_that_hangs_in_Stop_is_left_behind_and_the_others_are_still_stopped()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
var order = new List<string>();
|
||||
var before = new Host("Before", order);
|
||||
var hanging = new Host("Hanging", order, hangUntil: release);
|
||||
var after = new Host("After", order);
|
||||
var sut = rig.Build(
|
||||
componentFactory: new Factory(new ICollectorHost[] { before, hanging, after }),
|
||||
collectorHostStopTimeout: Bound);
|
||||
sut.Start();
|
||||
|
||||
try
|
||||
{
|
||||
var stop = Task.Run(() => sut.StopCollectorHosts());
|
||||
|
||||
Assert.True(stop.Wait(Wait), "StopCollectorHosts never returned although one host hangs");
|
||||
Assert.Equal(new[] { "Before", "Hanging", "After" }, order.ToArray());
|
||||
|
||||
JObject? evt = null;
|
||||
Assert.True(SpinWait.SpinUntil(() => (evt = StopTimeoutEvent(sut)) != null, Wait),
|
||||
"the hanging host was not reported");
|
||||
Assert.Equal("agent_trace", (string?)evt!["EventType"]);
|
||||
var data = (JObject)evt["Data"]!;
|
||||
Assert.Equal("collector_host_stop_timeout", (string?)data["decision"]);
|
||||
Assert.Equal("Hanging", (string?)data["host"]);
|
||||
Assert.Equal(1, (int)data["timeoutSeconds"]!);
|
||||
|
||||
// Stop() afterwards: the stuck host is not touched again, the others are disposed.
|
||||
var fullStop = Task.Run(() => sut.Stop());
|
||||
Assert.True(fullStop.Wait(Wait), "Stop never returned");
|
||||
Assert.Equal(1, before.DisposeCalls);
|
||||
Assert.Equal(0, hanging.DisposeCalls);
|
||||
Assert.Equal(1, after.DisposeCalls);
|
||||
Assert.Equal(1, hanging.StopCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_hosts_are_stopped_and_disposed_in_order_without_a_report()
|
||||
{
|
||||
using var rig = new EnrollmentOrchestratorRig();
|
||||
var order = new List<string>();
|
||||
var first = new Host("First", order);
|
||||
var throwing = new Host("Throwing", order, throwOnStop: new InvalidOperationException("boom"));
|
||||
var last = new Host("Last", order);
|
||||
var hosts = new[] { first, throwing, last };
|
||||
var sut = rig.Build(componentFactory: new Factory(hosts)); // default bound: nothing here comes near it
|
||||
sut.Start();
|
||||
|
||||
sut.StopCollectorHosts();
|
||||
sut.StopCollectorHosts(); // idempotent
|
||||
|
||||
Assert.Equal(new[] { "First", "Throwing", "Last" }, order.ToArray());
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.StopCalls));
|
||||
|
||||
Thread.Sleep(200);
|
||||
Assert.Null(StopTimeoutEvent(sut));
|
||||
|
||||
sut.Stop();
|
||||
Assert.All(hosts, h => Assert.Equal(1, h.DisposeCalls));
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
#pragma warning disable xUnit1031 // the tests wait on tasks on purpose: a blocked call is the failure they look for
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="OfficeInstallDetectorHost"/> with its real watchers: the registry watcher on a
|
||||
/// key under HKCU, the binary watcher on a temp folder, snapshots injected. Stopping the host
|
||||
/// must never wait for a watcher callback, and a completion that coincides with a registry
|
||||
/// callback must not leave the host unable to stop.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // registry + filesystem callbacks arrive on the shared ThreadPool
|
||||
public sealed class OfficeInstallDetectorHostTests : IDisposable
|
||||
{
|
||||
private const string RegistrySubKey = @"SOFTWARE\AutopilotMonitor.Tests\OfficeInstallDetectorHost";
|
||||
private static readonly DateTime At = new DateTime(2026, 11, 10, 9, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink = new FakeSignalIngressSink();
|
||||
private readonly string _installPath;
|
||||
private Func<OfficeC2RSnapshot> _snapshot;
|
||||
|
||||
public OfficeInstallDetectorHostTests()
|
||||
{
|
||||
DeleteTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistrySubKey)) Assert.NotNull(key);
|
||||
_installPath = Path.Combine(_tmp.Path, "Office");
|
||||
Directory.CreateDirectory(_installPath);
|
||||
_snapshot = Idle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DeleteTestKey();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private static void DeleteTestKey()
|
||||
{
|
||||
try { Registry.CurrentUser.DeleteSubKeyTree(RegistrySubKey, throwOnMissingSubKey: false); }
|
||||
catch { /* teardown noise must not fail a test */ }
|
||||
}
|
||||
|
||||
private static void TouchRegistry()
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistrySubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("Probe", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
}
|
||||
|
||||
private OfficeInstallDetectorHost NewHost() =>
|
||||
new OfficeInstallDetectorHost(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
ingress: _sink,
|
||||
clock: new VirtualClock(At),
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
settleSeconds: 0,
|
||||
statePersistence: null,
|
||||
resumeState: null,
|
||||
registryHive: RegistryHive.CurrentUser,
|
||||
registrySubKey: RegistrySubKey,
|
||||
snapshotProvider: () => _snapshot());
|
||||
|
||||
// No install scenario, no install path: the detector stays idle and the host keeps
|
||||
// raising OfficeExpected on registry changes.
|
||||
private static OfficeC2RSnapshot Idle() => new OfficeC2RSnapshot { ConfigurationKeyPresent = true };
|
||||
|
||||
private OfficeC2RSnapshot Installing()
|
||||
{
|
||||
var s = new OfficeC2RSnapshot
|
||||
{
|
||||
ConfigurationKeyPresent = true,
|
||||
ActiveScenarioPresent = true,
|
||||
ActiveScenarioName = "INSTALL",
|
||||
InstallScenarioPresent = true,
|
||||
InstallScenarioName = "INSTALL",
|
||||
InstallationPath = _installPath,
|
||||
VersionToReport = "16.0.1.1",
|
||||
};
|
||||
s.Products.Add("O365ProPlusRetail");
|
||||
return s;
|
||||
}
|
||||
|
||||
private int OfficeEvents(string eventType) => _sink.Posted.Count(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType);
|
||||
|
||||
[Fact]
|
||||
public void Stop_returns_while_a_registry_handler_is_running()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_completion_during_a_running_registry_handler_does_not_block_Stop()
|
||||
{
|
||||
var host = NewHost();
|
||||
using var entered = new ManualResetEventSlim(false);
|
||||
using var release = new ManualResetEventSlim(false);
|
||||
host.OfficeExpected += (_, __) => { entered.Set(); release.Wait(Timeout); };
|
||||
|
||||
try
|
||||
{
|
||||
// 1) A registry callback is in flight (parked in the host's OfficeExpected handler).
|
||||
host.Start();
|
||||
TouchRegistry();
|
||||
Assert.True(entered.Wait(Timeout), "the registry change did not reach the host");
|
||||
|
||||
// 2) The install starts and completes meanwhile: the binary watcher sees the core
|
||||
// binary, the detector reports completed, the host tears its watchers down.
|
||||
_snapshot = Installing;
|
||||
host.SubmitDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 10 });
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallStarted));
|
||||
|
||||
var versionDir = Path.Combine(_installPath, "root", "Office16");
|
||||
Directory.CreateDirectory(versionDir);
|
||||
File.WriteAllText(Path.Combine(versionDir, "EXCEL.EXE"), "stub");
|
||||
Assert.True(
|
||||
SpinWait.SpinUntil(() => OfficeEvents(Constants.EventTypes.OfficeInstallCompleted) == 1, Timeout),
|
||||
"the install was not reported as completed");
|
||||
|
||||
// 3) The agent stops while that registry callback is still running.
|
||||
var stop = Task.Run(() => host.Stop());
|
||||
|
||||
Assert.True(stop.Wait(TimeSpan.FromSeconds(5)),
|
||||
"Stop waited behind the completion's teardown, which waited for the running registry handler");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
}
|
||||
|
||||
Assert.Equal(1, OfficeEvents(Constants.EventTypes.OfficeInstallCompleted));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -200,6 +200,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Process attached;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -208,12 +209,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
attached = _imeProcess;
|
||||
_imeProcess = null;
|
||||
}
|
||||
|
||||
// Not under the lock: .NET holds its own lock on the Process while it delivers Exited,
|
||||
// OnImeExited takes _lock, and Process.Dispose waits for that delivery to finish.
|
||||
if (attached != null)
|
||||
{
|
||||
try { attached.Exited -= OnImeExited; } catch { }
|
||||
try { attached.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-19
@@ -194,6 +194,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// subtree) into RJ's lifecycle.
|
||||
private bool _packageWatchersArmed;
|
||||
private bool _disposed;
|
||||
// Set by Stop under _stateLock: a registry callback that is still running then must not
|
||||
// arm or attach another watcher.
|
||||
private bool _stopped;
|
||||
|
||||
public event EventHandler<RealmJoinDetectedEventArgs>? RealmJoinDetected;
|
||||
public event EventHandler<RealmJoinResolvedEventArgs>? RealmJoinResolved;
|
||||
@@ -228,6 +231,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(RealmJoinWatcher));
|
||||
lock (_stateLock) { _stopped = false; }
|
||||
|
||||
// The Services\realmjoin gate is the SOLE entry point. HKLM\SOFTWARE\RealmJoin and
|
||||
// the HKU watcher do not run until this fires — RJ presence is the prerequisite for
|
||||
@@ -277,27 +281,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
try
|
||||
{
|
||||
var stoppedAny = false;
|
||||
var watchers = new List<RegistryWatcher>(8);
|
||||
lock (_stateLock)
|
||||
{
|
||||
stoppedAny |= DisposeAndNull(ref _servicesAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userSoftwareAppearanceWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallAppearanceWatcher);
|
||||
_stopped = true;
|
||||
|
||||
Take(ref _servicesAppearanceWatcher, watchers);
|
||||
Take(ref _machineSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _userSoftwareAppearanceWatcher, watchers);
|
||||
Take(ref _uninstallAppearanceWatcher, watchers);
|
||||
|
||||
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
|
||||
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
|
||||
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
|
||||
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
|
||||
|
||||
stoppedAny |= DisposeAndNull(ref _serviceRealmjoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _machineRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _userRealmJoinWatcher);
|
||||
stoppedAny |= DisposeAndNull(ref _uninstallWatcher);
|
||||
Take(ref _serviceRealmjoinWatcher, watchers);
|
||||
Take(ref _machineRealmJoinWatcher, watchers);
|
||||
Take(ref _userRealmJoinWatcher, watchers);
|
||||
Take(ref _uninstallWatcher, watchers);
|
||||
}
|
||||
|
||||
// Disposed outside the lock: RegistryWatcher.Dispose waits for a callback that is
|
||||
// still running, and the appearance callback takes _stateLock.
|
||||
foreach (var watcher in watchers)
|
||||
{
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
// Hosts call Stop and then Dispose, which stops again; only the pass that
|
||||
// stopped a watcher logs.
|
||||
if (stoppedAny) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
if (watchers.Count > 0) _logger.Info($"RealmJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -391,6 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
Action clearSlot,
|
||||
Action onAppeared)
|
||||
{
|
||||
lock (_stateLock) { if (_stopped) return; }
|
||||
try
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
@@ -440,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_serviceRealmjoinWatcher != null) return;
|
||||
if (_stopped || _serviceRealmjoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_serviceRealmjoinDebounce = new Timer(_ => CheckParameters(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -486,7 +501,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_machineRealmJoinWatcher != null) return;
|
||||
if (_stopped || _machineRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets with every package sub-key already present at arming time.
|
||||
@@ -529,7 +544,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_userRealmJoinWatcher != null) return;
|
||||
if (_stopped || _userRealmJoinWatcher != null) return;
|
||||
try
|
||||
{
|
||||
// Seed dedup sets — see AttachMachineRealmJoinSubtreeWatcher for rationale.
|
||||
@@ -711,7 +726,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_disposed || _uninstallWatcher != null) return;
|
||||
if (_disposed || _stopped || _uninstallWatcher != null) return;
|
||||
try
|
||||
{
|
||||
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
@@ -941,12 +956,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
private static bool DisposeAndNull(ref RegistryWatcher? w)
|
||||
// Caller holds _stateLock; the taken watchers are disposed after it is released.
|
||||
private static void Take(ref RegistryWatcher? slot, List<RegistryWatcher> taken)
|
||||
{
|
||||
if (w == null) return false;
|
||||
try { w.Dispose(); } catch { }
|
||||
w = null;
|
||||
return true;
|
||||
if (slot == null) return;
|
||||
taken.Add(slot);
|
||||
slot = null;
|
||||
}
|
||||
|
||||
// Test seams — drive the read paths deterministically without touching the real registry.
|
||||
|
||||
+16
-12
@@ -79,11 +79,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
TryArm();
|
||||
raise = TryArm();
|
||||
}
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,17 +132,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _lock.
|
||||
private void TryArm()
|
||||
// Caller holds _lock. Returns true when BinaryAppeared is due; the caller raises it after
|
||||
// releasing the lock, like OnRecheck does — the handler probes and may dispose this watcher.
|
||||
private bool TryArm()
|
||||
{
|
||||
if (_disposed || _fsw != null) return;
|
||||
if (_disposed || _fsw != null) return false;
|
||||
|
||||
// Initial scan — the binaries may already be present (armed late / update over existing).
|
||||
if (CoreBinariesPresent())
|
||||
{
|
||||
Raise();
|
||||
return;
|
||||
}
|
||||
if (CoreBinariesPresent()) return true;
|
||||
|
||||
if (!Directory.Exists(_installationPath))
|
||||
{
|
||||
@@ -154,7 +154,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Debug($"[OfficeBinaryWatcher] install path '{_installationPath}' never appeared — giving up (no completion proof)");
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -172,17 +172,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeBinaryWatcher] watching '{_installationPath}' for core Office binaries");
|
||||
|
||||
// Race: a binary may have appeared between the scan above and arming — re-scan once.
|
||||
if (CoreBinariesPresent()) Raise();
|
||||
return CoreBinariesPresent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[OfficeBinaryWatcher] could not watch '{_installationPath}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArmRetry(object? state)
|
||||
{
|
||||
lock (_lock) { TryArm(); }
|
||||
bool raise;
|
||||
lock (_lock) { raise = TryArm(); }
|
||||
|
||||
if (raise) Raise();
|
||||
}
|
||||
|
||||
private void OnFileEvent(object sender, FileSystemEventArgs e) => CheckCandidate(e.Name);
|
||||
|
||||
+132
-52
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
@@ -15,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// idle polling:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Start</b> — a true ETW-backed push via WMI <c>Win32_ProcessStartTrace</c>
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a one-shot startup probe so an install already
|
||||
/// (<see cref="ManagementEventWatcher"/>), plus a startup probe so an install already
|
||||
/// in flight when the agent boots (realistic during OOBE) is still caught.</item>
|
||||
/// <item><b>Stop</b> — <see cref="Process.Exited"/> on each tracked worker (clean OS event, same
|
||||
/// pattern as <c>ImeProcessWatcher</c>).</item>
|
||||
@@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the
|
||||
/// startup probe still covers the already-running case — no polling fallback by design.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state. No event is raised and no
|
||||
/// <see cref="Process"/> is disposed while it is held: .NET holds its own lock on a
|
||||
/// <see cref="Process"/> for as long as it delivers <see cref="Process.Exited"/>, that handler
|
||||
/// takes this lock, and <see cref="Process.Dispose()"/> waits for the delivery to finish.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OfficeProcessWatcher : IDisposable
|
||||
{
|
||||
@@ -53,6 +60,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </summary>
|
||||
public event EventHandler? Stopped;
|
||||
|
||||
/// <summary>Test seam: enumerates the running workers (default: Process.GetProcessesByName).</summary>
|
||||
internal Func<Process[]> WorkerSource { get; set; } = () => Process.GetProcessesByName(WorkerProcessNameNoExt);
|
||||
|
||||
/// <param name="settleSeconds">Grace period after the last worker exits before the window is
|
||||
/// declared closed; a new worker within it keeps the window open. <=0 closes immediately.</param>
|
||||
public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10)
|
||||
@@ -67,7 +77,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
get { lock (_lock) { return _active; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
public void Start() => Start(armWmi: true);
|
||||
|
||||
/// <summary>Test seam: <paramref name="armWmi"/> = false leaves out the WMI subscription.</summary>
|
||||
internal void Start(bool armWmi)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -75,10 +88,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_started = true;
|
||||
}
|
||||
|
||||
// 1) Startup probe — catch a worker already running before the watcher armed.
|
||||
// 1) Startup probe — catch a worker already running before the subscription exists. It
|
||||
// stays on the caller's thread: it needs no WMI, so a worker that is already running is
|
||||
// reported even when the subscription below never completes.
|
||||
ProbeRunningWorkers();
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling. WinMgmt can hang during OOBE, so the
|
||||
// subscription never runs on the caller's thread (the orchestrator starts its hosts one
|
||||
// after another on it).
|
||||
if (armWmi) Task.Run(() => ArmStartTrace());
|
||||
}
|
||||
|
||||
private void ProbeRunningWorkers()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt))
|
||||
foreach (var proc in WorkerSource())
|
||||
{
|
||||
TrackWorker(proc);
|
||||
}
|
||||
@@ -87,24 +112,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// 2) ETW-backed push on worker creation — no polling.
|
||||
private void ArmStartTrace()
|
||||
{
|
||||
ManagementEventWatcher? watcher = null;
|
||||
try
|
||||
{
|
||||
var query = new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'");
|
||||
_startWatcher = new ManagementEventWatcher(query);
|
||||
_startWatcher.EventArrived += OnProcessStartTrace;
|
||||
_startWatcher.Start();
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
watcher = new ManagementEventWatcher(new WqlEventQuery(
|
||||
$"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"));
|
||||
watcher.EventArrived += OnProcessStartTrace;
|
||||
watcher.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// No polling fallback by design — the startup probe already covers the in-flight case.
|
||||
_logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " +
|
||||
$"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only.");
|
||||
_startWatcher = null;
|
||||
if (watcher != null) TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
// Published only once it is armed, so Dispose never meets a half-started subscription; one
|
||||
// that completes after Dispose is taken down right here.
|
||||
bool keep;
|
||||
lock (_lock)
|
||||
{
|
||||
keep = !_disposed;
|
||||
if (keep) _startWatcher = watcher;
|
||||
}
|
||||
if (!keep)
|
||||
{
|
||||
TearDownStartTrace(watcher);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}");
|
||||
|
||||
// A worker may have started between the probe in Start and the subscription.
|
||||
ProbeRunningWorkers();
|
||||
}
|
||||
|
||||
private void TearDownStartTrace(ManagementEventWatcher watcher)
|
||||
{
|
||||
try { watcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { watcher.Stop(); } catch { }
|
||||
try { watcher.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private void OnProcessStartTrace(object sender, EventArrivedEventArgs e)
|
||||
@@ -128,39 +181,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private void TrackWorker(Process proc)
|
||||
{
|
||||
bool tracked = false;
|
||||
bool raiseStarted = false;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) { try { proc.Dispose(); } catch { } return; }
|
||||
if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; }
|
||||
if (!_disposed && !_workers.ContainsKey(proc.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
tracked = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnWorkerExited;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between discovery and Exited-hookup — drop it.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
if (tracked)
|
||||
{
|
||||
_workers[proc.Id] = proc;
|
||||
// A worker is back — cancel any pending settle (the window must stay open).
|
||||
CancelSettleTimer();
|
||||
if (!_active)
|
||||
{
|
||||
_active = true;
|
||||
raiseStarted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (!tracked)
|
||||
{
|
||||
// Disposed, already tracked under another handle, or gone before the hookup.
|
||||
try { proc.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +231,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
_logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active");
|
||||
RaiseStarted();
|
||||
}
|
||||
|
||||
// .NET raises Exited once per Process object. A worker that exited before the hookup
|
||||
// above was subscribed would stay tracked forever and the window would never close.
|
||||
if (HasExitedSafe(proc))
|
||||
ThreadPool.QueueUserWorkItem(_ => OnWorkerExited(proc, EventArgs.Empty));
|
||||
}
|
||||
|
||||
private void OnWorkerExited(object? sender, EventArgs e)
|
||||
@@ -178,11 +245,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_workers.Remove(SafePid(proc)))
|
||||
{
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
}
|
||||
// Only the call that still finds this worker acts: after Dispose nothing is tracked,
|
||||
// and TrackWorker's own re-check can deliver the same exit a second time.
|
||||
if (_disposed || !_workers.TryGetValue(SafePid(proc), out var tracked) || !ReferenceEquals(tracked, proc))
|
||||
return;
|
||||
_workers.Remove(SafePid(proc));
|
||||
|
||||
if (_active && _workers.Count == 0)
|
||||
{
|
||||
if (_settleSeconds <= 0)
|
||||
@@ -200,6 +268,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
try { proc.Exited -= OnWorkerExited; } catch { }
|
||||
try { proc.Dispose(); } catch { }
|
||||
|
||||
if (raiseStoppedNow)
|
||||
{
|
||||
_logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed");
|
||||
@@ -260,31 +331,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
try { return proc.Id; } catch { return -1; }
|
||||
}
|
||||
|
||||
private static bool HasExitedSafe(Process proc)
|
||||
{
|
||||
try { return proc.HasExited; } catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ManagementEventWatcher? startWatcher;
|
||||
Process[] workers;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
if (_startWatcher != null)
|
||||
{
|
||||
try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { }
|
||||
try { _startWatcher.Stop(); } catch { }
|
||||
try { _startWatcher.Dispose(); } catch { }
|
||||
startWatcher = _startWatcher;
|
||||
_startWatcher = null;
|
||||
CancelSettleTimer();
|
||||
workers = new Process[_workers.Count];
|
||||
_workers.Values.CopyTo(workers, 0);
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
if (startWatcher != null)
|
||||
{
|
||||
CancelSettleTimer();
|
||||
foreach (var kv in _workers)
|
||||
{
|
||||
try { kv.Value.Exited -= OnWorkerExited; } catch { }
|
||||
try { kv.Value.Dispose(); } catch { }
|
||||
}
|
||||
_workers.Clear();
|
||||
// Cancelling a WMI subscription can take tens of seconds on a busy WinMgmt. The caller
|
||||
// is the agent's stop path, so it never waits for it.
|
||||
var armed = startWatcher;
|
||||
Task.Run(() => TearDownStartTrace(armed));
|
||||
}
|
||||
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
try { worker.Exited -= OnWorkerExited; } catch { }
|
||||
try { worker.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-28
@@ -26,6 +26,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// </para>
|
||||
/// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and
|
||||
/// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events).
|
||||
/// <para>
|
||||
/// <b>Locking</b>: the lock guards this watcher's own state and is never held while
|
||||
/// <see cref="Changed"/> is raised. A handler may dispose the watcher or take locks of its own,
|
||||
/// and <see cref="Dispose"/> never waits for a handler. Raising under the lock deadlocks against
|
||||
/// an owner that disposes this watcher under a lock its handler also takes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RegistryChangeWatcher : IDisposable
|
||||
{
|
||||
@@ -34,10 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn)
|
||||
ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished)
|
||||
// The registration must outlive the thread that issued it: re-arming happens on rotating
|
||||
// ThreadPool threads, and without this flag Windows signals the event when such a thread
|
||||
// ends — reported here as a change that never happened.
|
||||
ThreadAgnostic = 0x10000000,
|
||||
}
|
||||
|
||||
private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet;
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only
|
||||
private const RegNotifyFilter PrimaryFilter =
|
||||
RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet | RegNotifyFilter.ThreadAgnostic;
|
||||
// Cheap: direct-child create/delete only.
|
||||
private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ThreadAgnostic;
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
private static extern int RegNotifyChangeKeyValue(
|
||||
@@ -49,6 +61,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
private enum WatchMode { None, Primary, Bootstrap }
|
||||
|
||||
private readonly RegistryHive _hive;
|
||||
private readonly string _hiveLabel;
|
||||
private readonly string _targetSubKey;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _lock = new object();
|
||||
@@ -66,13 +80,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
|
||||
/// <param name="targetSubKey">HKLM-relative key, e.g. <c>SOFTWARE\Microsoft\Office\ClickToRun</c>.</param>
|
||||
public RegistryChangeWatcher(string targetSubKey, AgentLogger logger)
|
||||
: this(RegistryHive.LocalMachine, targetSubKey, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: production always watches HKLM; tests use a hive they may write to.</summary>
|
||||
internal RegistryChangeWatcher(RegistryHive hive, string targetSubKey, AgentLogger logger)
|
||||
{
|
||||
_hive = hive;
|
||||
_hiveLabel = hive == RegistryHive.LocalMachine ? "HKLM" : hive.ToString();
|
||||
_targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>The filter the full target watch is armed with (tests).</summary>
|
||||
internal uint EffectivePrimaryFilter => (uint)PrimaryFilter;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
bool raise = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_started || _disposed) return;
|
||||
@@ -81,24 +107,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
// Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise
|
||||
// resolve the stale WOW6432Node mirror).
|
||||
_baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
_baseKey = RegistryKey.OpenBaseKey(_hive, RegistryView.Registry64);
|
||||
_signal = new AutoResetEvent(false);
|
||||
ArmBestEffort(raiseOnPromote: false);
|
||||
raise = ArmBestEffort(targetIsNews: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] failed to start on {_hiveLabel}\\{_targetSubKey}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap
|
||||
/// name-only watch on the nearest existing ancestor until the target appears. Must hold <see cref="_lock"/>.
|
||||
/// Returns true when the target key appeared and the caller has to raise <see cref="Changed"/>
|
||||
/// for it — after releasing the lock.
|
||||
/// </summary>
|
||||
private void ArmBestEffort(bool raiseOnPromote)
|
||||
/// <param name="targetIsNews">False when finding the target right away is the expected start
|
||||
/// state; true when the watch was waiting for it.</param>
|
||||
private bool ArmBestEffort(bool targetIsNews)
|
||||
{
|
||||
if (_disposed || _baseKey == null) return;
|
||||
if (_disposed || _baseKey == null) return false;
|
||||
|
||||
CloseWatchedKey();
|
||||
|
||||
@@ -107,27 +139,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_watchedKey = target;
|
||||
_mode = WatchMode.Primary;
|
||||
if (!ArmNotify(subtree: true, PrimaryFilter)) return;
|
||||
if (raiseOnPromote) RaiseChanged();
|
||||
return;
|
||||
return ArmNotify(subtree: true, PrimaryFilter) && targetIsNews;
|
||||
}
|
||||
|
||||
// Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap).
|
||||
var ancestor = OpenNearestExistingAncestor(out var ancestorPath);
|
||||
if (ancestor == null)
|
||||
{
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming");
|
||||
return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] no existing ancestor for {_hiveLabel}\\{_targetSubKey}; not arming");
|
||||
return false;
|
||||
}
|
||||
_watchedKey = ancestor;
|
||||
_mode = WatchMode.Bootstrap;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return;
|
||||
_logger.Debug($"[RegistryChangeWatcher] target {_hiveLabel}\\{_targetSubKey} absent — bootstrap-watching {_hiveLabel}\\{ancestorPath} (name-only)", repeatKey: "RegistryChangeWatcher.bootstrap:" + _targetSubKey);
|
||||
if (!ArmNotify(subtree: false, BootstrapFilter)) return false;
|
||||
|
||||
// TOCTOU: the target may have been created between TryOpen and arming the ancestor watch.
|
||||
using (var recheck = TryOpen(_targetSubKey))
|
||||
{
|
||||
if (recheck != null) ArmBestEffort(raiseOnPromote: true);
|
||||
return recheck != null && ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true);
|
||||
if (rc != 0)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})");
|
||||
_logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} ({_hiveLabel}\\{_targetSubKey})");
|
||||
return false;
|
||||
}
|
||||
UnregisterWait();
|
||||
@@ -149,29 +179,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}");
|
||||
_logger.Warning($"[RegistryChangeWatcher] arm failed ({_hiveLabel}\\{_targetSubKey}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSignalled(object? state, bool timedOut)
|
||||
{
|
||||
bool raise;
|
||||
bool rearmAfterHandler;
|
||||
lock (_lock)
|
||||
{
|
||||
UnregisterWait();
|
||||
if (_disposed || timedOut) return;
|
||||
|
||||
if (_mode == WatchMode.Primary)
|
||||
{
|
||||
RaiseChanged();
|
||||
ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key
|
||||
}
|
||||
else
|
||||
{
|
||||
// A direct child changed under the ancestor — promote to the target watch if it now
|
||||
// exists (raises Changed on promotion), else keep the cheap bootstrap watch.
|
||||
ArmBestEffort(raiseOnPromote: true);
|
||||
}
|
||||
rearmAfterHandler = _mode == WatchMode.Primary;
|
||||
// Bootstrap: a direct child changed under the ancestor — promote to the target watch if
|
||||
// it now exists (then Changed is due), else keep the cheap bootstrap watch.
|
||||
raise = rearmAfterHandler || ArmBestEffort(targetIsNews: true);
|
||||
}
|
||||
|
||||
if (raise) RaiseChanged();
|
||||
|
||||
if (!rearmAfterHandler) return;
|
||||
lock (_lock)
|
||||
{
|
||||
// Re-arm on the same target key, and only now: no wait was registered while the handler
|
||||
// ran, so handlers never overlap, and a change that arrived meanwhile completes this
|
||||
// registration at once.
|
||||
ArmNotify(subtree: true, PrimaryFilter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
}
|
||||
}
|
||||
|
||||
// Never called with _lock held.
|
||||
private void RaiseChanged()
|
||||
{
|
||||
var handler = Changed;
|
||||
|
||||
@@ -67,6 +67,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <summary>Default Timeout für den Terminal-Drain beim Stop.</summary>
|
||||
public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long termination waits for ONE collector host to stop (and later to dispose). A whole
|
||||
/// healthy stop pass takes about 0.2 s and the slowest single host seen in the field 7 s, so
|
||||
/// this only ever ends the wait for a host that hangs.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultCollectorHostStopTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _stateDirectory;
|
||||
@@ -81,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly int _quarantineThreshold;
|
||||
private readonly TimeSpan _drainInterval;
|
||||
private readonly TimeSpan _terminalDrainTimeout;
|
||||
private readonly TimeSpan _collectorHostStopTimeout;
|
||||
private readonly TimeSpan? _agentMaxLifetime;
|
||||
private readonly int _uploadBatchSize;
|
||||
|
||||
@@ -124,6 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private SessionTraceOrdinalProvider? _traceCounter;
|
||||
private SignalIngress? _ingress;
|
||||
private IReadOnlyList<ICollectorHost>? _collectorHosts;
|
||||
// Hosts that did not come back from Stop or Dispose in time. A thread is still inside them,
|
||||
// so nothing calls into them again. Locked on itself.
|
||||
private readonly HashSet<ICollectorHost> _unresponsiveHosts = new HashSet<ICollectorHost>();
|
||||
|
||||
private EventHandler<DeadlineFiredEventArgs>? _deadlineBridge;
|
||||
|
||||
@@ -181,7 +192,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null,
|
||||
TimeSpan? collectorHostStopTimeout = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -211,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_quarantineThreshold = quarantineThreshold;
|
||||
_drainInterval = drainInterval ?? DefaultDrainInterval;
|
||||
_terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout;
|
||||
_collectorHostStopTimeout = collectorHostStopTimeout ?? DefaultCollectorHostStopTimeout;
|
||||
|
||||
if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set.");
|
||||
@@ -947,6 +960,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// diagnostics ZIP is built so periodic collectors (PerformanceCollector,
|
||||
/// AgentSelfMetricsCollector, …) don't keep emitting <c>performance_snapshot</c> /
|
||||
/// <c>agent_metrics_snapshot</c> events while the package is being assembled.
|
||||
/// <para>
|
||||
/// Bounded per host (<see cref="DefaultCollectorHostStopTimeout"/>): a host that hangs is
|
||||
/// left behind and the remaining hosts are still stopped. The diagnostics upload and the
|
||||
/// self-destruct come after this call, so it has to return whatever a collector does.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void StopCollectorHosts()
|
||||
{
|
||||
@@ -955,8 +973,94 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); }
|
||||
RunHostStepBounded(host, "stop", h => h.Stop());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one host's Stop or Dispose on a thread of its own and waits at most
|
||||
/// <see cref="_collectorHostStopTimeout"/> for it.
|
||||
/// </summary>
|
||||
private void RunHostStepBounded(ICollectorHost host, string step, Action<ICollectorHost> action)
|
||||
{
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
if (_unresponsiveHosts.Contains(host)) return;
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
try { action(host); }
|
||||
catch (Exception ex) { failure = ex; }
|
||||
})
|
||||
{
|
||||
// Not a ThreadPool thread: a host that hangs would keep one for good, and the pool may
|
||||
// be the very thing that is stuck. Background, so it cannot keep the process alive.
|
||||
IsBackground = true,
|
||||
Name = $"CollectorHost.{step}:{host.Name}",
|
||||
};
|
||||
try { worker.Start(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' {step} could not be started: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (worker.Join(_collectorHostStopTimeout))
|
||||
{
|
||||
if (failure == null) return;
|
||||
if (step == "stop")
|
||||
_logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {failure.Message}");
|
||||
else
|
||||
_logger.Debug($"EnrollmentOrchestrator: host '{host.Name}' {step} failed: {failure.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_unresponsiveHosts)
|
||||
{
|
||||
_unresponsiveHosts.Add(host);
|
||||
}
|
||||
var seconds = (int)Math.Ceiling(_collectorHostStopTimeout.TotalSeconds);
|
||||
_logger.Warning(
|
||||
$"EnrollmentOrchestrator: host '{host.Name}' did not return from {step} within {seconds}s — " +
|
||||
"termination continues without it.");
|
||||
if (step == "stop") EmitCollectorHostStopTimeout(host.Name, seconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>agent_trace</c> per host that hung in Stop, so the fleet can count it. Deliberately
|
||||
/// not <c>collector_degraded</c>: that one says the collector's signals are missing and gates
|
||||
/// analyze rules, while a host that will not stop has missed nothing. (A host that hangs in
|
||||
/// Dispose is logged only — the ingress is already down by then.)
|
||||
/// </summary>
|
||||
private void EmitCollectorHostStopTimeout(string hostName, int timeoutSeconds)
|
||||
{
|
||||
var ingress = _ingress;
|
||||
if (ingress == null) return;
|
||||
try
|
||||
{
|
||||
new InformationalEventPost(ingress, _clock).Emit(new Shared.Models.EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = Shared.Models.EventSeverity.Warning,
|
||||
Source = "EnrollmentOrchestrator",
|
||||
Phase = Shared.Models.EnrollmentPhase.Unknown,
|
||||
Message = $"Collector host '{hostName}' did not stop within {timeoutSeconds}s; termination continued without it.",
|
||||
Timestamp = _clock.UtcNow,
|
||||
Data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["decision"] = "collector_host_stop_timeout",
|
||||
["host"] = hostName,
|
||||
["timeoutSeconds"] = timeoutSeconds,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentOrchestrator: failed to post collector_host_stop_timeout: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,12 +1147,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); }
|
||||
|
||||
// 7) Dispose aller Disposables.
|
||||
// 7) Dispose all disposables. Hosts go through the same bound as in StopCollectorHosts;
|
||||
// one that is still stuck in Stop is skipped (its Dispose would wait on the same thing).
|
||||
if (_collectorHosts != null)
|
||||
{
|
||||
foreach (var host in _collectorHosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* best-effort */ }
|
||||
RunHostStepBounded(host, "dispose", h => h.Dispose());
|
||||
}
|
||||
}
|
||||
try { _transport?.Dispose(); } catch { /* best-effort */ }
|
||||
|
||||
+64
-23
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
@@ -49,9 +50,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly OfficeInstallDetector _detector;
|
||||
private readonly OfficeProcessWatcher _processWatcher;
|
||||
private readonly AgentLogger _logger;
|
||||
// Guards the fields below, nothing else. Never held across a call into a watcher or the
|
||||
// detector: their callbacks come back into this host and take it.
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private readonly OfficeInstallStateData? _resumeState;
|
||||
private readonly RegistryHive _registryHive;
|
||||
private readonly string _registrySubKey;
|
||||
private RegistryChangeWatcher? _registryWatcher;
|
||||
private OfficeBinaryWatcher? _binaryWatcher;
|
||||
private bool _pathObserved; // InstallationPath known → stop poking the DO collector
|
||||
@@ -74,16 +79,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence = null,
|
||||
OfficeInstallStateData? resumeState = null)
|
||||
: this(sessionId, tenantId, ingress, clock, logger, settleSeconds, statePersistence, resumeState,
|
||||
RegistryHive.LocalMachine, ClickToRunSubKey, snapshotProvider: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: points the registry watcher at a key a test may write to and feeds the
|
||||
/// detector synthetic snapshots. Production uses the constructor above.
|
||||
/// </summary>
|
||||
internal OfficeInstallDetectorHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
int settleSeconds,
|
||||
OfficeInstallStatePersistence? statePersistence,
|
||||
OfficeInstallStateData? resumeState,
|
||||
RegistryHive registryHive,
|
||||
string registrySubKey,
|
||||
Func<OfficeC2RSnapshot>? snapshotProvider)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_resumeState = resumeState;
|
||||
_registryHive = registryHive;
|
||||
_registrySubKey = registrySubKey ?? throw new ArgumentNullException(nameof(registrySubKey));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
_detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock,
|
||||
onInstallationPathObserved: OnInstallationPathObserved,
|
||||
statePersistence: statePersistence);
|
||||
statePersistence: statePersistence)
|
||||
{
|
||||
SnapshotProvider = snapshotProvider,
|
||||
};
|
||||
_processWatcher = new OfficeProcessWatcher(logger, settleSeconds);
|
||||
_processWatcher.Started += OnWorkerStarted;
|
||||
}
|
||||
@@ -108,12 +139,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Arm the registry watcher up front (not at worker-start) so the early Scenario\INSTALL key
|
||||
// is caught before the (late) worker process. The watcher's bootstrap fallback handles the
|
||||
// case where the ClickToRun key does not exist yet on a clean first install.
|
||||
lock (_lock)
|
||||
{
|
||||
_registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger);
|
||||
_registryWatcher.Changed += OnRegistryChanged;
|
||||
_registryWatcher.Start();
|
||||
}
|
||||
var registryWatcher = new RegistryChangeWatcher(_registryHive, _registrySubKey, _logger);
|
||||
registryWatcher.Changed += OnRegistryChanged;
|
||||
lock (_lock) { _registryWatcher = registryWatcher; }
|
||||
registryWatcher.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
// Resume a persisted *Active* lifecycle (enrollments span reboots) AFTER the watchers are
|
||||
@@ -134,7 +163,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// re-poking it on every registry value churn is pure log noise (field session 7da7dead).
|
||||
bool raise;
|
||||
lock (_lock) { raise = !_pathObserved && !_lifecycleEnded; }
|
||||
if (raise)
|
||||
// A callback can still be running after Dispose returned — Dispose never waits for one.
|
||||
if (raise && Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
try { OfficeExpected?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"[OfficeInstallDetectorHost] OfficeExpected handler threw: {ex.Message}"); }
|
||||
@@ -147,15 +177,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnInstallationPathObserved(string? installationPath)
|
||||
{
|
||||
OfficeBinaryWatcher watcher;
|
||||
lock (_lock)
|
||||
{
|
||||
_pathObserved = true; // stop raising OfficeExpected (lifecycle has started)
|
||||
if (_lifecycleEnded || _binaryWatcher != null || string.IsNullOrEmpty(installationPath)) return;
|
||||
if (Volatile.Read(ref _disposed) == 1) return;
|
||||
|
||||
_binaryWatcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
_binaryWatcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher.Start();
|
||||
watcher = new OfficeBinaryWatcher(installationPath!, OfficeInstallDetector.CoreBinaries, _logger);
|
||||
watcher.BinaryAppeared += OnBinaryAppeared;
|
||||
_binaryWatcher = watcher;
|
||||
}
|
||||
|
||||
// Start can raise BinaryAppeared right away (Office already on disk), and that handler
|
||||
// ends in DisposeWatchers.
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnBinaryAppeared(object? sender, EventArgs e)
|
||||
@@ -191,20 +227,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void DisposeWatchers()
|
||||
{
|
||||
RegistryChangeWatcher? registryWatcher;
|
||||
OfficeBinaryWatcher? binaryWatcher;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_registryWatcher != null)
|
||||
{
|
||||
try { _registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { _registryWatcher.Dispose(); } catch { }
|
||||
_registryWatcher = null;
|
||||
}
|
||||
if (_binaryWatcher != null)
|
||||
{
|
||||
try { _binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { _binaryWatcher.Dispose(); } catch { }
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
registryWatcher = _registryWatcher;
|
||||
_registryWatcher = null;
|
||||
binaryWatcher = _binaryWatcher;
|
||||
_binaryWatcher = null;
|
||||
}
|
||||
|
||||
if (registryWatcher != null)
|
||||
{
|
||||
try { registryWatcher.Changed -= OnRegistryChanged; } catch { }
|
||||
try { registryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
if (binaryWatcher != null)
|
||||
{
|
||||
try { binaryWatcher.BinaryAppeared -= OnBinaryAppeared; } catch { }
|
||||
try { binaryWatcher.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -324,7 +324,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// DeliveryOptimizationCollector, …) before the diagnostics ZIP is built so
|
||||
// late `performance_snapshot` / `agent_metrics_snapshot` events don't slip in
|
||||
// after `diagnostics_collecting` and the snapshot captured in the package
|
||||
// matches the timeline. Best-effort — never blocks termination.
|
||||
// matches the timeline. Best-effort, and bounded per host by the orchestrator:
|
||||
// a collector that hangs is left behind, so the upload and the cleanup below
|
||||
// still run.
|
||||
StopPeripheralCollectorsBestEffort();
|
||||
|
||||
RunUploadDiagnosticsWithEvents(args);
|
||||
|
||||
+8
-8
@@ -16,7 +16,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"jose": "^6.2.15",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.44.0",
|
||||
"knip": "^6.44.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
@@ -4289,9 +4289,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.14",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz",
|
||||
"integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==",
|
||||
"version": "6.2.15",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
|
||||
"integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -4351,9 +4351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"version": "6.44.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.1.tgz",
|
||||
"integrity": "sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"jose": "^6.2.15",
|
||||
"undici": "^8.12.3",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -40,7 +40,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.5",
|
||||
"eslint": "^9.39.5",
|
||||
"knip": "^6.44.0",
|
||||
"knip": "^6.44.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "Can I follow a Windows Autopilot enrollment live?",
|
||||
answer:
|
||||
"Yes. Assign the Autopilot Monitor bootstrapper script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
"Yes. Assign the Autopilot Monitor loader script to your Autopilot device groups in Intune. During enrollment the agent captures Enrollment Status Page (ESP) phases, app downloads and installs, errors, reboots, and performance snapshots and streams them to the portal as they happen. You see every device's progress without refreshing the page or touching the device.",
|
||||
},
|
||||
{
|
||||
question: "Why did my Autopilot enrollment fail, and how do I find out without touching the device?",
|
||||
@@ -67,7 +67,7 @@ export const ABOUT_FAQ: AboutFaqItem[] = [
|
||||
{
|
||||
question: "How is the agent deployed, and does it stay on the device?",
|
||||
answer:
|
||||
"The agent is a small .NET binary installed by an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation it ships as a thin MSI line-of-business app. It runs as a scheduled task, not a Windows service, authenticates with the existing Intune MDM device certificate, and exists only for the duration of the enrollment: by default it removes its task and files on completion, and it never runs longer than six hours.",
|
||||
"The agent is a small .NET binary installed by an Intune platform script (a PowerShell loader); for Autopilot Device Preparation it ships as a thin MSI line-of-business app. It runs as a scheduled task, not a Windows service, authenticates with the existing Intune MDM device certificate, and exists only for the duration of the enrollment: by default it removes its task and files on completion, and it never runs longer than six hours.",
|
||||
},
|
||||
{
|
||||
question: "Which Autopilot scenarios are supported?",
|
||||
|
||||
@@ -203,16 +203,16 @@ export default function AboutPage() {
|
||||
<section className="bg-[var(--lp-surface)] border border-[var(--lp-line-soft)] rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">How It Works</h2>
|
||||
<p className="text-gray-700 leading-relaxed mb-5">
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices via an Intune bootstrapper
|
||||
script. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
Autopilot Monitor uses a lightweight .NET agent deployed to devices by a loader script you
|
||||
assign in Intune. The agent monitors the enrollment process in real time and streams telemetry events
|
||||
— including ESP phases, app installs, performance snapshots, and custom gather rule data —
|
||||
to the Azure-hosted backend pipeline. The portal displays live session data, runs analyze rules
|
||||
automatically, and alerts on failure conditions.
|
||||
</p>
|
||||
<ol className="space-y-3">
|
||||
{[
|
||||
{ step: "1", text: "Assign the bootstrapper PowerShell script to your Autopilot device groups in Intune." },
|
||||
{ step: "2", text: "The bootstrapper installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "1", text: "Assign the loader script (PowerShell) to your Autopilot device groups in Intune." },
|
||||
{ step: "2", text: "The script installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "3", text: "The agent captures live enrollment events and uploads them to the backend pipeline." },
|
||||
{ step: "4", text: "The portal displays real-time session data, analyze rule results, and fleet health metrics." },
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
|
||||
@@ -178,7 +178,7 @@ export function SetupChecklistCard({ setup }: { setup: SetupChecklistState }) {
|
||||
</button>
|
||||
)}
|
||||
{readOnlyReason === "view-as" && <span className="text-xs text-gray-500 dark:text-gray-400">View as is read-only.</span>}
|
||||
<span className="ml-auto text-xs text-gray-400">About 10 minutes, Intune included</span>
|
||||
<span className="ml-auto text-xs text-gray-400">A few minutes, Intune included</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DOCS_URL, SITE_URL } from "@/lib/config";
|
||||
export const metadata: Metadata = {
|
||||
title: "Get started",
|
||||
description:
|
||||
"From sign-in to your first live Autopilot enrollment in five steps: sign in, request access, grant consent, deploy the bootstrapper via Intune, watch live.",
|
||||
"From sign-in to your first live Autopilot enrollment in four steps: sign in, follow the setup guide, add one script in Intune, watch live.",
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/get-started`,
|
||||
},
|
||||
@@ -18,33 +18,24 @@ export const metadata: Metadata = {
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Sign in with Microsoft",
|
||||
description:
|
||||
"Use your work account — there is no signup form. The first sign-in creates nothing in your tenant.",
|
||||
note: "30 seconds",
|
||||
description: "Use your work account. Your tenant is usually ready a minute or two later.",
|
||||
note: "No signup form",
|
||||
},
|
||||
{
|
||||
title: "Tenant activation",
|
||||
title: "Follow the setup guide",
|
||||
description:
|
||||
"Every new organization goes through a short activation step — you'll see the status right after signing in, and it usually completes within a couple of minutes.",
|
||||
note: "Usually a few minutes",
|
||||
"A checklist on your dashboard: recommended settings in one click, your hardware models, one admin consent.",
|
||||
note: "A few minutes",
|
||||
},
|
||||
{
|
||||
title: "Grant consent once",
|
||||
description:
|
||||
"Approve the Entra ID application for your tenant so the portal can operate. One admin consent, fully revocable.",
|
||||
note: "One-time admin consent",
|
||||
},
|
||||
{
|
||||
title: "Deploy the bootstrapper via Intune",
|
||||
description:
|
||||
"Assign one PowerShell script to your Autopilot scope. That is the whole rollout — no servers, no gateways, no infrastructure on your side.",
|
||||
note: "One script assignment",
|
||||
title: "Add one script in Intune",
|
||||
description: "Assign one PowerShell script to your Autopilot devices. No servers, no gateways.",
|
||||
note: "The whole rollout",
|
||||
},
|
||||
{
|
||||
title: "Watch your first enrollment live",
|
||||
description:
|
||||
"The next device that enrolls streams in minutes later: phases, app installs, analysis results, diagnostics on demand.",
|
||||
note: "Minutes later",
|
||||
description: "Enroll a device and follow it on your dashboard as it happens: phases, app installs, errors.",
|
||||
note: "In real time",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -60,11 +51,10 @@ export default function GetStartedPage() {
|
||||
Get started
|
||||
</p>
|
||||
<h1 className="mt-4 text-4xl sm:text-5xl font-bold tracking-tight text-[var(--lp-ink)] leading-[1.08] text-balance">
|
||||
From sign-in to your first live enrollment.
|
||||
From <span className="whitespace-nowrap">sign-in</span> to your first live enrollment.
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--lp-ink-soft)] leading-relaxed max-w-xl">
|
||||
Five steps, no infrastructure on your side. Most teams see their first live session
|
||||
the same day.
|
||||
Four steps, a few minutes, no infrastructure on your side.
|
||||
</p>
|
||||
|
||||
{/* Step sequence */}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { Reveal } from "./Reveal";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Sign in & grant access",
|
||||
description: "Authenticate with Microsoft and approve tenant access once.",
|
||||
title: "Sign in with Microsoft",
|
||||
description: "Use your work account. No signup form.",
|
||||
},
|
||||
{
|
||||
title: "Deploy the bootstrapper via Intune",
|
||||
description: "Assign one PowerShell script to your Autopilot scope. That's the whole rollout.",
|
||||
title: "Add one script in Intune",
|
||||
description: "A setup guide in the portal walks you through it in a few minutes. That's the whole rollout.",
|
||||
},
|
||||
{
|
||||
title: "Watch your first enrollment live",
|
||||
description: "Phases, apps, failures, and diagnostics stream in minutes later.",
|
||||
description: "Phases, apps, failures and diagnostics, as they happen.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function Story() {
|
||||
text: (
|
||||
<>
|
||||
<p>
|
||||
A new hire unboxes their laptop three time zones away. The bootstrapper you assigned
|
||||
A new hire unboxes their laptop three time zones away. The script you assigned
|
||||
once in Intune kicks in, and the enrollment appears on your dashboard — live.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -303,7 +303,7 @@ export function ChannelEditor({
|
||||
<span className="text-gray-700 font-medium text-sm">{isEmail ? "Recipients" : "Webhook URL"}</span>
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
{channel.providerType === 2
|
||||
? "Create a Workflow in Teams (Channel → Manage channel → Workflows → \"Post to a channel when a webhook request is received\") and paste the URL here."
|
||||
? "Create a Workflow in Teams (… next to the channel → Workflows → \"Send webhook alerts from anyone to Teams\") and paste the URL here. Variants limited to specific people or your org reject these alerts."
|
||||
: channel.providerType === 10
|
||||
? "Create an Incoming Webhook in Slack (Apps → Incoming Webhooks → Add to channel) and paste the URL here."
|
||||
: channel.providerType === GENERIC_PROVIDER
|
||||
|
||||
@@ -127,13 +127,13 @@ export function selectionUsesHostedStorage(config: TenantConfiguration, selected
|
||||
// ── Step 2: hardware ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One-click additions, ordered by how many tenants had devices of that manufacturer rejected.
|
||||
* Virtual machines report their own manufacturer; Hyper-V reports Microsoft Corporation, which
|
||||
* the default list already covers.
|
||||
* One-click additions: well-known manufacturers outside the default list. Virtual machines report
|
||||
* their own manufacturer; Hyper-V reports Microsoft Corporation, which the default list already
|
||||
* covers.
|
||||
*/
|
||||
export const QUICK_MANUFACTURERS = {
|
||||
virtualMachines: ["VMware*", "QEMU*", "Proxmox*", "Parallels*"],
|
||||
hardware: ["ASUS*", "Getac*", "Acer*", "Fujitsu*", "Panasonic*"],
|
||||
hardware: ["ASUS*", "Samsung*", "Acer*", "Fujitsu*", "Panasonic*"],
|
||||
} as const;
|
||||
|
||||
// ── Step 4: the agent ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+56
-56
@@ -16,7 +16,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.4.3",
|
||||
"next": "^16.4.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
@@ -25,15 +25,15 @@
|
||||
"web-vitals": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.4.3",
|
||||
"@next/bundle-analyzer": "^16.4.4",
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"knip": "^6.44.0",
|
||||
"eslint-config-next": "^16.4.4",
|
||||
"knip": "^6.44.1",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -1363,9 +1363,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/bundle-analyzer": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.4.3.tgz",
|
||||
"integrity": "sha512-FGcLziT2rqvGZTxZUjnCzwjCANnigN9vrbCm+1hQtzXbbtc1bXBTRmkXohVqyB/ReF5bOjpM2bNJQhz0PfLk9Q==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.4.4.tgz",
|
||||
"integrity": "sha512-lStTzXqm6lboFH0wUEVoNzsm75AI5P+5G2kUKZqkyBYb97LD10J30zei0Pl38RO3faODl3uF6KT/Husue8QUTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1373,15 +1373,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.3.tgz",
|
||||
"integrity": "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.4.tgz",
|
||||
"integrity": "sha512-6/3ZuELR0tPLgNigvv8d/LAmfLIo1IpNfvrI/AHCAl9w9D0DNVGQGvo+OLPzAWR+WMW3vVyO7HaW0kfL97huBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.3.tgz",
|
||||
"integrity": "sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.4.tgz",
|
||||
"integrity": "sha512-O3ZZhL+PMlqa8xgg8Sx/UeG1BGgl/jOGkyRZFC8pDZTNYBoIbbUZtwDotNR+aGsnROE321lOQ+MfwFjdVFz+FA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1390,9 +1390,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.3.tgz",
|
||||
"integrity": "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.4.tgz",
|
||||
"integrity": "sha512-1KlZ5IJ7zcLBZvqG1q7nuRWm2LAAN4C3iBqqoBCRRl1UEv0A4k6Rek+x3j23UiWf8Gz+VsN+M/LylAWkxpm2mg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1406,9 +1406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.3.tgz",
|
||||
"integrity": "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.4.tgz",
|
||||
"integrity": "sha512-Tb7efFdhbS/ph74RS7wob84gXGM58qxoO/WrkiMsTmy/JZktKOKBeiM45W0E/b8JfBzZJdexQqSmvedBwDK0PA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1422,9 +1422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.3.tgz",
|
||||
"integrity": "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.4.tgz",
|
||||
"integrity": "sha512-wojSfLnH8RRImhtbcn3Dlx9JICh6aQHgDA33XDr+EPua2tt7Q/yjDn0FTAbqfNlLrBmOASAGhLh6NvBk9L3gzw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1441,9 +1441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.3.tgz",
|
||||
"integrity": "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.4.tgz",
|
||||
"integrity": "sha512-zgxpGOZaR+2O6dOk5ZAUkKgH92ZtqNg3ohzyg2HmWc2N+AZH4dEpTwdpB0e5MR9YRVdVDUkEM5g6c3TWWn8g1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1460,9 +1460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.3.tgz",
|
||||
"integrity": "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.4.tgz",
|
||||
"integrity": "sha512-fg3Wr030Y3BDkCXi6HGsPz0BUPTGZJS8TZSrCO0tS3O/KBsCyx4eP6aeaizCpGmcUKhtb4l7JoMc88QO2qBl2g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1479,9 +1479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.3.tgz",
|
||||
"integrity": "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.4.tgz",
|
||||
"integrity": "sha512-40cr71PSdCV9zU2g0B1XG27af10tYU+9g8F+FtF3qSguQg0jSZDoxn0NrAvgFQ0cy9s0ZBA/00P9g/XL1O43xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1498,9 +1498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.3.tgz",
|
||||
"integrity": "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.4.tgz",
|
||||
"integrity": "sha512-vFMiEbRZ+LYNeswslf4LuGuoPHE8JbtqV0GnmzbpnGNMs0uWaR2q98hu8M/UWGycNJERBXIUss+BGd3PIJ8VZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4822,13 +4822,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.3.tgz",
|
||||
"integrity": "sha512-j9IqBma6abp800frdUURaizYMgXOK+jNGyq6CsYcM9AxpSJs5D/TgMNGeAmRUilZvA7g9V1Tr71gNg9/SVTuMQ==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.4.tgz",
|
||||
"integrity": "sha512-r+rPQU1uaKDXWWWro6hDk3VrtBjmRyDz7UySTe9CU8HHG4eTXXLSHxasSwpOz/7FJZZuWBVcFERpjWszu04BbQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.4.3",
|
||||
"@next/eslint-plugin-next": "16.4.4",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -6489,9 +6489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/knip": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
|
||||
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
|
||||
"version": "6.44.1",
|
||||
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.1.tgz",
|
||||
"integrity": "sha512-Hbd8nvkIsNgFfXgC7Jsb5LHv7jj6VwA3tmfHd53XPX2z9Vc9RnAZqV0BIiP4OrtYqKwCoLseFHQZerb1bNlbSw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -7072,12 +7072,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.4.3.tgz",
|
||||
"integrity": "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.4.4.tgz",
|
||||
"integrity": "sha512-Hu4pOw2x0RQ0TQabNWbpsNg/3+SZf+rLQwvfhBLnptv98GS4dcIVMlfEyn9jb2/ZJDSahhnUKEs0YrfarjGX3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.4.3",
|
||||
"@next/env": "16.4.4",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -7091,14 +7091,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.4.3",
|
||||
"@next/swc-darwin-x64": "16.4.3",
|
||||
"@next/swc-linux-arm64-gnu": "16.4.3",
|
||||
"@next/swc-linux-arm64-musl": "16.4.3",
|
||||
"@next/swc-linux-x64-gnu": "16.4.3",
|
||||
"@next/swc-linux-x64-musl": "16.4.3",
|
||||
"@next/swc-win32-arm64-msvc": "16.4.3",
|
||||
"@next/swc-win32-x64-msvc": "16.4.3",
|
||||
"@next/swc-darwin-arm64": "16.4.4",
|
||||
"@next/swc-darwin-x64": "16.4.4",
|
||||
"@next/swc-linux-arm64-gnu": "16.4.4",
|
||||
"@next/swc-linux-arm64-musl": "16.4.4",
|
||||
"@next/swc-linux-x64-gnu": "16.4.4",
|
||||
"@next/swc-linux-x64-musl": "16.4.4",
|
||||
"@next/swc-win32-arm64-msvc": "16.4.4",
|
||||
"@next/swc-win32-x64-msvc": "16.4.4",
|
||||
"sharp": "^0.35.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -7125,9 +7125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.3.tgz",
|
||||
"integrity": "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==",
|
||||
"version": "16.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.4.tgz",
|
||||
"integrity": "sha512-jy2P4V8BDZ+CFCzYABHYRZ1CmyFhfmYe22i8KA4uEafdYPrUjJ3Ot+lx265mLWLHiZRIfBzCn3V1F17m0vzl9Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.4.3",
|
||||
"next": "^16.4.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
@@ -36,15 +36,15 @@
|
||||
"web-vitals": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.4.3",
|
||||
"@next/bundle-analyzer": "^16.4.4",
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.6.2",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^16.4.3",
|
||||
"knip": "^6.44.0",
|
||||
"eslint-config-next": "^16.4.4",
|
||||
"knip": "^6.44.1",
|
||||
"postcss": "^8.5.32",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
Reference in New Issue
Block a user