mcp-pacemaker 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +98 -2
  2. package/CONTRIBUTING.md +10 -3
  3. package/README.md +98 -760
  4. package/autostart/windows/legacy-access.ps1 +359 -0
  5. package/bin/account-upgrade-controller.mjs +1 -1
  6. package/bin/account-upgrade-worker.mjs +7 -0
  7. package/bin/cli-main.mjs +61 -5
  8. package/bin/dependency-lock.json +642 -0
  9. package/bin/legacy-access.mjs +73 -0
  10. package/bin/legacy-installation.mjs +75 -19
  11. package/bin/legacy-task.mjs +24 -0
  12. package/bin/legacy-upgrade.mjs +66 -10
  13. package/bin/managed-upgrade.mjs +102 -12
  14. package/bin/mcp-bridge.mjs +38 -20
  15. package/bin/prewarm.mjs +28 -6
  16. package/bin/service-control.mjs +10 -5
  17. package/bin/upgrade-capability.json +2 -1
  18. package/bin/upgrade-dependencies.mjs +67 -6
  19. package/bin/upgrade-package.mjs +2 -0
  20. package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
  21. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  22. package/bin/windows-legacy/src/LegacyNative.cs +60 -11
  23. package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
  24. package/bin/windows-legacy-process.mjs +12 -5
  25. package/docs/account-upgrades.md +17 -4
  26. package/docs/compatibility.md +39 -0
  27. package/docs/configuration.md +146 -0
  28. package/docs/dashboards.md +125 -0
  29. package/docs/execution-context.md +11 -5
  30. package/docs/http-api.md +47 -0
  31. package/docs/install.md +52 -0
  32. package/docs/operations.md +99 -0
  33. package/docs/upgrades.md +324 -0
  34. package/package.json +10 -3
  35. package/tools/windows-legacy/README.md +118 -92
  36. package/tools/windows-legacy/inventory.mjs +8 -6
  37. package/ui/dist/third-party-manifest.json +1 -1
@@ -14,14 +14,33 @@ using System.Web.Script.Serialization;
14
14
 
15
15
  internal sealed class LegacyProcessBroker
16
16
  {
17
+ // A plan names only the two roots. Plans written by 2.1.0 also list up to 510 descendants,
18
+ // and recovery still verifies those, so plan bounds stay at their 2.1.0 values.
19
+ private const int MaximumPlanIdentities = 512;
20
+ private const int MaximumPlanBytes = 240 * 1024;
21
+ private const int MaximumMessageBytes = 262144;
22
+ // One query reads both roots' command lines in each phase.
23
+ private const int MaximumArgumentQueries = 1;
24
+ // Descendants are found while stopping. One busy 1.3 host had 533 processes in its tree.
25
+ private const int MaximumSweptProcesses = 2048;
26
+ private const int MaximumSweepRounds = 16;
27
+ private const int MaximumPinPasses = 8;
28
+ private const long RequestMilliseconds = 15000;
29
+ private const long PhaseMilliseconds = 120000;
30
+ private const long StopMilliseconds = 20000;
31
+ private const long WatchdogMarginMilliseconds = 10000;
17
32
  private readonly LegacyNative native = new LegacyNative();
18
- private readonly JavaScriptSerializer json = new JavaScriptSerializer { MaxJsonLength = 262144, RecursionLimit = 20 };
33
+ private readonly JavaScriptSerializer json = new JavaScriptSerializer { MaxJsonLength = MaximumMessageBytes, RecursionLimit = 20 };
19
34
  private readonly List<Held> held = new List<Held>();
20
- private readonly List<ExcludedIdentity> excluded = new List<ExcludedIdentity>();
35
+ private readonly HashSet<string> rejected = new HashSet<string>();
36
+ // The processes that started this broker, by PID and creation time; the sweep never adopts them.
37
+ private readonly Dictionary<int, long> ancestors = new Dictionary<int, long>();
38
+ private readonly int brokerId = Process.GetCurrentProcess().Id;
39
+ private bool swept;
21
40
  private readonly List<FileStream> scripts = new List<FileStream>();
22
41
  private readonly string user = WindowsIdentity.GetCurrent().User.Value;
23
42
  private readonly Stopwatch clock = Stopwatch.StartNew();
24
- private long deadline = 15000;
43
+ private long deadline = RequestMilliseconds;
25
44
  private volatile bool committed;
26
45
  private string stage = "request";
27
46
  private string root;
@@ -31,7 +50,6 @@ internal sealed class LegacyProcessBroker
31
50
  private object captureFailure;
32
51
  private LegacySnapshot snapshot;
33
52
  private LegacyArgumentQueries arguments;
34
- private bool revalidating;
35
53
  private readonly Stopwatch phaseClock = new Stopwatch();
36
54
  private object captureDiagnostics;
37
55
  private object revalidationDiagnostics;
@@ -51,6 +69,9 @@ internal sealed class LegacyProcessBroker
51
69
  Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false, true)) { AutoFlush = true });
52
70
  StartWatchdog();
53
71
  var request = Read();
72
+ // Phase budgets start once the request is read, so a slow broker start on a loaded
73
+ // host does not consume the capture budget.
74
+ Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + PhaseMilliseconds + WatchdogMarginMilliseconds);
54
75
  if (request.action == "verify")
55
76
  {
56
77
  ValidatePlan(request.expected);
@@ -68,20 +89,18 @@ internal sealed class LegacyProcessBroker
68
89
  stage = "capture-roots";
69
90
  phaseClock.Start();
70
91
  arguments = new LegacyArgumentQueries(CheckPhase);
71
- arguments.BeginPhase(48);
92
+ arguments.BeginPhase(MaximumArgumentQueries);
72
93
  Held bridge;
73
94
  Held supervisor;
74
95
  using (var images = new LegacyImageCache(CheckPhase))
75
96
  {
76
- bridge = Capture(native.Listener(port), null);
77
- supervisor = Capture(bridge.Info.parentPid, null);
97
+ bridge = Capture(native.Listener(port));
98
+ supervisor = Capture(bridge.Info.parentPid);
78
99
  if (bridge.Info.pid == supervisor.Info.pid ||
79
100
  bridge.Created < supervisor.Created) throw new InvalidOperationException();
80
101
  bridge.Info.parentCreationTime = supervisor.Info.creationTime;
81
102
  bridge.KeepArguments = true;
82
103
  supervisor.KeepArguments = true;
83
- bridge.SelectedRoot = true;
84
- supervisor.SelectedRoot = true;
85
104
  var roots = new List<Held> { bridge, supervisor };
86
105
  CaptureFacts(roots, images);
87
106
  CheckRootArguments(bridge, supervisor);
@@ -89,9 +108,7 @@ internal sealed class LegacyProcessBroker
89
108
  if (!snapshot.Contains(bridge.Info.pid, supervisor.Info.pid) ||
90
109
  !snapshot.Contains(supervisor.Info.pid, supervisor.Info.parentPid))
91
110
  throw new InvalidOperationException("Snapshot root changed");
92
- CaptureChildren(supervisor, 0);
93
- var descendants = held.FindAll(value => value != bridge && value != supervisor);
94
- CaptureFacts(descendants, images);
111
+ CheckAncestors(bridge, supervisor);
95
112
  if (!native.Alive(supervisor.Handle) ||
96
113
  !native.Alive(bridge.Handle) ||
97
114
  native.Listener(port) != bridge.Info.pid) throw new InvalidOperationException("Root changed");
@@ -104,8 +121,7 @@ internal sealed class LegacyProcessBroker
104
121
  imageBytesHashed = images.Bytes, planUtf8Bytes = planBytes, memory = native.Memory() };
105
122
  }
106
123
  if (request.expected != null &&
107
- json.Serialize(request.expected) != json.Serialize(plan))
108
- throw new InvalidOperationException("Plan changed");
124
+ !SameRoots(request.expected, plan)) throw new InvalidOperationException("Plan changed");
109
125
  token = NewToken();
110
126
  Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + 120000);
111
127
  Write(new { ok = true, plan, token, diagnostics = captureDiagnostics, brokerIdentity });
@@ -126,16 +142,15 @@ internal sealed class LegacyProcessBroker
126
142
  request.token != token ||
127
143
  request.planSha256 != plan.planSha256) throw new ArgumentException();
128
144
  stage = "revalidate";
129
- revalidating = true;
130
145
  phaseClock.Restart();
131
- Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + 15000);
132
- arguments.BeginPhase(32);
146
+ Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + PhaseMilliseconds + WatchdogMarginMilliseconds);
147
+ arguments.BeginPhase(MaximumArgumentQueries);
133
148
  Revalidate(supervisor, bridge);
134
149
  arguments.Dispose();
135
150
  arguments = null;
136
151
  committed = true;
137
152
  stage = "stop";
138
- Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + 12000);
153
+ Interlocked.Exchange(ref deadline, clock.ElapsedMilliseconds + StopMilliseconds + WatchdogMarginMilliseconds);
139
154
  var stopWatch = Stopwatch.StartNew();
140
155
  var receipt = Stop(supervisor, bridge);
141
156
  Write(new { ok = true, receipt, diagnostics = new {
@@ -205,14 +220,14 @@ internal sealed class LegacyProcessBroker
205
220
  private Request Read()
206
221
  {
207
222
  var line = new StringBuilder();
208
- while (line.Length < 262144)
223
+ while (line.Length < MaximumMessageBytes)
209
224
  {
210
225
  int value = Console.In.Read();
211
226
  if (value < 0) throw new EndOfStreamException();
212
227
  if (value == 10)
213
228
  {
214
229
  var text = line.ToString();
215
- if (Encoding.UTF8.GetByteCount(text) + 1 > 262144) throw new ArgumentException("Request bounds");
230
+ if (Encoding.UTF8.GetByteCount(text) + 1 > MaximumMessageBytes) throw new ArgumentException("Request bounds");
216
231
  return json.Deserialize<Request>(text);
217
232
  }
218
233
  line.Append((char)value);
@@ -226,26 +241,13 @@ internal sealed class LegacyProcessBroker
226
241
  Console.Out.Flush();
227
242
  }
228
243
 
229
- private Held Capture(int id, Held parent)
244
+ private Held Capture(int id)
230
245
  {
231
246
  CheckPhase();
232
- var known = held.Find(value => value.Info.pid == id);
233
- if (known != null)
234
- {
235
- if (!native.Alive(known.Handle) ||
236
- native.Creation(known.Handle) != known.Created ||
237
- native.Parent(known.Handle) != known.Info.parentPid ||
238
- native.Owner(known.Handle) != user) throw new InvalidOperationException("Capture changed");
239
- if (parent != null &&
240
- !KnownEdge(known, parent)) return null;
241
- return known;
242
- }
243
- if (held.Count + excluded.Count >= 512) throw new InvalidOperationException("Capture bound");
244
247
  var substage = LegacyFailureStage.CaptureOpenQuery;
245
248
  long? observedBirth = null;
246
249
  int? observedParent = null;
247
250
  bool? observedAlive = null;
248
- bool? observedParentAlive = null;
249
251
  bool? observedOwnerMatch = null;
250
252
  var handle = Api.OpenProcess(0x00100000 | 0x1000, false, id);
251
253
  try
@@ -260,40 +262,6 @@ internal sealed class LegacyProcessBroker
260
262
  substage = LegacyFailureStage.CaptureParent;
261
263
  int parentId = native.Parent(handle);
262
264
  observedParent = parentId;
263
- if (parent != null)
264
- {
265
- bool parentAlive;
266
- try { parentAlive = native.Alive(parent.Handle); }
267
- catch (Exception error)
268
- {
269
- native.TagFailure(error, LegacyFailureStage.CaptureParentLiveness,
270
- parent.Info.pid, parent.Created, parent.Info.parentPid, ownerMatched: true);
271
- throw;
272
- }
273
- observedParentAlive = parentAlive;
274
- bool bornBeforeParent = created < parent.Created;
275
- bool changedEdge = parentId != parent.Info.pid;
276
- if (!parentAlive ||
277
- changedEdge)
278
- {
279
- captureFailure = new { pid = id, creationTime = created.ToString(CultureInfo.InvariantCulture),
280
- actualParentPid = parentId,
281
- expectedParentPid = parent.Info.pid, parentCreationTime = parent.Info.creationTime,
282
- parentAlive, bornBeforeParent, changedEdge };
283
- throw new InvalidOperationException("Unproven ancestry");
284
- }
285
- if (bornBeforeParent)
286
- {
287
- // A reused creator PID is not ownership of its older survivors.
288
- // This candidate never receives terminate access or enters held.
289
- excluded.Add(new ExcludedIdentity { pid = id,
290
- creationTime = created.ToString(CultureInfo.InvariantCulture),
291
- parentPid = parentId, parentCreationTime = parent.Info.creationTime,
292
- reason = "predates-held-parent" });
293
- handle.Dispose();
294
- return null;
295
- }
296
- }
297
265
  substage = LegacyFailureStage.CaptureOwner;
298
266
  var owner = native.Owner(handle);
299
267
  observedOwnerMatch = owner == user;
@@ -313,8 +281,7 @@ internal sealed class LegacyProcessBroker
313
281
  substage = LegacyFailureStage.CaptureImage;
314
282
  var image = native.Image(handle);
315
283
  var info = new Identity { pid = id, creationTime = created.ToString(CultureInfo.InvariantCulture),
316
- parentPid = parentId, parentCreationTime = parent == null ? null : parent.Info.creationTime,
317
- ownerSid = owner, imagePath = image };
284
+ parentPid = parentId, ownerSid = owner, imagePath = image };
318
285
  if (!native.Alive(handle)) throw new InvalidOperationException("Capture changed");
319
286
  var result = new Held { Handle = handle, Info = info, Created = created };
320
287
  held.Add(result);
@@ -323,52 +290,15 @@ internal sealed class LegacyProcessBroker
323
290
  catch (Exception error)
324
291
  {
325
292
  native.TagFailure(error, substage, id, observedBirth, observedParent,
326
- parent == null ? (int?)null : parent.Info.pid, parent == null ? null : parent.Info.creationTime,
327
- observedAlive, observedParentAlive, observedOwnerMatch);
293
+ alive: observedAlive, ownerMatched: observedOwnerMatch);
328
294
  handle.Dispose();
329
295
  throw;
330
296
  }
331
297
  }
332
298
 
333
- private bool KnownEdge(Held child, Held parent)
334
- {
335
- CheckPhase();
336
- if (!native.Alive(child.Handle) ||
337
- !native.Alive(parent.Handle) ||
338
- native.Creation(child.Handle) != child.Created ||
339
- native.Creation(parent.Handle) != parent.Created ||
340
- native.Parent(child.Handle) != parent.Info.pid ||
341
- child.Info.parentPid != parent.Info.pid ||
342
- native.Owner(child.Handle) != user ||
343
- native.Owner(parent.Handle) != user)
344
- throw new InvalidOperationException("Ancestry changed");
345
- if (child.Created < parent.Created)
346
- {
347
- // The selected root stays held; only this false reused-creator edge is ignored.
348
- if (child.SelectedRoot) return false;
349
- throw new InvalidOperationException("Unproven ancestry");
350
- }
351
- if (child.Info.parentCreationTime != parent.Info.creationTime)
352
- throw new InvalidOperationException("Ancestry changed");
353
- return true;
354
- }
355
-
356
- private List<int> Children(int parentId)
357
- {
358
- CheckPhase();
359
- var parent = held.Find(value => value.Info.pid == parentId);
360
- if (snapshot == null ||
361
- parent == null ||
362
- !native.Alive(parent.Handle)) throw new InvalidOperationException("Unproven ancestry");
363
- var ids = snapshot.Children(parentId);
364
- ids.Sort();
365
- return ids;
366
- }
367
-
368
299
  private void CheckPhase()
369
300
  {
370
- if ((!revalidating && clock.ElapsedMilliseconds > 14000) ||
371
- (revalidating && phaseClock.ElapsedMilliseconds >= 15000))
301
+ if (phaseClock.ElapsedMilliseconds >= PhaseMilliseconds)
372
302
  throw new InvalidOperationException("Capture bound");
373
303
  }
374
304
 
@@ -409,25 +339,12 @@ internal sealed class LegacyProcessBroker
409
339
  try
410
340
  {
411
341
  int bytes = Encoding.UTF8.GetByteCount(json.Serialize(value));
412
- if (bytes > 240 * 1024) throw new InvalidOperationException("Plan byte bound");
342
+ if (bytes > MaximumPlanBytes) throw new InvalidOperationException("Plan byte bound");
413
343
  return bytes;
414
344
  }
415
345
  catch (InvalidOperationException) { throw new InvalidOperationException("Plan byte bound"); }
416
346
  }
417
347
 
418
- private void CaptureChildren(Held parent, int depth)
419
- {
420
- if (depth >= 8) throw new InvalidOperationException("Depth bound");
421
- foreach (int id in Children(parent.Info.pid))
422
- {
423
- if (id == parent.Info.pid) throw new InvalidOperationException("Ancestry cycle");
424
- var child = Capture(id, parent);
425
- if (child == null) continue;
426
- child.Info.parentCreationTime = parent.Info.creationTime;
427
- CaptureChildren(child, depth + 1);
428
- }
429
- }
430
-
431
348
  private void CheckRootArguments(Held bridge, Held supervisor)
432
349
  {
433
350
  var bridgeScript = Path.Combine(root, "bin", "mcp-bridge.mjs");
@@ -457,6 +374,30 @@ internal sealed class LegacyProcessBroker
457
374
  supervisor.Info.scriptSha256 = HoldScript(supervisorScript);
458
375
  }
459
376
 
377
+ // The upgrade cannot stop the bridge it runs under. A CLI started from inside the 1.3
378
+ // tree, for example through an MCP server's shell, is refused here before any change.
379
+ private void CheckAncestors(Held bridge, Held supervisor)
380
+ {
381
+ int id = Process.GetCurrentProcess().Id;
382
+ long childCreated = long.MaxValue;
383
+ for (int depth = 0; depth < 64; depth++)
384
+ {
385
+ using (var process = Api.OpenProcess(0x1000, false, id))
386
+ {
387
+ if (process.IsInvalid) return;
388
+ long created = native.Creation(process);
389
+ // A parent PID that now names a newer process is not this process's parent.
390
+ if (created > childCreated) return;
391
+ bool insideRoots = id == bridge.Info.pid ||
392
+ id == supervisor.Info.pid;
393
+ if (insideRoots) throw new InvalidOperationException("Run the upgrade from a terminal the 1.3 bridge did not start");
394
+ ancestors[id] = created;
395
+ childCreated = created;
396
+ id = native.Parent(process);
397
+ }
398
+ }
399
+ }
400
+
460
401
  private string HoldScript(string path)
461
402
  {
462
403
  var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
@@ -502,21 +443,27 @@ internal sealed class LegacyProcessBroker
502
443
 
503
444
  private Plan MakePlan(Held supervisor, Held bridge)
504
445
  {
505
- var observed = new List<Identity>();
506
- foreach (var process in held)
507
- if (process != supervisor &&
508
- process != bridge) observed.Add(process.Info);
509
- observed.Sort((left, right) => left.pid.CompareTo(right.pid));
510
- excluded.Sort((left, right) => left.pid.CompareTo(right.pid));
511
446
  var result = new Plan { protocol = 1, kind = "legacy-observed-process-set", port = port, root = root,
512
447
  ownerSid = user, roots = new Roots { supervisor = supervisor.Info, bridge = bridge.Info },
513
- observed = observed.ToArray(), excluded = excluded.ToArray(), treeCompleteness = "unproven",
514
- scope = "Selected roots and captured proven descendants only; uncaptured and historical orphans are untouched." };
448
+ observed = new Identity[0], excluded = new ExcludedIdentity[0], treeCompleteness = "unproven",
449
+ scope = "Selected roots only. When stopping, processes they started are found by parent and stopped; other processes are untouched." };
515
450
  result.observedSetSha256 = native.Hash(Encoding.UTF8.GetBytes(json.Serialize(result.observed)));
516
451
  result.planSha256 = DigestPlan(result);
517
452
  return result;
518
453
  }
519
454
 
455
+ // Sessions start and end all the time, so only the roots bind a reopened plan. A plan
456
+ // written by 2.1.0 also lists the descendants it captured; those are not compared.
457
+ private bool SameRoots(Plan expected, Plan actual)
458
+ {
459
+ return json.Serialize(RootBinding(expected)) == json.Serialize(RootBinding(actual));
460
+ }
461
+
462
+ private object RootBinding(Plan value)
463
+ {
464
+ return new { value.protocol, value.kind, value.port, value.root, value.ownerSid, value.roots };
465
+ }
466
+
520
467
  private string DigestPlan(Plan value)
521
468
  {
522
469
  var saved = value.planSha256;
@@ -531,11 +478,11 @@ internal sealed class LegacyProcessBroker
531
478
  if (!native.Alive(supervisor.Handle) ||
532
479
  !native.Alive(bridge.Handle) ||
533
480
  native.Listener(port) != bridge.Info.pid) throw new InvalidOperationException("Root changed");
534
- var live = held.FindAll(value => native.Alive(value.Handle));
535
- arguments.Read(live, false);
481
+ var roots = new List<Held> { bridge, supervisor };
482
+ arguments.Read(roots, false);
536
483
  using (var images = new LegacyImageCache(CheckPhase))
537
484
  {
538
- foreach (var process in live)
485
+ foreach (var process in roots)
539
486
  {
540
487
  CheckPhase();
541
488
  if (!native.Alive(process.Handle) ||
@@ -547,22 +494,9 @@ internal sealed class LegacyProcessBroker
547
494
  throw new InvalidOperationException("Identity changed");
548
495
  }
549
496
  RefreshSnapshot();
550
- foreach (var process in held)
551
- {
552
- if (!native.Alive(process.Handle)) continue;
553
- if (!snapshot.Contains(process.Info.pid, process.Info.parentPid))
554
- throw new InvalidOperationException("Snapshot held member changed");
555
- foreach (int id in Children(process.Info.pid))
556
- {
557
- var known = held.Find(value => value.Info.pid == id);
558
- if (known != null)
559
- {
560
- if (!KnownEdge(known, process)) continue;
561
- }
562
- else if (!ApprovedNonmember(process, id))
563
- throw new InvalidOperationException("Observed set expanded; replan");
564
- }
565
- }
497
+ if (!snapshot.Contains(bridge.Info.pid, supervisor.Info.pid) ||
498
+ !snapshot.Contains(supervisor.Info.pid, supervisor.Info.parentPid))
499
+ throw new InvalidOperationException("Snapshot held member changed");
566
500
  if (!native.Alive(supervisor.Handle) ||
567
501
  !native.Alive(bridge.Handle) ||
568
502
  native.Listener(port) != bridge.Info.pid) throw new InvalidOperationException("Root changed");
@@ -572,79 +506,254 @@ internal sealed class LegacyProcessBroker
572
506
  }
573
507
  }
574
508
 
575
- private bool ApprovedNonmember(Held parent, int id)
576
- {
577
- var prior = excluded.Find(value => value.pid == id &&
578
- value.parentPid == parent.Info.pid &&
579
- value.parentCreationTime == parent.Info.creationTime);
580
- if (prior == null) return false;
581
- using (var process = Api.OpenProcess(0x00100000 | 0x1000, false, id))
582
- {
583
- native.Require(!process.IsInvalid);
584
- long created = native.Creation(process);
585
- return native.Alive(parent.Handle) &&
586
- native.Alive(process) &&
587
- native.Parent(process) == parent.Info.pid &&
588
- created < parent.Created &&
589
- created.ToString(CultureInfo.InvariantCulture) == prior.creationTime;
590
- }
591
- }
592
-
593
509
  private Receipt Stop(Held supervisor, Held bridge)
594
510
  {
595
511
  var errors = new List<string>();
596
512
  var stopClock = Stopwatch.StartNew();
513
+ // The supervisor goes first so it cannot start another bridge.
597
514
  Kill(supervisor, stopClock, errors);
598
515
  if (!native.Alive(supervisor.Handle))
599
516
  {
517
+ // When a Node process exits, its job object ends its direct children with it (1.3
518
+ // wraps each bare server command in cmd.exe), and their own children then name a
519
+ // parent nobody holds. So the bridge's whole tree is held before the bridge stops.
520
+ bool settled;
521
+ Pin(stopClock, errors, out settled);
600
522
  Kill(bridge, stopClock, errors);
601
- for (int index = held.Count - 1; index >= 0; index--)
602
- if (held[index] != supervisor &&
603
- held[index] != bridge) Kill(held[index], stopClock, errors);
523
+ if (!native.Alive(bridge.Handle)) Sweep(stopClock, errors);
604
524
  }
605
525
  var result = HeldStatus();
606
526
  result.errors = errors.ToArray();
607
527
  return result;
608
528
  }
609
529
 
530
+ // A busy bridge starts and ends sessions every few seconds, so its descendants are found
531
+ // while stopping instead of being frozen into the plan. Each round first holds everything
532
+ // new that descends from a held process, then stops every held process not yet stopped
533
+ // and waits for it, even if the tree was still growing. The next round finds anything
534
+ // those processes started before they died.
535
+ private void Sweep(Stopwatch stopClock, List<string> errors)
536
+ {
537
+ try
538
+ {
539
+ for (int round = 0; round < MaximumSweepRounds; round++)
540
+ {
541
+ // Once the stop budget is spent nothing more can be terminated, so the sweep ends.
542
+ CheckStop(stopClock);
543
+ bool settled;
544
+ int pinned = Pin(stopClock, errors, out settled);
545
+ var terminations = new List<Termination>();
546
+ foreach (var process in held)
547
+ {
548
+ if (process.Stopping) continue;
549
+ var termination = Terminate(process, stopClock, errors);
550
+ if (termination != null) terminations.Add(termination);
551
+ }
552
+ bool done = settled &&
553
+ pinned == 0 &&
554
+ terminations.Count == 0;
555
+ if (done)
556
+ {
557
+ swept = true;
558
+ return;
559
+ }
560
+ foreach (var termination in terminations) AwaitExit(termination, stopClock, errors);
561
+ }
562
+ errors.Add("sweep:rounds");
563
+ }
564
+ catch (Exception error)
565
+ {
566
+ errors.Add("sweep:" + (RefusalReason(error) ?? error.GetType().Name));
567
+ }
568
+ }
569
+
570
+ // Holds every process that descends from a held process, repeating until a snapshot shows
571
+ // nothing new (settled). Every held handle keeps its PID from being reused, so a process
572
+ // whose parent PID is held and which started no earlier than that parent was started by it.
573
+ // A tree still growing after every pass is left to the caller's next round; a snapshot that
574
+ // cannot be read is reported. Nothing is stopped here, and nothing is thrown.
575
+ private int Pin(Stopwatch stopClock, List<string> errors, out bool settled)
576
+ {
577
+ settled = false;
578
+ int total = 0;
579
+ try
580
+ {
581
+ for (int pass = 0; pass < MaximumPinPasses; pass++)
582
+ {
583
+ var current = new LegacySnapshot();
584
+ current.Read(() => CheckStop(stopClock));
585
+ int adopted = 0;
586
+ // Processes held during this pass are walked in the same pass, so one snapshot
587
+ // covers the whole tree; later passes only find processes started since.
588
+ for (int index = 0; index < held.Count; index++)
589
+ {
590
+ var parent = held[index];
591
+ foreach (int id in Children(current, parent.Info.pid))
592
+ {
593
+ if (held.Exists(value => value.Info.pid == id)) continue;
594
+ if (Adopt(id, parent, errors) != null) adopted++;
595
+ }
596
+ }
597
+ total += adopted;
598
+ if (adopted == 0)
599
+ {
600
+ settled = true;
601
+ return total;
602
+ }
603
+ }
604
+ }
605
+ catch (Exception error)
606
+ {
607
+ Reject("pin:" + (RefusalReason(error) ?? error.GetType().Name), errors);
608
+ }
609
+ return total;
610
+ }
611
+
612
+ private List<int> Children(LegacySnapshot current, int parentId)
613
+ {
614
+ var ids = current.Children(parentId);
615
+ ids.Sort();
616
+ return ids;
617
+ }
618
+
619
+ private Held Adopt(int id, Held parent, List<string> errors)
620
+ {
621
+ if (id == brokerId) return null;
622
+ if (held.Count >= MaximumSweptProcesses + 2)
623
+ {
624
+ Reject("sweep:bound", errors);
625
+ return null;
626
+ }
627
+ var opened = Api.OpenProcess(0x00100000 | 0x1000 | 1, false, id);
628
+ if (opened.IsInvalid)
629
+ {
630
+ int code = Marshal.GetLastWin32Error();
631
+ opened.Dispose();
632
+ // 87: the process exited after the snapshot and nothing holds it any more.
633
+ if (code != 87) Reject(id + ":OpenProcess:" + code, errors);
634
+ return null;
635
+ }
636
+ try
637
+ {
638
+ long created = native.Creation(opened);
639
+ long ancestorCreated;
640
+ bool ancestor = ancestors.TryGetValue(id, out ancestorCreated) &&
641
+ ancestorCreated == created;
642
+ // A process older than the held parent got that PID from an earlier owner.
643
+ bool child = native.Parent(opened) == parent.Info.pid &&
644
+ created >= parent.Created;
645
+ if (ancestor ||
646
+ !child)
647
+ {
648
+ opened.Dispose();
649
+ return null;
650
+ }
651
+ // An exited child is held as well: its handle keeps its PID, so its own children
652
+ // stay traceable. Only a live process can belong to another user.
653
+ bool foreign = native.Alive(opened) &&
654
+ native.Owner(opened) != user;
655
+ if (foreign)
656
+ {
657
+ opened.Dispose();
658
+ Reject(id + ":owner", errors);
659
+ return null;
660
+ }
661
+ var result = new Held { Handle = opened, Created = created, Info = new Identity { pid = id,
662
+ creationTime = created.ToString(CultureInfo.InvariantCulture), parentPid = parent.Info.pid,
663
+ parentCreationTime = parent.Info.creationTime, ownerSid = user } };
664
+ held.Add(result);
665
+ return result;
666
+ }
667
+ catch (Exception error)
668
+ {
669
+ opened.Dispose();
670
+ var win32 = error as Win32Exception;
671
+ Reject(id + ":" + error.GetType().Name + (win32 == null ? "" : ":" + win32.NativeErrorCode), errors);
672
+ return null;
673
+ }
674
+ }
675
+
676
+ private void Reject(string reason, List<string> errors)
677
+ {
678
+ if (rejected.Add(reason)) errors.Add(reason);
679
+ }
680
+
681
+ private void CheckStop(Stopwatch stopClock)
682
+ {
683
+ if (stopClock.ElapsedMilliseconds >= StopMilliseconds) throw new TimeoutException();
684
+ }
685
+
610
686
  private void Kill(Held process, Stopwatch stopClock, List<string> errors)
611
687
  {
688
+ var termination = Terminate(process, stopClock, errors);
689
+ if (termination != null) AwaitExit(termination, stopClock, errors);
690
+ }
691
+
692
+ private Termination Terminate(Held process, Stopwatch stopClock, List<string> errors)
693
+ {
694
+ process.Stopping = true;
612
695
  try
613
696
  {
614
- if (!native.Alive(process.Handle)) return;
615
- if (stopClock.ElapsedMilliseconds >= 8000) throw new TimeoutException();
697
+ if (!native.Alive(process.Handle)) return null;
698
+ if (stopClock.ElapsedMilliseconds >= StopMilliseconds) throw new TimeoutException();
616
699
  bool terminated = Api.TerminateProcess(process.Handle, 1);
617
700
  int terminationError = terminated ? 0 : Marshal.GetLastWin32Error();
618
- uint remaining = (uint)Math.Max(1, 8000 - stopClock.ElapsedMilliseconds);
701
+ return new Termination { Process = process, Terminated = terminated, Error = terminationError };
702
+ }
703
+ catch (Exception error)
704
+ {
705
+ RecordStopError(process, error, errors);
706
+ return null;
707
+ }
708
+ }
709
+
710
+ private void AwaitExit(Termination termination, Stopwatch stopClock, List<string> errors)
711
+ {
712
+ try
713
+ {
714
+ uint remaining = (uint)Math.Max(0, StopMilliseconds - stopClock.ElapsedMilliseconds);
619
715
  // Parent exit can already be terminating this child. A racing
620
716
  // TerminateProcess failure is reconciled only by this held handle
621
717
  // becoming signaled within the same total stop budget.
622
- if (Api.WaitForSingleObject(process.Handle, remaining) != 0)
718
+ if (Api.WaitForSingleObject(termination.Process.Handle, remaining) != 0)
623
719
  {
624
- if (!terminated) throw new Win32Exception(terminationError);
720
+ if (!termination.Terminated) throw new Win32Exception(termination.Error);
625
721
  throw new TimeoutException();
626
722
  }
627
723
  }
628
- catch (Exception error)
629
- {
630
- var win32 = error as Win32Exception;
631
- errors.Add(process.Info.pid + ":" + error.GetType().Name +
632
- (win32 == null ? "" : ":" + win32.NativeErrorCode));
633
- }
724
+ catch (Exception error) { RecordStopError(termination.Process, error, errors); }
725
+ }
726
+
727
+ private void RecordStopError(Held process, Exception error, List<string> errors)
728
+ {
729
+ var win32 = error as Win32Exception;
730
+ errors.Add(process.Info.pid + ":" + error.GetType().Name +
731
+ (win32 == null ? "" : ":" + win32.NativeErrorCode));
634
732
  }
635
733
 
636
734
  private Receipt HeldStatus()
637
735
  {
638
736
  bool rootsGone = true;
639
- bool observedGone = true;
737
+ int found = 0;
738
+ int stopped = 0;
640
739
  foreach (var process in held)
641
740
  {
642
- if (!native.Alive(process.Handle)) continue;
643
- if (process.Info.pid == plan.roots.bridge.pid ||
644
- process.Info.pid == plan.roots.supervisor.pid) rootsGone = false;
645
- else observedGone = false;
741
+ bool alive = native.Alive(process.Handle);
742
+ bool root = process.Info.pid == plan.roots.bridge.pid ||
743
+ process.Info.pid == plan.roots.supervisor.pid;
744
+ if (root)
745
+ {
746
+ if (alive) rootsGone = false;
747
+ continue;
748
+ }
749
+ found++;
750
+ if (!alive) stopped++;
646
751
  }
647
- return new Receipt { legacyRootStopVerified = rootsGone, observedDescendantsStopped = observedGone,
752
+ bool descendantsGone = swept &&
753
+ stopped == found &&
754
+ rejected.Count == 0;
755
+ return new Receipt { legacyRootStopVerified = rootsGone, observedDescendantsStopped = descendantsGone,
756
+ descendantsFound = found, descendantsStopped = stopped,
648
757
  treeCompleteness = "unproven", observedCount = plan.observed.Length,
649
758
  excludedCount = plan.excluded.Length, planSha256 = plan.planSha256,
650
759
  errors = new string[0], unattributed = "Not discovered or not provable; left untouched." };
@@ -662,8 +771,8 @@ internal sealed class LegacyProcessBroker
662
771
  value.roots.supervisor == null ||
663
772
  value.observed == null ||
664
773
  value.excluded == null ||
665
- value.observed.Length > 510 ||
666
- value.excluded.Length + value.observed.Length > 510 ||
774
+ value.observed.Length > MaximumPlanIdentities - 2 ||
775
+ value.excluded.Length + value.observed.Length > MaximumPlanIdentities - 2 ||
667
776
  value.port < 1 ||
668
777
  value.port > 65535 ||
669
778
  !Path.IsPathRooted(value.root) ||
@@ -728,7 +837,10 @@ internal sealed class LegacyProcessBroker
728
837
  if (index < 2) rootsGone = false; else childrenGone = false;
729
838
  }
730
839
  }
731
- return new Receipt { legacyRootStopVerified = rootsGone, observedDescendantsStopped = childrenGone,
840
+ // A plan written now lists no descendants, so this read-only check cannot vouch for them.
841
+ bool descendantsVerified = childrenGone &&
842
+ value.observed.Length > 0;
843
+ return new Receipt { legacyRootStopVerified = rootsGone, observedDescendantsStopped = descendantsVerified,
732
844
  observedCount = value.observed.Length, excludedCount = value.excluded.Length, treeCompleteness = "unproven",
733
845
  planSha256 = value.planSha256, errors = errors.ToArray(),
734
846
  unattributed = "Not discovered or not provable; left untouched." };
@@ -747,17 +859,16 @@ internal sealed class LegacyProcessBroker
747
859
  // WMI/IO exception text, which can contain paths or command-line content.
748
860
  var reasons = new HashSet<string> {
749
861
  "Plan changed", "Capture bound", "Capture exited", "Owner mismatch",
750
- "Unproven ancestry", "Capture changed", "Ancestry changed", "Ancestry cycle",
751
- "Child bound", "Depth bound", "Root arguments mismatch", "Script bound",
752
- "Root changed", "Identity changed", "Arguments changed",
753
- "Observed set expanded; replan", "Process query changed",
862
+ "Capture changed", "Child bound", "Root arguments mismatch", "Script bound",
863
+ "Root changed", "Identity changed", "Arguments changed", "Process query changed",
754
864
  "Process query unavailable", "Process query generation changed",
865
+ "Run the upgrade from a terminal the 1.3 bridge did not start",
755
866
  "Argument bounds", "Listener ambiguity", "Loopback listener missing",
756
867
  "Broker parent identity lost", "Script argument not fully qualified",
757
868
  "Snapshot schema unavailable", "Snapshot enumeration failed", "Snapshot entry bound",
758
869
  "Snapshot duplicate entry", "Snapshot identity unavailable", "Snapshot close failed",
759
870
  "Snapshot root changed", "Snapshot held member changed", "Argument query bound",
760
- "Argument query cancelled", "Image file identity unavailable", "Image file identity changed",
871
+ "Argument query cancelled", "Argument query timed out", "Image file identity unavailable", "Image file identity changed",
761
872
  "Image hash bound", "Plan byte bound", "Request bounds"
762
873
  };
763
874
  return reasons.Contains(error.Message) ? error.Message : null;
@@ -771,7 +882,14 @@ internal sealed class Held
771
882
  internal long Created;
772
883
  internal string[] Arguments;
773
884
  internal bool KeepArguments;
774
- internal bool SelectedRoot;
885
+ internal bool Stopping;
886
+ }
887
+
888
+ internal sealed class Termination
889
+ {
890
+ internal Held Process;
891
+ internal bool Terminated;
892
+ internal int Error;
775
893
  }
776
894
 
777
895
  internal sealed class Request
@@ -832,6 +950,8 @@ internal sealed class Receipt
832
950
  {
833
951
  public bool legacyRootStopVerified { get; set; }
834
952
  public bool observedDescendantsStopped { get; set; }
953
+ public int? descendantsFound { get; set; }
954
+ public int? descendantsStopped { get; set; }
835
955
  public string treeCompleteness { get; set; }
836
956
  public int observedCount { get; set; }
837
957
  public int excludedCount { get; set; }