processkit 3.3.3

Async child-process management for tokio: whole-tree kill-on-drop (no orphans), plus streaming, pipelines, timeouts, and supervision
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
//! Whole-tree signals, suspend/resume, adoption, and member inspection —
//! everything behind the `process-control` feature (the `mod` declaration in
//! `main.rs` carries the gate).

use std::time::Duration;

#[cfg(target_os = "linux")]
use processkit::Mechanism;
use processkit::{Command, ProcessGroup, Signal};

use crate::common::*;

#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess and signals it"]
async fn unix_signal_reaches_the_tree() {
    use tokio_stream::StreamExt;

    let group = ProcessGroup::new().expect("create group");
    // Print a readiness marker once the trap is installed, then idle; on SIGHUP
    // the trap fires after the current `sleep` returns (it dies to the HUP too).
    let cmd = Command::new("sh").args([
        "-c",
        "trap 'echo got-hup' HUP; echo ready; while :; do sleep 0.1; done",
    ]);
    let mut process = group.start(&cmd).await.expect("start trap child");
    let mut lines = process.stdout_lines().unwrap();

    let ready = tokio::time::timeout(Duration::from_secs(10), lines.next())
        .await
        .expect("readiness line in time")
        .expect("readiness line");
    assert!(ready.contains("ready"), "line: {ready:?}");

    group.signal(Signal::Hup).expect("broadcast SIGHUP");
    let got = tokio::time::timeout(Duration::from_secs(10), lines.next())
        .await
        .expect("trap line in time")
        .expect("trap line");
    assert!(got.contains("got-hup"), "line: {got:?}");
}

#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess and freezes it"]
async fn unix_suspend_freezes_progress() {
    use tokio_stream::StreamExt;

    let group = ProcessGroup::new().expect("create group");
    // A ticker: one line every ~50ms.
    let cmd = Command::new("sh").args([
        "-c",
        "i=0; while :; do i=$((i+1)); echo $i; sleep 0.05; done",
    ]);
    let mut process = group.start(&cmd).await.expect("start ticker");
    let mut lines = process.stdout_lines().unwrap();

    // Prove it is producing output, then freeze.
    tokio::time::timeout(Duration::from_secs(10), lines.next())
        .await
        .expect("first tick in time")
        .expect("first tick");
    group.suspend().expect("suspend");

    // Drain lines emitted before the freeze landed (pipe buffering), then
    // require silence for a window several ticks long.
    tokio::time::sleep(Duration::from_millis(200)).await;
    while let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(100), lines.next()).await {}
    let stalled = tokio::time::timeout(Duration::from_millis(400), lines.next()).await;
    assert!(stalled.is_err(), "frozen tree kept producing output");

    group.resume().expect("resume");
    let resumed = tokio::time::timeout(Duration::from_secs(10), lines.next()).await;
    assert!(
        resumed.is_ok_and(|line| line.is_some()),
        "tree did not resume ticking"
    );
}

#[cfg(unix)]
#[test]
#[ignore = "creates an OS job/cgroup"]
fn signal_on_empty_group_is_ok() {
    // An empty group is trivially signalled/suspended/resumed — load-bearing
    // for callers that broadcast before (or after) any member is alive.
    let group = ProcessGroup::new().expect("create group");
    group.signal(Signal::Term).expect("signal on empty group");
    group.suspend().expect("suspend on empty group");
    group.resume().expect("resume on empty group");
}

#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess; cross-checks soft_stop_scope against signal"]
async fn unix_soft_stop_scope_is_whole_tree_and_matches_signal() {
    use processkit::SoftStopScope;

    // The soft-stop capability report must agree with the real `signal` outcome
    // on the SAME group (the honesty contract: what it advertises is what a soft
    // stop actually reaches). On every Unix mechanism — cgroup v2, the POSIX
    // process-group fallback, and the FreeBSD process reaper — a soft `Int`/`Term`
    // reaches the whole tree and never reports `Unsupported`, so the report is
    // `WholeTree` and a real `Term` returns `Ok`.
    let group = ProcessGroup::new().expect("create group");

    // Before any member: whole-tree capability, and an empty group accepts a soft
    // signal trivially.
    assert_eq!(
        group.soft_stop_scope(),
        SoftStopScope::WholeTree,
        "a Unix group always offers a whole-tree soft stop"
    );
    group
        .signal(Signal::Term)
        .expect("Term on an empty Unix group is Ok — matching the WholeTree report");

    // With a live child: still whole tree, and a real soft `Term` still succeeds,
    // so the report matches the observed signal outcome.
    let cmd = Command::new("sh").args(["-c", "while :; do sleep 0.1; done"]);
    let _run = group.start(&cmd).await.expect("start sleeper");
    assert_eq!(
        group.soft_stop_scope(),
        SoftStopScope::WholeTree,
        "the POSIX/cgroup soft stop reaches the whole tree with a live member too"
    );
    group
        .signal(Signal::Term)
        .expect("a real Term reaches the tree, matching the whole-tree report");
}

#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a fork storm and broadcasts SIGKILL to the group"]
async fn unix_fork_storm_is_swept_by_group_broadcast() {
    // Best-effort boundary of the pgroup mechanism under a fork storm. A group
    // leader forks a dense burst of grandchildren — each inheriting the leader's
    // process group, none `setsid`-ing away — while we broadcast `Signal::Kill`.
    // `killpg` reaches the whole process group in one sweep (the documented
    // "SIGKILL … cannot miss a process forked mid-broadcast"), and any child
    // forked in the race window is caught by the next sweep, so the storm is
    // fully torn down — the only pgroup escape hatch is a member that `setsid`s
    // into its own session. We record the single-sweep catch count (best-effort,
    // not a strict 100% guarantee) and assert the group drains completely after
    // teardown. (Under the Linux cgroup mechanism the whole tree is contained via
    // `cgroup.kill`; the assertions hold there too.)
    let tmp = std::env::temp_dir();
    let dir = tmp.join(format!("processkit_fork_storm_{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create storm dir");

    // The leader forks a grandchild every ~20ms; each records its OWN pid (`$$`
    // of its own `sh -c`) and then sleeps well past the test, so the burst runs
    // concurrently with the broadcast below.
    let script = r#"i=0; while [ "$i" -lt 40 ]; do sh -c 'echo live > "$PK_DIR/$$"; exec sleep 30' & i=$((i + 1)); sleep 0.02; done; wait"#;
    let group = ProcessGroup::new().expect("create group");
    let forker = group
        .start(&Command::new("sh").args(["-c", script]).env("PK_DIR", &dir))
        .await
        .expect("fork-storm leader spawns");

    // Count grandchildren currently registered *and* alive (files are named by
    // pid, so the filename is the pid to probe).
    let alive_registered = || -> usize {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            return 0;
        };
        entries
            .flatten()
            .filter_map(|e| e.file_name().to_string_lossy().parse::<i32>().ok())
            // SAFETY: signal 0 is a sound liveness probe.
            .filter(|&pid| unsafe { libc::kill(pid, 0) } == 0)
            .count()
    };

    // Warm up until a real storm is running, so the broadcast races live forks.
    poll_until(
        Duration::from_secs(5),
        Duration::from_millis(20),
        "fork storm never ramped up",
        || alive_registered() >= 6,
    )
    .await;
    let before = alive_registered();
    assert!(
        before >= 4,
        "expected a live fork storm, saw {before} grandchildren"
    );

    // One broadcast sweep, mid-storm.
    group
        .signal(Signal::Kill)
        .expect("broadcast SIGKILL to the group");

    // The sweep must catch the bulk of the group. Poll (not a fixed sleep) so a
    // slow init-reap of the freshly-killed orphans doesn't count lingering
    // zombies as survivors: killpg is best-effort against the concurrent fork
    // race, but a whole-group SIGKILL leaves at most a handful forked inside the
    // syscall's race window — never more than half of a real burst.
    poll_until(
        Duration::from_secs(5),
        Duration::from_millis(50),
        "one whole-group sweep did not catch the bulk of the storm",
        || alive_registered() * 2 <= before,
    )
    .await;
    let survived_one_sweep = alive_registered();

    // Reap the leader (SIGKILL'd above) so the group's liveness probe is driven
    // purely by the grandchildren, then take a final sweep to catch any
    // race-window survivor.
    completes_within(Duration::from_secs(10), "leader reap", forker.wait())
        .await
        .expect("leader waits");
    group.kill_all().expect("final whole-tree sweep");

    // Load-bearing: the whole tracked group must drain. `members()` uses the
    // crate's own recycle-safe probe and reports empty only once the group is
    // genuinely gone — no grandchild permanently escaped the mechanism.
    poll_until(
        Duration::from_secs(10),
        Duration::from_millis(50),
        "fork storm did not fully drain — a grandchild escaped the group",
        || group.members().is_ok_and(|m| m.is_empty()),
    )
    .await;

    eprintln!(
        "fork storm: {before} grandchildren alive before broadcast, \
         {survived_one_sweep} survived one sweep, group fully drained after teardown"
    );
    let _ = std::fs::remove_dir_all(&dir);
}

#[cfg(windows)]
#[test]
#[ignore = "creates an OS job"]
fn windows_signal_non_kill_is_unsupported() {
    // Job Objects have no POSIX signals. `Int`/`Term` get a best-effort soft close
    // (console `CTRL_BREAK` + `WM_CLOSE` to windowed members), but this empty group
    // has neither a console leader nor a windowed member, so they too surface the
    // typed Unsupported error here — as does every other non-Kill signal
    // unconditionally. Never a silent no-op.
    let group = ProcessGroup::new().expect("create group");
    for sig in [Signal::Term, Signal::Hup, Signal::Other(9)] {
        let err = group
            .signal(sig)
            .expect_err("a non-Kill signal with no soft-close target must be rejected on Windows");
        assert!(
            matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
            "expected ErrorReason::Unsupported for {sig:?}, got {err:?}"
        );
    }
}

#[cfg(windows)]
#[test]
#[ignore = "creates an OS job"]
fn windows_soft_stop_scope_on_empty_group_is_unsupported() {
    use processkit::SoftStopScope;

    // The soft-stop capability report agrees with the narrowed `signal` contract:
    // a group with neither a console-CTRL leader nor a windowed member can soft-stop
    // nothing, so the report is `Unsupported` and a real `signal(Term)` would return
    // `ErrorReason::Unsupported` — the caller can decide up front without firing (and
    // parsing back) the error. The probe is side-effect-free (no spawn, no signal).
    let group = ProcessGroup::new().expect("create group");
    assert_eq!(
        group.soft_stop_scope(),
        SoftStopScope::Unsupported,
        "an empty Windows group has no soft-stop target"
    );
    let err = group
        .signal(Signal::Term)
        .expect_err("no soft-close target on an empty Windows group");
    assert!(
        matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
        "the report and the real signal outcome agree on Unsupported, got {err:?}"
    );
}

#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real opt-in console child; probes soft-stop availability at the ProcessGroup API"]
async fn windows_soft_stop_scope_reports_opt_in_members_for_a_live_child() {
    use processkit::SoftStopScope;

    // End-to-end through the public API (including the `windows_graceful_ctrl_break`
    // → `SpawnOptions::windows_new_process_group` → recorded-leader wiring): a live
    // opt-in console child makes a soft stop available, so the side-effect-free
    // probe reports `OptInMembers` and a real `signal(Term)` then succeeds — the
    // report matches the observed outcome. `ping` ignores CTRL_BREAK (it installs
    // its own handler), so the child stays reapable for the explicit teardown.
    let group = ProcessGroup::new().expect("create group");
    let _run = group
        .start(
            &Command::new("ping")
                .args(["-n", "30", "127.0.0.1"])
                .windows_graceful_ctrl_break(),
        )
        .await
        .expect("start opt-in console child");

    assert_eq!(
        group.soft_stop_scope(),
        SoftStopScope::OptInMembers,
        "a live opt-in console child makes a soft stop available"
    );
    group
        .signal(Signal::Term)
        .expect("a real Term reaches the live opt-in leader the probe reported");

    group.kill_all().expect("tear the tree down");
}

#[cfg(all(windows, feature = "pty"))]
#[tokio::test]
#[ignore = "spawns a real opt-in ConPTY child; probes shared Job bookkeeping"]
async fn windows_conpty_opt_in_child_is_recorded_for_soft_stop() {
    use processkit::SoftStopScope;

    let group = ProcessGroup::new().expect("create group");
    let _run = group
        .start(
            &Command::new("ping")
                .args(["-n", "30", "127.0.0.1"])
                .windows_graceful_ctrl_break()
                .use_pty(),
        )
        .await
        .expect("start opt-in ConPTY child");

    assert_eq!(
        group.soft_stop_scope(),
        SoftStopScope::OptInMembers,
        "ConPTY spawn must register its console process-group leader on the Job"
    );
    group
        .signal(Signal::Term)
        .expect("the registered ConPTY leader must accept the advertised soft stop");
    group.kill_all().expect("tear the ConPTY tree down");
}

#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and kills it via Signal::Kill"]
async fn windows_signal_kill_kills_tree() {
    let group = ProcessGroup::new().expect("create group");
    let process = group.start(&sleeper()).await.expect("start sleeper");
    assert!(process.pid().is_some());

    group
        .signal(Signal::Kill)
        .expect("Signal::Kill maps to job terminate");

    // The ~30s sleeper waiting out promptly proves the whole tree was killed
    // (pid liveness can't be probed here: our own RunningProcess still holds the
    // child handle, which keeps the terminated process object around).
    completes_within(Duration::from_secs(5), "Signal::Kill reap", process.wait())
        .await
        .expect("wait");
}

#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and suspends/resumes its threads"]
async fn windows_suspend_resume_stalls_output() {
    use tokio_stream::StreamExt;

    let group = ProcessGroup::new().expect("create group");
    // ping prints one line per second — a slow ticker.
    let cmd = Command::new("ping").args(["-n", "30", "127.0.0.1"]);
    let mut process = group.start(&cmd).await.expect("start ping");
    let mut lines = process.stdout_lines().unwrap();

    tokio::time::timeout(Duration::from_secs(10), lines.next())
        .await
        .expect("first ping line in time")
        .expect("first ping line");
    group.suspend().expect("suspend");

    // Drain pre-freeze buffered lines, then require silence across what would
    // be two ticks.
    tokio::time::sleep(Duration::from_millis(200)).await;
    while let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(100), lines.next()).await {}
    let stalled = tokio::time::timeout(Duration::from_secs(2), lines.next()).await;
    assert!(stalled.is_err(), "suspended tree kept producing output");

    group.resume().expect("resume");
    let resumed = tokio::time::timeout(Duration::from_secs(10), lines.next()).await;
    assert!(
        resumed.is_ok_and(|line| line.is_some()),
        "tree did not resume output"
    );
}

#[tokio::test]
#[ignore = "spawns a real subprocess outside the group and adopts it"]
async fn adopt_brings_an_external_child_under_containment() {
    // Spawn OUTSIDE any processkit group, adopt, then prove the group's
    // teardown reaps it — the adopt() containment claim, end-to-end.
    let mut cmd = if cfg!(windows) {
        let mut c = tokio::process::Command::new("ping");
        c.args(["-n", "30", "127.0.0.1"]);
        c
    } else {
        let mut c = tokio::process::Command::new("sleep");
        c.arg("30");
        c
    };
    cmd.stdout(std::process::Stdio::null());
    let mut child = cmd.spawn().expect("spawn external child");

    let group = ProcessGroup::new().expect("create group");
    group.adopt(&child).expect("adopt external child");
    group.kill_all().expect("hard-kill the adopted tree");

    // The adopted child must die promptly — well under its ~30s natural run.
    let _ = completes_within(Duration::from_secs(5), "adopted child reap", child.wait()).await;
}

/// Whether `pid` still names a **running** process, asked about a process this
/// test is not the parent of (so `Child::wait` is unavailable — exactly the
/// position `adopt_external`'s caller is in).
///
/// On Windows a terminated process stays *openable* while any handle to it is held
/// anywhere, so liveness has to be read from the exit code rather than from
/// `OpenProcess` succeeding.
#[cfg(windows)]
fn foreign_pid_running(pid: u32) -> bool {
    use windows_sys::Win32::Foundation::CloseHandle;
    use windows_sys::Win32::System::Threading::{
        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
    };
    const STILL_ACTIVE: u32 = 259;
    // SAFETY: limited-information access; null when the pid is gone.
    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
    if handle.is_null() {
        return false;
    }
    let mut code: u32 = 0;
    // SAFETY: valid handle; `code` is an owned local.
    let ok = unsafe { GetExitCodeProcess(handle, &mut code) };
    // SAFETY: handle came from OpenProcess; closed exactly once.
    unsafe { CloseHandle(handle) };
    ok != 0 && code == STILL_ACTIVE
}

/// The unix twin. An orphan is reaped by `init` the moment it dies, so it cannot
/// linger as a zombie this probe would misread as alive.
#[cfg(unix)]
fn foreign_pid_running(pid: u32) -> bool {
    // SAFETY: signal 0 is a pure existence probe and delivers nothing.
    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}

/// Start a process that is genuinely **foreign** to this one — an intermediate
/// launches it and exits, so nothing this test holds is its parent and no handle to
/// it exists here. The `(b)` half of what `adopt_external` has to cover: a process
/// this crate has no `Child` for and never could have.
async fn spawn_orphan() -> u32 {
    let mut cmd = if cfg!(windows) {
        // `Start-Process -PassThru` prints the new process's id; the launching
        // PowerShell then exits, leaving `ping` running with a stale parent id.
        let mut c = tokio::process::Command::new("powershell");
        c.args([
            "-NoProfile",
            "-NonInteractive",
            "-Command",
            "(Start-Process -FilePath ping -ArgumentList '-n','120','127.0.0.1' \
             -WindowStyle Hidden -PassThru).Id",
        ]);
        c
    } else {
        // The shell backgrounds `sleep` and exits, so `init` adopts it. The
        // background process gets its own `/dev/null` stdio: inheriting the
        // launcher's captured pipe would keep it open, and `output()` waits for
        // EOF — i.e. for the whole 120 seconds — before returning.
        let mut c = tokio::process::Command::new("sh");
        c.args(["-c", "sleep 120 >/dev/null 2>&1 </dev/null & echo $!"]);
        c
    };
    let out = cmd.output().await.expect("launch the orphan's launcher");
    assert!(
        out.status.success(),
        "the orphan's launcher failed: {out:?}"
    );
    let pid: u32 = String::from_utf8_lossy(&out.stdout)
        .trim()
        .parse()
        .expect("the launcher prints the orphan's pid");
    // The launcher is gone by construction; wait for the orphan to really be up.
    poll_until(
        Duration::from_secs(10),
        Duration::from_millis(20),
        "the orphan starts",
        || foreign_pid_running(pid),
    )
    .await;
    pid
}

/// Best-effort teardown for an orphan a test may have left running.
fn kill_orphan(pid: u32) {
    #[cfg(unix)]
    // SAFETY: a best-effort SIGKILL of a pid this test itself created.
    unsafe {
        libc::kill(pid as libc::pid_t, libc::SIGKILL);
    }
    #[cfg(windows)]
    {
        let _ = std::process::Command::new("taskkill")
            .args(["/F", "/PID", &pid.to_string()])
            .output();
    }
}

/// Whether this target refuses bare-pid adoption outright for want of a start-time
/// identity reader (FreeBSD and the other BSDs — see `adopt_external`'s platform
/// section). Windows, Linux and macOS all have one.
fn adoption_by_pid_is_unsupported_here() -> bool {
    cfg!(all(
        unix,
        not(any(
            target_os = "linux",
            target_os = "android",
            target_vendor = "apple"
        ))
    ))
}

#[tokio::test]
#[ignore = "spawns a process outside this one's tree and adopts it by pid"]
async fn adopt_external_contains_a_truly_foreign_process() {
    let pid = spawn_orphan().await;
    let group = ProcessGroup::new().expect("create group");

    match group.adopt_external(pid) {
        Ok(()) => {}
        Err(err) if adoption_by_pid_is_unsupported_here() => {
            assert!(
                matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
                "a target with no identity reader must refuse with Unsupported, got {err:?}",
            );
            kill_orphan(pid);
            return;
        }
        Err(err) => {
            kill_orphan(pid);
            panic!("adopt_external of a live foreign process failed: {err:?}");
        }
    }

    group.kill_all().expect("hard-kill the adopted tree");
    // Nothing here can reap it (it is not our child) — poll it down instead.
    poll_until(
        Duration::from_secs(10),
        Duration::from_millis(20),
        "the adopted foreign process dies with the group",
        || !foreign_pid_running(pid),
    )
    .await;
}

#[tokio::test]
#[ignore = "spawns an untracked child of this process and adopts it by pid"]
async fn adopt_external_contains_an_untracked_child_of_this_process() {
    // The `(a)` half of the contract: a process this one forked itself but never
    // handed to the crate. The same call covers it — nothing about the adoption
    // depends on who the parent is — and the exit status stays with whoever *is*
    // the parent, which here is this test.
    let mut cmd = if cfg!(windows) {
        let mut c = tokio::process::Command::new("ping");
        c.args(["-n", "120", "127.0.0.1"]);
        c
    } else {
        let mut c = tokio::process::Command::new("sleep");
        c.arg("120");
        c
    };
    cmd.stdout(std::process::Stdio::null());
    let mut child = cmd.spawn().expect("spawn an untracked child");
    let pid = child.id().expect("a live child has a pid");

    let group = ProcessGroup::new().expect("create group");
    match group.adopt_external(pid) {
        Ok(()) => {}
        Err(err) if adoption_by_pid_is_unsupported_here() => {
            assert!(
                matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
                "a target with no identity reader must refuse with Unsupported, got {err:?}",
            );
            let _ = child.kill().await;
            return;
        }
        Err(err) => {
            let _ = child.kill().await;
            panic!("adopt_external of an untracked child failed: {err:?}");
        }
    }

    group.kill_all().expect("hard-kill the adopted tree");
    // The group killed it; reaping is still ours, and no exit status for it ever
    // came through the group.
    let _ = completes_within(
        Duration::from_secs(10),
        "the adopted child is killed by the group",
        child.wait(),
    )
    .await;
}

#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn adopt_external_refuses_pid_zero_and_this_process() {
    let group = ProcessGroup::new().expect("create group");

    // Both are refused before any backend call: pid 0 is "the caller's own process
    // group" to `kill` and "self" to `setpgid`, and adopting this very process
    // would point the group's own teardown at the caller. Neither must reach a
    // backend on any platform — including the ones where bare-pid adoption is
    // otherwise Unsupported.
    for pid in [0, std::process::id()] {
        let err = group
            .adopt_external(pid)
            .expect_err("pid 0 and this process's own pid are not adoptable");
        match err.reason() {
            processkit::ErrorReason::Io(source) => assert_eq!(
                source.kind(),
                std::io::ErrorKind::InvalidInput,
                "expected InvalidInput for pid {pid}, got {err:?}",
            ),
            other => panic!("expected an Io(InvalidInput) refusal for pid {pid}, got {other:?}"),
        }
    }

    // The refusal is a rejection, not a side effect: the group is untouched and
    // still empty.
    assert!(
        group.members().expect("members").is_empty(),
        "a refused adoption must not have tracked anything",
    );
}

#[tokio::test]
#[ignore = "queries a pid past any valid range"]
async fn adopt_external_of_a_pid_that_names_nothing_is_not_found() {
    let group = ProcessGroup::new().expect("create group");
    // ~2e9 is far above any OS's pid_max yet still fits `pid_t`, so each backend's
    // real "not found" path runs rather than a range guard.
    let err = group
        .adopt_external(2_000_000_000)
        .expect_err("a pid that names nothing must not be adoptable");

    if adoption_by_pid_is_unsupported_here() {
        assert!(
            matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
            "a target with no identity reader refuses before looking, got {err:?}",
        );
        return;
    }
    match err.reason() {
        processkit::ErrorReason::Io(source) => assert_eq!(
            source.kind(),
            std::io::ErrorKind::NotFound,
            "a pid naming nothing must be NotFound, not {err:?}",
        ),
        other => panic!("expected Io(NotFound), got {other:?}"),
    }
}

#[tokio::test]
#[ignore = "spawns a short subprocess and adopts its pid after reaping"]
async fn adopt_external_of_a_reaped_pid_is_not_found() {
    // The bare-pid counterpart of `adopt_of_a_reaped_child_errors_instead_of_
    // tracking_nothing`: with a `Child` the crate can see the handle is spent, but
    // a number carries no such evidence — the identity anchor is what refuses it.
    let mut cmd = if cfg!(windows) {
        let mut c = tokio::process::Command::new("cmd");
        c.args(["/c", "exit", "0"]);
        c
    } else {
        let mut c = tokio::process::Command::new("sh");
        c.args(["-c", "exit 0"]);
        c
    };
    let mut child = cmd.spawn().expect("spawn short child");
    let pid = child.id().expect("a live child has a pid");
    let _ = tokio::time::timeout(Duration::from_secs(10), child.wait())
        .await
        .expect("short child exits");
    drop(child); // release this process's own handle on Windows.

    if processkit::process_info(pid).is_ok_and(|info| info.is_some()) {
        // The OS handed the number to someone else already (or a third-party
        // handle keeps it openable). Asserting "gone" would be asserting about a
        // stranger, so skip rather than flake.
        eprintln!("skipping: pid {pid} was no longer a clean negative after the reap");
        return;
    }

    let group = ProcessGroup::new().expect("create group");
    let err = group
        .adopt_external(pid)
        .expect_err("a reaped pid must not be adoptable");
    if adoption_by_pid_is_unsupported_here() {
        assert!(matches!(
            err.reason(),
            processkit::ErrorReason::Unsupported { .. }
        ));
        return;
    }
    match err.reason() {
        processkit::ErrorReason::Io(source) => assert_eq!(
            source.kind(),
            std::io::ErrorKind::NotFound,
            "a reaped pid must be NotFound, not {err:?}",
        ),
        other => panic!("expected Io(NotFound), got {other:?}"),
    }
}

/// What `adopt_external` does with a process that already belongs to **another**
/// Job Object — the case a Windows caller most plausibly hits (an orchestrator's
/// own job, a CI agent's job) and the one whose answer must be observed rather
/// than assumed: since Windows 8 a process may belong to several *nested* jobs, so
/// "already in a job" is not by itself a refusal.
///
/// Observed on Windows 10/11: the adoption **succeeds** (the crate's job becomes a
/// nested job of the outer one) and this group's teardown reaches the process. The
/// test accepts a refusal too, since the kernel owns the nesting rules and a host
/// may reject a particular pairing — but either way the group must be honest: an
/// `Ok` must mean teardown really reaches it, and an `Err` must leave it alone
/// rather than be reported as containment.
#[cfg(windows)]
#[tokio::test]
#[ignore = "creates a second Job Object and adopts a member of it"]
async fn windows_adopt_external_of_a_process_already_in_another_job() {
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::System::JobObjects::{
        AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob,
    };
    use windows_sys::Win32::System::Threading::{
        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
    };

    let mut cmd = tokio::process::Command::new("ping");
    cmd.args(["-n", "120", "127.0.0.1"]);
    cmd.stdout(std::process::Stdio::null());
    let mut child = cmd.spawn().expect("spawn a child to put in the outer job");
    let pid = child.id().expect("a live child has a pid");

    // An outer job with no limits of its own, so nothing but membership can make
    // the crate's assign fail.
    // SAFETY: null name/attributes request an unnamed job with defaults.
    let outer: HANDLE = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
    assert!(!outer.is_null(), "CreateJobObjectW failed");
    // SAFETY: opens the child by pid with the rights an assign needs.
    let target = unsafe {
        OpenProcess(
            PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION,
            0,
            pid,
        )
    };
    assert!(!target.is_null(), "OpenProcess of our own child failed");
    // SAFETY: both handles are valid for the call.
    let assigned = unsafe { AssignProcessToJobObject(outer, target) != 0 };
    assert!(assigned, "could not put the child into the outer job");
    let mut in_outer: i32 = 0;
    // SAFETY: valid handles; `in_outer` is a valid BOOL out-param.
    unsafe { IsProcessInJob(target, outer, &mut in_outer) };
    assert!(in_outer != 0, "the child must be in the outer job");

    let group = ProcessGroup::new().expect("create group");
    let adopted = group.adopt_external(pid);
    // Recorded rather than asserted: this is one half of the observation the
    // contract's wording about order-dependence rests on (the other half is the
    // already-non-empty group, in the test below).
    eprintln!("adopt_external of another job's member, group still empty: {adopted:?}");

    if adopted.is_ok() {
        // An `Ok` claims containment, so teardown must really reach it.
        group.kill_all().expect("hard-kill the adopted tree");
        poll_until(
            Duration::from_secs(10),
            Duration::from_millis(20),
            "a process adopted out of another job dies with this group",
            || !foreign_pid_running(pid),
        )
        .await;
    } else {
        // A refusal is fine — but it must be a refusal, not a silent no-op: the
        // process stays alive and unowned by this group.
        assert!(
            foreign_pid_running(pid),
            "a refused adoption must leave the process alone",
        );
    }

    // SAFETY: both handles came from Create/OpenProcess; closed exactly once.
    unsafe {
        CloseHandle(target);
        CloseHandle(outer);
    }
    let _ = child.kill().await;
}

/// The **other side of a successful nesting adoption**: once this group's job has
/// been nested under an outer job, does that outer job reach members this group
/// spawns *afterwards*?
///
/// This is the half a caller cannot see and the contract now states, so it is
/// observed here rather than reasoned about: the group adopts a member of an outer
/// job (the empty-group order, the one that succeeds), then starts a child of its
/// own, and the **outer** job is terminated. Observed on Windows 11 (26200): the
/// child started after the adoption dies with the outer job — an unrelated job now
/// reaches processes this group started, which is exactly what
/// `escalate_to_kill(false)`'s "spares the survivors" cannot promise across that
/// boundary.
///
/// The test skips itself if the adoption is refused on the host running it (the
/// pairing's verdict is the kernel's, and the sibling test above owns that
/// question); it asserts nothing about the adopted process, only about the member
/// this group started afterwards.
#[cfg(windows)]
#[tokio::test]
#[ignore = "creates a second Job Object, nests this group under it and terminates it"]
async fn windows_an_outer_job_reaches_members_spawned_after_a_nesting_adoption() {
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::System::JobObjects::{
        AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject,
    };
    use windows_sys::Win32::System::Threading::{
        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
    };

    let mut cmd = tokio::process::Command::new("ping");
    cmd.args(["-n", "120", "127.0.0.1"]);
    cmd.stdout(std::process::Stdio::null());
    let mut child = cmd.spawn().expect("spawn a child to put in the outer job");
    let pid = child.id().expect("a live child has a pid");

    // SAFETY: null name/attributes request an unnamed job with defaults.
    let outer: HANDLE = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
    assert!(!outer.is_null(), "CreateJobObjectW failed");
    // SAFETY: opens the child by pid with the rights an assign needs.
    let target = unsafe {
        OpenProcess(
            PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION,
            0,
            pid,
        )
    };
    assert!(!target.is_null(), "OpenProcess of our own child failed");
    // SAFETY: both handles are valid for the call.
    assert!(
        unsafe { AssignProcessToJobObject(outer, target) != 0 },
        "could not put the child into the outer job"
    );

    let group = ProcessGroup::new().expect("create group");
    let nested = group.adopt_external(pid).is_ok();
    let ours = group.start(&sleeper()).await.expect("start our own member");
    let our_pid = ours.pid().expect("a live child has a pid");

    if nested {
        // SAFETY: a valid job handle; terminates the outer job's whole hierarchy.
        assert!(
            unsafe { TerminateJobObject(outer, 1) != 0 },
            "TerminateJobObject of the outer job failed"
        );
        poll_until(
            Duration::from_secs(10),
            Duration::from_millis(20),
            "a member started AFTER the nesting adoption dies with the OUTER job",
            || !foreign_pid_running(our_pid),
        )
        .await;
    } else {
        eprintln!("skipping: this host refused the nesting adoption, so no nesting to observe");
    }

    // SAFETY: both handles came from Create/OpenProcess; closed exactly once.
    unsafe {
        CloseHandle(target);
        CloseHandle(outer);
    }
    let _ = child.kill().await;
}

/// The same pairing in the **other order**: this group already has a member of its
/// own — one that is *not* in the outer job's hierarchy — before it adopts a member
/// of that outer job. Nesting is a property of the two jobs, not of the one process,
/// so the answer need not be the one the empty-group case gives, and a caller's
/// spawn/adopt order is the difference.
///
/// Observed on Windows 11 (26200), reproducibly, and it is the **opposite** of the
/// empty-group case: the adoption is refused with `ERROR_ACCESS_DENIED`, where the
/// same pairing on an empty group succeeds. So "already in another job is not a
/// refusal" is true of one order and false of the other, which is why the public
/// contract states the dependence instead of the winning half.
///
/// Neither verdict is asserted as the general rule — the kernel owns the nesting
/// rules and this is one host — so what the test enforces is the honesty invariant
/// that holds whatever the verdict: an `Ok` must mean teardown really reaches it, an
/// `Err` must leave it alone, and either way the group's **own** member must still
/// die with the group.
#[cfg(windows)]
#[tokio::test]
#[ignore = "creates a second Job Object and adopts a member of it"]
async fn windows_adopt_external_of_a_process_already_in_another_job_after_our_own_spawn() {
    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
    use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW};
    use windows_sys::Win32::System::Threading::{
        OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
    };

    // This group's own member first — the whole point of the ordering.
    let group = ProcessGroup::new().expect("create group");
    let ours = group.start(&sleeper()).await.expect("start our own member");
    let our_pid = ours.pid().expect("a live child has a pid");

    let mut cmd = tokio::process::Command::new("ping");
    cmd.args(["-n", "120", "127.0.0.1"]);
    cmd.stdout(std::process::Stdio::null());
    let mut child = cmd.spawn().expect("spawn a child to put in the outer job");
    let pid = child.id().expect("a live child has a pid");

    // SAFETY: null name/attributes request an unnamed job with defaults.
    let outer: HANDLE = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
    assert!(!outer.is_null(), "CreateJobObjectW failed");
    // SAFETY: opens the child by pid with the rights an assign needs.
    let target = unsafe {
        OpenProcess(
            PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION,
            0,
            pid,
        )
    };
    assert!(!target.is_null(), "OpenProcess of our own child failed");
    // SAFETY: both handles are valid for the call.
    let assigned = unsafe { AssignProcessToJobObject(outer, target) != 0 };
    assert!(assigned, "could not put the child into the outer job");

    let adopted = group.adopt_external(pid);
    // Recorded rather than asserted: this is the observation the contract's wording
    // about order-dependence rests on.
    eprintln!("adopt_external of another job's member, group already non-empty: {adopted:?}");

    if adopted.is_ok() {
        group.kill_all().expect("hard-kill the adopted tree");
        poll_until(
            Duration::from_secs(10),
            Duration::from_millis(20),
            "a process adopted out of another job dies with this group",
            || !foreign_pid_running(pid),
        )
        .await;
    } else {
        assert!(
            foreign_pid_running(pid),
            "a refused adoption must leave the process alone",
        );
        group.kill_all().expect("hard-kill this group's own tree");
    }
    // Either way the group's own member is the group's to kill.
    poll_until(
        Duration::from_secs(10),
        Duration::from_millis(20),
        "this group's own member dies with the group",
        || !foreign_pid_running(our_pid),
    )
    .await;

    // SAFETY: both handles came from Create/OpenProcess; closed exactly once.
    unsafe {
        CloseHandle(target);
        CloseHandle(outer);
    }
    let _ = child.kill().await;
}

#[tokio::test]
#[ignore = "spawns real subprocesses and lists the group's members"]
async fn members_lists_live_children() {
    let group = ProcessGroup::new().expect("create group");
    let _a = group.start(&sleeper()).await.expect("start first sleeper");
    let _b = group.start(&sleeper()).await.expect("start second sleeper");

    // Windows/cgroup list the whole tree (a started child may be a shell plus
    // its own child); the pgroup backends list one leader per started child.
    // Either way, two started children mean at least two live pids.
    let members = group.members().expect("members");
    assert!(members.len() >= 2, "members: {members:?}");
}

#[tokio::test]
#[ignore = "spawns real subprocesses and watches the member list shrink"]
async fn members_shrinks_when_a_child_dies() {
    let group = ProcessGroup::new().expect("create group");
    // Single-process sleepers, deliberately: the cmd-wrapped `sleeper()` is two
    // processes whose second member spawns asynchronously, so a `before`
    // snapshot can race it — and `start_kill` would hit only the wrapper,
    // leaving its orphan in the job and the count above the threshold forever
    // (seen on a cold CI runner).
    let _keep = group.start(&sleep_secs(30)).await.expect("start survivor");
    let mut dying = group.start(&sleep_secs(30)).await.expect("start victim");
    let before = group.members().expect("members").len();
    assert!(before >= 2, "expected at least two members, got {before}");

    dying.start_kill().expect("kill victim");
    // Reap it (wait consumes the handle) so the kill is visible everywhere —
    // an unreaped zombie still probes as alive on the pgroup backends.
    let _ = completes_within(Duration::from_secs(10), "victim reap", dying.wait()).await;

    poll_until(
        Duration::from_secs(5),
        Duration::from_millis(50),
        &format!("member count never dropped below {before}"),
        || group.members().expect("members").len() < before,
    )
    .await;
}

#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn members_on_empty_group_is_empty() {
    let group = ProcessGroup::new().expect("create group");
    let members = group.members().expect("members");
    assert!(members.is_empty(), "fresh group has members: {members:?}");
}

#[tokio::test]
#[ignore = "spawns a real subprocess and reads its enriched member snapshot"]
async fn members_info_enriches_a_live_child() {
    let group = ProcessGroup::new().expect("create group");
    let child = group.start(&sleeper()).await.expect("start sleeper");
    let child_pid = child.pid().expect("child pid");

    let infos = group.members_info().expect("members_info");
    assert!(!infos.is_empty(), "members_info empty for a live child");

    // The started child's real pid must be among the enriched records — proof the
    // snapshot reports genuine member pids (whole tree on Windows/cgroup, the group
    // leader on the pgroup backends, and the direct child is that leader).
    let mine = infos
        .iter()
        .find(|m| m.pid() == child_pid)
        .unwrap_or_else(|| panic!("child pid {child_pid} not in members_info {infos:?}"));

    // Every field this platform declares available (see `MemberInfo`'s matrix) must
    // actually be filled for that live member — not silently `None`.
    #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
    {
        assert!(
            mine.ppid().is_some(),
            "ppid should be reported here: {mine:?}"
        );
        assert!(
            mine.exe_name().is_some(),
            "exe_name should be reported here: {mine:?}"
        );
        assert!(
            mine.start_time().is_some(),
            "start_time should be reported here: {mine:?}"
        );
    }
    // On the bare BSDs the enriching fields are honestly `None`; the pid being
    // present is the whole guarantee there.
    #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
    let _ = mine;
}

#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn members_info_on_empty_group_is_empty() {
    let group = ProcessGroup::new().expect("create group");
    let infos = group.members_info().expect("members_info");
    assert!(infos.is_empty(), "fresh group has members: {infos:?}");
}

// ── standalone process_info / process_is_alive (T-175) ───────────────────────

// A live child is recognised by the free-standing pid query — outside any group —
// with the same best-effort fields a group member carries, and its saved
// (pid, start-time) pair reports it alive.
#[tokio::test]
#[ignore = "spawns a real subprocess and queries its identity by pid"]
async fn process_info_identifies_a_live_child() {
    let group = ProcessGroup::new().expect("create group");
    let child = group.start(&sleeper()).await.expect("start sleeper");
    let pid = child.pid().expect("child pid");

    let info = processkit::process_info(pid)
        .expect("process_info must not error on a live child we own")
        .expect("a live child must be found by pid");
    assert_eq!(info.pid(), pid, "process_info reported the wrong pid");

    // Every field this platform declares available (see `MemberInfo`'s matrix) must
    // actually be filled for a live process — exactly as `members_info` fills them,
    // proof the standalone query reuses the same readers rather than a stub.
    #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
    {
        assert!(
            info.ppid().is_some(),
            "ppid should be reported here: {info:?}"
        );
        assert!(
            info.exe_name().is_some(),
            "exe_name should be reported here: {info:?}"
        );
        assert!(
            info.start_time().is_some(),
            "start_time should be reported here: {info:?}"
        );
    }

    // The saved (pid, start-time) pair reports the same instance alive. On the bare
    // BSDs `start_time()` is `None`, so this degrades to bare-pid liveness — still
    // `true` for a live child.
    assert!(
        processkit::process_is_alive(pid, info.start_time())
            .expect("liveness query must not error on a live child"),
        "the live child must read as alive by its (pid, start-time) pair",
    );
}

// Models a **recycled pid** without waiting for a real recycle: the pid is a live
// process, but the saved start-time differs — as if a different process had
// reclaimed the number after the original exited. A recycle-aware liveness check
// must report the saved instance gone. Only meaningful where a start-time token is
// reported (Windows / Linux / macOS); the bare BSDs report none and degrade to
// number-only liveness by design.
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
#[tokio::test]
#[ignore = "spawns a real subprocess to model a recycled pid without waiting for a real recycle"]
async fn process_is_alive_rejects_a_recycled_number() {
    let group = ProcessGroup::new().expect("create group");
    let child = group.start(&sleeper()).await.expect("start sleeper");
    let pid = child.pid().expect("child pid");

    let start = processkit::process_info(pid)
        .expect("process_info")
        .and_then(|i| i.start_time())
        .expect("a start-time token is reported on this platform");

    // The genuine token: same instance, alive.
    assert!(
        processkit::process_is_alive(pid, Some(start)).expect("liveness with the real token"),
        "the real (pid, start-time) pair must read as alive",
    );

    // A stale token on the *same live pid* stands in for the number having been
    // recycled by a different process — the check must call the saved instance gone.
    let stale = start ^ 0x5A5A_5A5A;
    assert_ne!(
        stale, start,
        "the stale token must differ from the real one"
    );
    assert!(
        !processkit::process_is_alive(pid, Some(stale))
            .expect("liveness must not error on a live pid with a stale token"),
        "a mismatched start-time must read as gone — the number was recycled",
    );
}

// Once a child is torn down and reaped, the free-standing query reports its saved
// (pid, start-time) pair gone — the "after it exits, an honest no" half of the
// contract. Uses the same `OpenProcess`-by-pid liveness the group-teardown tests
// rely on, polling to let the async reap complete (and, on Windows, the process
// handle close).
#[tokio::test]
#[ignore = "spawns a real subprocess, tears it down, and confirms its pid reads as gone"]
async fn process_is_alive_reports_gone_after_teardown() {
    let group = ProcessGroup::new().expect("create group");
    let child = group.start(&sleeper()).await.expect("start sleeper");
    let pid = child.pid().expect("child pid");
    let start = processkit::process_info(pid)
        .expect("process_info")
        .and_then(|i| i.start_time());

    assert!(
        processkit::process_is_alive(pid, start).expect("liveness while running"),
        "the child must read alive before teardown",
    );

    group.kill_all().expect("terminate the tree");
    let _ = child.wait().await.expect("reap the killed child");
    drop(group);

    // The pid must now read as gone. Poll: on the pgroup backends the reap frees the
    // number a beat after the kill, and on Windows the last process handle closes as
    // the child and job handles drop.
    poll_until(
        Duration::from_secs(10),
        Duration::from_millis(100),
        "the reaped child's pid still read as alive",
        || {
            !processkit::process_is_alive(pid, start)
                .expect("liveness after teardown must not error")
        },
    )
    .await;

    // A fresh lookup is likewise a clean negative (or, if the number was already
    // recycled by an unrelated process, a *different* instance — never the saved
    // one, which `process_is_alive` above already proved gone).
    match processkit::process_info(pid).expect("process_info after teardown must not error") {
        None => {}
        Some(info) => assert_ne!(
            info.start_time(),
            start,
            "the saved instance is gone; any Some here is a recycled stranger",
        ),
    }
}

// A pid past any valid range names no process: a clean `Ok(None)`, never an error
// or a panic, and never alive. The deterministic, subprocess-free negative.
#[tokio::test]
#[ignore = "queries a pid past any valid range"]
async fn process_info_on_a_nonexistent_pid_is_a_clean_none() {
    // ~2e9 is far above any OS's pid_max yet still fits `pid_t` (`i32`), so it
    // exercises each backend's real "not found" path rather than a range guard.
    let bogus = 2_000_000_000u32;
    assert_eq!(
        processkit::process_info(bogus).expect("a nonexistent pid is not an error"),
        None,
        "a nonexistent pid must be a clean None, not Some/Err",
    );
    assert!(
        !processkit::process_is_alive(bogus, None).expect("liveness on a gone pid"),
        "a nonexistent pid must read as not alive",
    );
    assert!(
        !processkit::process_is_alive(bogus, Some(12_345)).expect("liveness on a gone pid"),
        "a token cannot resurrect a nonexistent pid",
    );
}

// A foreign **privileged** process must never be a false "gone": the documented and
// verified behaviour is `Ok(Some)` when the caller may query it, else a permission
// `Err` — but never `Ok(None)` (which would let a caller conclude a live process is
// dead). Robust to whether the runner is elevated.
#[tokio::test]
#[ignore = "queries a known privileged system process by pid"]
async fn process_info_on_a_privileged_process_is_never_a_false_gone() {
    #[cfg(windows)]
    let pid = 4; // the Windows `System` process — normally not query-openable.
    #[cfg(not(windows))]
    let pid = 1; // pid 1: init/systemd (Linux), launchd (macOS), the container init.

    match processkit::process_info(pid) {
        Ok(Some(info)) => assert_eq!(info.pid(), pid, "reported the wrong pid"),
        Ok(None) => panic!(
            "a live privileged process (pid {pid}) must never read as a nonexistent pid — \
             'can't look' is not 'dead'"
        ),
        // A permission error is the honest "not allowed to look" — expected on the
        // targets/rights where the query is denied, and explicitly not a false gone.
        Err(_) => {}
    }
}

#[tokio::test]
#[ignore = "spawns a short subprocess and adopts it after reaping"]
async fn adopt_of_a_reaped_child_errors_instead_of_tracking_nothing() {
    let group = ProcessGroup::new().expect("create group");

    let mut cmd = if cfg!(windows) {
        let mut c = tokio::process::Command::new("cmd");
        c.args(["/c", "exit", "0"]);
        c
    } else {
        let mut c = tokio::process::Command::new("sh");
        c.args(["-c", "exit 0"]);
        c
    };
    let mut child = cmd.spawn().expect("spawn short child");
    let _ = tokio::time::timeout(Duration::from_secs(10), child.wait())
        .await
        .expect("short child exits");

    // A reaped child has no pid/handle left — adopting it must say so loudly
    // rather than silently tracking nothing.
    let err = group
        .adopt(&child)
        .expect_err("adopting a reaped child must error");
    assert!(
        matches!(err.reason(), processkit::ErrorReason::Io(_)),
        "expected the no-pid Io error, got {err:?}"
    );
}

#[tokio::test]
#[ignore = "spawns a child, kills it UNREAPED, then adopts the zombie"]
async fn adopt_of_an_exited_unreaped_child_is_ok() {
    // E21: a child that has EXITED but not yet been reaped (a zombie — its
    // handle/pid is still valid while the process is dead, distinct from the
    // reaped case above) has nothing to contain, so `adopt` returns Ok on every
    // backend (cgroup/pgroup `ESRCH` → Ok, Windows `GetExitCodeProcess` → Ok),
    // rather than surfacing the raw backend failure.
    let group = ProcessGroup::new().expect("create group");

    // A long-lived child we control: `start_kill` terminates it WITHOUT reaping,
    // so it is *deterministically* a dead-but-unreaped zombie at adopt time — no
    // reliance on natural-exit timing (a too-short sleep would adopt a still-live
    // child, whose assign succeeds, and never exercise the exited path).
    let mut cmd = if cfg!(windows) {
        let mut c = tokio::process::Command::new("ping");
        c.args(["-n", "60", "127.0.0.1"]);
        c
    } else {
        let mut c = tokio::process::Command::new("sleep");
        c.arg("60");
        c
    };
    let mut child = cmd.spawn().expect("spawn long-lived child");

    child
        .start_kill()
        .expect("kill the child without reaping it");
    // The kill is prompt (SIGKILL / TerminateProcess); give it a moment to become
    // a zombie. We never `wait`, so it stays unreaped (handle/pid still valid).
    tokio::time::sleep(Duration::from_millis(500)).await;

    group
        .adopt(&child)
        .expect("adopting an exited-but-unreaped (zombie) child must be a no-op Ok");

    let _ = child.wait().await;
    drop(group);
}

#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn empty_group_accepts_lifecycle_calls() {
    let group = ProcessGroup::new().expect("create group");

    // Signalling, freezing, and thawing nobody must succeed trivially…
    group.signal(Signal::Kill).expect("Kill on an empty group");
    if cfg!(windows) {
        // …except `Term`/`Int` on Windows: they would soft-close a console or
        // windowed member (`CTRL_BREAK` + `WM_CLOSE`), but an EMPTY group has
        // neither, so they are typed Unsupported here — a Job Object has no POSIX
        // signal to fall back on.
        let err = group
            .signal(Signal::Term)
            .expect_err("Term on an empty Windows group has no soft-close target");
        assert!(
            matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
            "expected Unsupported, got {err:?}"
        );
    } else {
        group.signal(Signal::Term).expect("Term on an empty group");
    }
    group.suspend().expect("suspend an empty group");
    group.resume().expect("resume an empty group");

    #[cfg(feature = "stats")]
    {
        let stats = group.stats().expect("stats on an empty group");
        assert_eq!(stats.active_process_count, 0);
    }
}

#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and nests suspend/resume"]
async fn windows_nested_suspend_needs_matching_resumes() {
    use tokio_stream::StreamExt;

    // Documented Windows semantics: suspend/resume are per-thread *counts*, so
    // two suspends need two resumes. A bare ping prints ~one line per second —
    // line flow is the freeze probe.
    let group = ProcessGroup::new().expect("create group");
    let mut run = group
        .start(&Command::new("ping").args(["-n", "31", "127.0.0.1"]))
        .await
        .expect("start ticker");
    let mut lines = run.stdout_lines().unwrap();
    tokio::time::timeout(Duration::from_secs(15), lines.next())
        .await
        .expect("ticker prints")
        .expect("first line");

    group.suspend().expect("suspend #1");
    group.suspend().expect("suspend #2");
    group.resume().expect("resume #1 of 2");

    // Drain lines emitted before the freeze landed; 2s of silence (double the
    // ticker period) means the tree is genuinely frozen.
    loop {
        match tokio::time::timeout(Duration::from_secs(2), lines.next()).await {
            Ok(Some(_)) => continue,
            Ok(None) => panic!("ticker exited while suspended"),
            Err(_) => break,
        }
    }
    assert!(
        tokio::time::timeout(Duration::from_secs(3), lines.next())
            .await
            .is_err(),
        "one resume must not thaw two suspends"
    );

    group.resume().expect("resume #2 of 2");
    let line = tokio::time::timeout(Duration::from_secs(15), lines.next())
        .await
        .expect("a balanced resume thaws the tree");
    assert!(line.is_some(), "ticker resumed output");
}

#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "adopts a real subprocess into a suspended cgroup"]
async fn linux_cgroup_adopt_into_suspended_group_freezes_the_child() {
    use tokio::io::AsyncBufReadExt;

    // Documented cgroup divergence: the freeze is *group state*, so a child
    // joining while the group is suspended freezes on attach. (Windows/pgroup
    // freeze only the members present at the call.) The join is exercised via
    // `adopt` — the parent writes the pid itself. `group.start()` would test
    // the same kernel behavior but can BLOCK here: the pre-exec cgroup join
    // freezes the child before the spawn handshake completes (see the
    // `suspend` rustdoc), which would hang this very test.
    let group = ProcessGroup::new().expect("create group");
    if !matches!(group.mechanism(), Mechanism::CgroupV2) {
        eprintln!("skipping: needs the cgroup mechanism");
        return;
    }

    // A free-running ticker, spawned OUTSIDE the group.
    let mut ticker = tokio::process::Command::new("sh")
        .args(["-c", "while :; do echo tick; sleep 0.25; done"])
        .stdout(std::process::Stdio::piped())
        .kill_on_drop(true)
        .spawn()
        .expect("spawn ticker");
    let stdout = ticker.stdout.take().expect("ticker stdout");
    let mut lines = tokio::io::BufReader::new(stdout).lines();
    tokio::time::timeout(Duration::from_secs(10), lines.next_line())
        .await
        .expect("ticker prints")
        .expect("read line")
        .expect("a tick before adoption");

    group.suspend().expect("suspend the empty group");
    group
        .adopt(&ticker)
        .expect("adopt the ticker into the frozen cgroup");

    // Drain ticks emitted before the freeze landed; 1s of silence (4× the
    // tick period) means the child is genuinely frozen…
    loop {
        match tokio::time::timeout(Duration::from_secs(1), lines.next_line()).await {
            Ok(Ok(Some(_))) => continue,
            Ok(_) => panic!("ticker exited while frozen"),
            Err(_) => break,
        }
    }
    // …and stays frozen.
    assert!(
        tokio::time::timeout(Duration::from_secs(2), lines.next_line())
            .await
            .is_err(),
        "a child adopted into a suspended cgroup must freeze on attach"
    );

    group.resume().expect("resume");
    let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
        .await
        .expect("thawed ticker resumes output")
        .expect("read line");
    assert_eq!(line.as_deref(), Some("tick"));

    let _ = ticker.kill().await;
}