File size: 144,521 Bytes
488d96b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
/**
 * CPython subprocess PTC runtime: a fresh `python3` process runs each model program under an
 * asyncio event loop with top-level ``await``. Binding calls travel on fd 3 as JSON-lines,
 * leaving stdout/stderr free for the program's own output. This is containment, not a security
 * boundary: model code has bash-equivalent trust, contained by a tempdir-only environment,
 * RLIMIT_CPU + RLIMIT_AS, wall-clock timeout, and SIGTERM→grace→SIGKILL on the process group.
 *
 * The package also owns the versionless fd-3 wire protocol itself; its host-side codec and
 * hostile-frame validators are re-exported so every consumer of the wire shares one vocabulary.
 * @module @deepseek-ai/dsh-experimental-ptc-runtime-python
 */

import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { accessSync, copyFileSync, constants as fsConstants, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getHeapStatistics } from 'node:v8'
import type { Duplex } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { PtcRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime'
import type { PtcBindingErrorClass, PtcBindingFunction, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
import { checkDoneValue, encodeJsonPlain, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from './protocol.ts'

// Re-export the fd-3 wire vocabulary so the runtime and its tests share one
// import surface; the protocol layer owns the definitions.
export type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts'
export {
  checkDoneValue,
  encodeJsonPlain,
  hasNonLosslessNumber,
  hasUnsafeIntegerToken,
  logTruncationMarker,
  validateChildFrame,
} from './protocol.ts'

/** Plugin config: every cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
  /**
   * RLIMIT_CPU in whole seconds (a positive integer — `setrlimit` in the child
   * rejects a float). The child sets the soft limit to `cpuSeconds` and the
   * hard limit to `cpuSeconds + 1`: the kernel delivers SIGXCPU at the soft
   * limit, which the host classifies as a `timeout`; the +1s hard limit is a
   * SIGKILL backstop for a program that traps SIGXCPU. Granularity is whole seconds.
   */
  cpuSeconds?: number
  /** Wall-clock ceiling in milliseconds; backstops CPU time for programs awaiting a promise nobody resolves. */
  maxWallMs?: number
  /**
   * RLIMIT_AS in mebibytes; caps address space so a runaway allocation fails
   * cleanly. Not applied on Darwin, where the dyld shared cache mapped into
   * every process at exec exceeds any practical cap and the kernel rejects
   * the call; `cpuSeconds` and `maxWallMs` still bound the run there. Bounds
   * `maxLogBytes`/`maxValueBytes` at load on EVERY platform (this static check
   * runs on Darwin too, where only the runtime `setrlimit` is skipped): each
   * budget times a worst-case Unicode expansion must fit this byte count minus a
   * fixed interpreter baseline, so a near-budget output cannot breach the address
   * space during the child's build-and-encode.
   */
  addressSpaceMb?: number
  /**
   * Shared byte budget for captured log text (host-side ledger). Bounded at load
   * against `addressSpaceMb`: the child builds and encodes a near-budget entry
   * under RLIMIT_AS with several copies live at once, so this cap times the
   * worst-case Unicode expansion must fit the address space left after the
   * interpreter baseline (see `addressSpaceMb`) — a load-time rejection, not a
   * runtime clamp. Also bounded at load by the host's configured heap like
   * `maxValueBytes` (see its JSDoc): the effective frame cap minus the frame
   * envelope.
   */
  maxLogBytes?: number
  /**
   * Byte cap for the completion value. Bounded at load against `addressSpaceMb`
   * the same way `maxLogBytes` is: the child builds and encodes a near-budget
   * value under RLIMIT_AS with several copies live at once, so this cap times the
   * worst-case Unicode expansion must fit the address space left after the
   * interpreter baseline. Both budgets are ALSO bounded at load by the host's
   * configured heap: the effective frame cap (the protocol cap, or a lower
   * heap-derived ceiling when the host heap cannot safely parse a near-cap
   * frame — see `hostFrameParseCeiling`) minus the frame envelope, so a budget
   * whose honest frame could OOM the host's own JSON.parse is rejected up
   * front.
   */
  maxValueBytes?: number
  /** SIGTERM→SIGKILL grace period on kill, matching bash-local's default. */
  graceMs?: number
  /**
   * Absolute path, relative path, or basename of a CPython 3.10+ interpreter.
   * Resolved and validated once at plugin load under a five-second force-kill
   * deadline; a basename searches `PATH`.
   */
  pythonBin?: string
}

/** {@link Config} with all defaults filled. */
type ResolvedConfig = Required<Config>

/**
 * The seam's language-portable identifier subset (see
 * `PtcBindingNamespace.global`) — identical to Python's identifier grammar,
 * so the shared contract needs no per-backend mapping here.
 */
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/

/**
 * The seam's cross-language reserved-word union: the portable-identifier
 * contract promises a namespace list valid here is valid on every backend, so
 * a JS keyword like `typeof` is refused even though it is a legal Python name.
 */
const RESERVED_NAMES = PORTABLE_RESERVED_WORDS

/**
 * The seam's shared backend-owned globals (`console` is the Node provider's slot;
 * `__dsh_main__`/`__builtins__`/`__name__` are this bootstrap's wrapper and
 * seeded module globals). Shared so a namespace list valid on one backend is
 * valid on all — colliding with an owned slot would be silently overwritten
 * (or overwrite builtins), so the seam rejects them up front.
 */
const RUNTIME_OWNED_GLOBALS = RESERVED_BINDING_GLOBALS

/**
 * The seam's shared error-member exclusions (`RESERVED_ERROR_MEMBERS` +
 * dunder-form names) — enforced identically here and in the Node backend so
 * an errorClass valid on one backend is valid on all. Several dunders are
 * constrained CPython descriptors whose `setattr` raises while constructing
 * the very rejection it was meant to carry; the exact set is an interpreter
 * version detail, hence the dunder-wide rule at the seam.
 */
const EXCEPTION_RESERVED_MEMBERS = RESERVED_ERROR_MEMBERS

const DUNDER = DUNDER_MEMBER

/**
 * The `py/` scripts the interpreter must be able to open: the entry script plus
 * every module it imports from its own directory. Kept beside the built JS so a
 * consumer package with `files: ['lib', 'py']` ships both.
 */
const PY_SCRIPTS = ['bootstrap.py', 'protocol.py']

/**
 * Copy the `py/` scripts to a real filesystem directory and return the entry
 * script's path there.
 *
 * The interpreter is an EXTERNAL process, so it can only open paths the OS
 * resolves. Inside the single-file Python-SDK executable, `import.meta.url`
 * resolves into pkg's virtual filesystem, which Node reads through its patched
 * `fs` but `python3` cannot see at all — the spawn fails with ENOENT on a path
 * that exists as far as the host is concerned. `bootstrap.py` additionally
 * inserts its own directory on `sys.path` to import the sibling `protocol.py`,
 * so both files must land in the SAME real directory.
 *
 * The copy is unconditional rather than gated on a bundled-runtime probe: the
 * read goes through Node's `fs` either way, and one code path means the
 * packaged deployment runs what the tests exercise. Placement is under
 * `os.tmpdir()` with `0o700` keeps the scripts off other users' reach, but NOT
 * the model's: the child runs as the same UID as the host, so a program can
 * rewrite the very files it was started from. Hence one copy per RUN, discarded
 * at settlement — a rewrite then damages only the run that performed it, which
 * is what fresh-subprocess-per-run already promises. Sharing one copy across
 * runs made an overwritten `bootstrap.py` break the next run.
 *
 * Deliberately SYNCHRONOUS. An `await` here would open an async boundary in
 * `execute` before the run is registered in `live` and before the abort
 * listener is installed, so a disposal or an abort landing in that window would
 * be missed: `teardown` would see no runs and return while the continuation
 * went on to spawn a subprocess, and an `addEventListener('abort')` installed
 * afterwards does not replay an event that already fired. Three small
 * filesystem operations per run are not worth that class of race, and `execute`
 * already runs synchronously up to `spawn`.
 *
 * A failed copy removes the directory here, so a partial attempt never outlives
 * the call that made it; a successful one is the caller's to remove, which it
 * derives from the returned path.
 *
 * @returns the absolute path of the materialized entry script.
 */
function materializePyScripts(): string {
  const dir = mkdtempSync(join(tmpdir(), 'dsh-ptc-runtime-python-'))
  const source = fileURLToPath(new URL('../py/', import.meta.url))
  try {
    for (const name of PY_SCRIPTS) copyFileSync(join(source, name), join(dir, name))
  } catch (error: unknown) {
    try {
      rmSync(dir, { recursive: true, force: true })
    } catch {
      // Swallows only a failure to remove the partial staging directory. The
      // caller reports the copy failure that got us here, which is the
      // diagnosable one; nothing else can act on a temp dir we cannot unlink.
    }
    throw error
  }
  return join(dir, 'bootstrap.py')
}

/**
 * A frame's RAW length is capped before JSON.parse: the 64 MiB fd-3 frame
 * parse cap bounds the bytes, not the decoded structure, and a compact wide
 * frame near that ceiling (e.g. a huge array of tiny elements) could decode to
 * far more host memory than the wire admitted — an OOM inside the receive
 * path. 64 MiB raw admits every legal config (the widest in-tree completion
 * and binding frames are ~12 MB) while bounding decode amplification to a
 * roughly constant factor of the wire bytes. The unframed-buffer counter is
 * checked against this same cap BEFORE a `Buffer.concat` join, so an oversized
 * frame is dropped at one copy of its wire bytes. A hostile-peer invariant,
 * not a deployment choice.
 */
const FRAME_PARSE_CAP_BYTES = 64 * 1024 * 1024

/**
 * Fragments the unframed fd-3 buffer may hold before they are coalesced into
 * one Buffer, bounding retained per-chunk overhead that the byte cap cannot
 * see: the cap meters payload bytes, while each chunk is a distinct Buffer
 * with its own object and backing store. A
 * program writing single bytes without a newline produced one chunk per write.
 * 1024 keeps the overhead a small constant factor of the payload while leaving
 * normal pipe-sized reads (which arrive in far fewer, much larger chunks)
 * untouched. A framing invariant, not a deployment choice.
 */
const MAX_PENDING_CHUNKS = 1024

/**
 * Replies the host retains before fd 3 accepts them. The drain loop writes one
 * reply per iteration and waits for `drain` when the pipe is full; a child
 * that never reads its replies (hostile or wedged) leaves the pipe full, so
 * every call frame it keeps sending adds a reply the drain cannot write, and
 * the backlog would grow without bound until the wall clock. 1024 keeps
 * legitimate concurrent gathers (measured queue depths reach 11) far below
 * the ceiling while bounding the hostile backlog; the run settles as a
 * worker-exit past it, like the frame cap settles an oversized frame. A
 * framing invariant, not a deployment choice.
 */
const MAX_PENDING_REPLIES = 1024

/**
 * Bytes a frame spends on its own JSON structure around a capped payload, used
 * to bound `maxLogBytes`/`maxValueBytes` against {@link FRAME_PARSE_CAP_BYTES}
 * (the receive path rejects raw frames past that cap, settling the run as a
 * worker-exit).
 * The widest carrier is `{"type":"log","text":"","truncated":true}` at 41
 * bytes; 64 rounds that up so adding a field to either frame does not silently
 * invalidate the bound. A protocol constant, not a deployment choice.
 */
const FRAME_ENVELOPE_BYTES = 64

/**
 * Smallest `maxLogBytes` the backend can honor. The truncation marker alone
 * (`logTruncationMarker`) must serialize within the budget, or a marker-only
 * truncated run returns more than the configured cap: the marker text is
 * `[dsh-ptc-runtime-python] log capture truncated at <N> bytes` — 50 fixed
 * characters (the bracketed prefix `[dsh-ptc-runtime-python] log capture
 * truncated at ` counts both square brackets) plus the digits of N plus 6 —
 * and its serialized form adds 4 (two quotes, two array brackets), so the
 * smallest N that admits its own marker is 62 (50 + 2 + 6 + 4 = 62); 64 is the
 * floor with two bytes of room. The marker itself remains envelope, not
 * payload, so a truncated run with admitted entries serializes to at most
 * `maxLogBytes + marker + envelope`.
 * `maxValueBytes` has no floor beyond the positive-integer requirement: a
 * completion can be as small as a single byte (`1`), and the done-frame
 * envelope is seam protocol cost, not the advertised completion budget.
 */
const MIN_LOG_BYTES = 64

/**
 * Extra time added to `graceMs` before the post-kill close-deadline force-settles
 * a run whose `close` never fires (a setsid-escaped orphan holds our inherited
 * stdio; see the `closeDeadline` arm in {@link PythonPtcRuntime.execute}). It
 * covers the OS reaping the killed child itself after SIGKILL — not a deployment
 * choice but a fixed safety margin, so it is a constant rather than a config knob.
 */
const CLOSE_REAP_MARGIN_MS = 2_000

/**
 * Worst-case peak child-process bytes a one-`maxLogBytes`/`maxValueBytes`-budget
 * output can transiently occupy while the child charges and frames it, expressed
 * as a multiple of the budget. The child's ledgers trigger on CHARACTER count
 * against a serialized-BYTE budget, and an astral character is one character but
 * four bytes of CPython `str` storage and four UTF-8 bytes — so a budget's worth
 * of astral characters is ~4x the budget in each string that holds it. The
 * heaviest path holds THREE such copies at once: a single
 * `sys.stdout.write(line + "\n")` keeps the caller's `text` argument (alive for
 * the whole `write` call, ~4x), the line slice `text[pos:newline]` handed to
 * `LogBuffer.push` (~4x), and the `text.encode("utf-8")` copy `_push_locked`
 * takes to charge and ship it (~4x). The settlement `flush_line` path holds only
 * two (its `"".join(...)` and that encode copy — it drops the pending chunks
 * before pushing), so the newline path is the binding worst case. Twelve covers
 * those three simultaneous ~4x copies. The interpreter baseline is NOT in this
 * multiple — it is reserved separately as {@link INTERPRETER_BASELINE_BYTES} —
 * because it is a fixed cost, not one that scales with the budget. Used to bound
 * `maxLogBytes`/`maxValueBytes` against `addressSpaceMb` at load, with a `>=` so
 * a budget whose worst-case peak exactly equals the room left after the baseline
 * is rejected (that peak plus the baseline is the whole address space, the
 * RLIMIT_AS edge), so a legitimate near-budget output truncates (log) or fails
 * as `output-limit` (value) rather than breaching `RLIMIT_AS` as `worker-exit`.
 * A fixed safety invariant tying the budgets to the address space, not a knob.
 */
const OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE = 12

/**
 * Fixed address-space headroom reserved for the CPython interpreter itself
 * (loaded modules, the asyncio loop, import machinery) before the output-budget
 * multiple claims the rest. The budget check subtracts this from `addressSpaceMb`
 * so a budget sized right at `addressSpaceMb / MULTIPLE` — which the multiple
 * alone would admit — cannot leave the peak output allocation plus the
 * interpreter over the limit. Sized against ADDRESS SPACE, which is what
 * `RLIMIT_AS` bounds, not resident set: the bootstrap's own measurement is
 * 30.23 MiB of mappings for a `python3 -I` child (see `_make_cpu_enforcer`,
 * which also records the 64 MiB glibc per-thread arena reservation that pushes
 * it to 102.37 MiB when threads are used). 64 MiB is roughly twice the measured
 * baseline, leaving room for allocator arenas and import jitter. The value is a
 * fixed safety margin, not a deployment knob.
 */
const INTERPRETER_BASELINE_BYTES = 64 * 1024 * 1024

/**
 * Worst-case peak host-heap bytes the PARSE of one inbound fd-3 frame can
 * transiently occupy, expressed as a multiple of the frame's raw bytes.
 * `JSON.parse` of a wide container materializes the object's property storage
 * and key strings on top of the raw text; the WORST shape is a dict of many
 * SHORT UNIQUE keys, which forces V8's dictionary-mode property storage
 * (~32-64 bytes per entry) plus one interned string per key (header + data)
 * plus string-table growth: measured 6.4x for a 3,000,000-key frame (~31 MB
 * raw) on a 1 GiB heap, trending up with key count (a flat unique-key array
 * is ~4x, a repeated-key dict ~3x). On a constrained heap the parse also
 * retains the raw frame string while the object builds, so the safety factor
 * is 16x — ~2.5x over the measured worst shape, ~1.6x over the claimed
 * GC-headroom bound. Used with the host's configured heap limit to derive the
 * largest frame whose parse cannot OOM the host process. This bounds the
 * HOST's parse; {@link OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE} bounds
 * the CHILD's build and encode under RLIMIT_AS, a different resource. A fixed
 * safety invariant, not a knob.
 */
const HOST_PARSE_WORST_CASE_MULTIPLE = 16

/**
 * Fixed host-heap headroom reserved for the application itself (the dsh
 * fiber, plugins, and this runtime's own state) before the frame-parse
 * multiple claims the rest: the effective frame cap is derived from
 * `heap_size_limit - HOST_PARSE_BASELINE_BYTES`, so a constrained host's
 * parse ceiling never spends the application's working set. A fixed safety
 * margin, not a knob.
 */
const HOST_PARSE_BASELINE_BYTES = 64 * 1024 * 1024

/**
 * The largest inbound fd-3 frame the HOST can parse without risking a
 * process-level OOM on its current heap: the configured heap limit (honoring
 * `--max-old-space-size`) minus the application baseline, divided by the
 * worst-case parse multiple, floored to the protocol frame cap. The
 * raw-byte cap alone does not protect the heap — `JSON.parse` of a
 * ≤64 MiB wide-object frame materializes several times that in property
 * storage — so the effective cap is the smaller of the two. A default Node
 * heap (~4 GiB) never binds; a constrained host (e.g.
 * `--max-old-space-size=256` reports a ~300 MiB limit) lowers it to ~14 MiB,
 * and the load gate rejects budgets that cannot cross it.
 * @param heapLimit - the host's configured heap limit; the live
 * `heap_size_limit` when omitted. A parameter so the derivation is unit
 * testable against simulated heap sizes.
 * @returns the effective frame parse cap in bytes.
 */
export function hostFrameParseCeiling(heapLimit: number = getHeapStatistics().heap_size_limit): number {
  return Math.min(FRAME_PARSE_CAP_BYTES, Math.floor((heapLimit - HOST_PARSE_BASELINE_BYTES) / HOST_PARSE_WORST_CASE_MULTIPLE))
}

/**
 * Interval between process-group liveness probes while settlement waits for an
 * escalated SIGKILL to empty the group (see the `killing` branch in
 * {@link PythonPtcRuntime.execute}'s settle). A poll rather than an event
 * because the group members are the model's own descendants, which the host does
 * not `wait()` for and gets no exit signal from; the probe is a signal-0
 * `process.kill(-pid, 0)`, so the interval only bounds how promptly a now-empty
 * group is noticed, capped by `graceMs + CLOSE_REAP_MARGIN_MS`.
 */
const GROUP_REAP_POLL_MS = 50

/**
 * Extract a human message from an unknown thrown value.
 *
 * `String(error)` runs the value's own conversion, and a host binding may reject
 * with an object whose `Symbol.toPrimitive` or `toString` throws. One call site
 * is a detached async reply callback, where that throw escapes as an unhandled
 * rejection: the reply frame is never written, the program stays blocked on
 * `await`, and the run degrades to a `maxWallMs` timeout (a Node host without an
 * `unhandledRejection` listener exits outright). The conversion is therefore
 * wrapped, with a fixed literal as the fallback — the value already proved it
 * cannot be rendered, so nothing derived from it is safe to try.
 *
 * `Error.message` is typed `string` but is a plain writable property, so a
 * rejecting binding can hand back an `Error` carrying any value there. The
 * `Error` arm therefore goes through the same conversion rather than returning
 * `message` verbatim: the returned string crosses the wire under
 * `encodeJsonPlain`'s JSON-plain precondition, where a cyclic object grows the
 * encoder stack until the host exhausts memory and any other unsupported value
 * prevents the reply frame outright.
 *
 * The same conversion renders abort reasons, which reach an `AbortSignal`
 * listener: Node reports a throw from such a listener as an uncaught exception,
 * so an unwrapped conversion there can terminate the host with the run left
 * unsettled.
 *
 * @param error The thrown value, of unknown shape.
 * @returns The value's message or string form; a fixed placeholder when its own
 *   conversion throws.
 */
function messageOf(error: unknown): string {
  try {
    return String(error instanceof Error ? error.message : error)
  } catch {
    // Swallows only a throw from the value's own `message` getter or string
    // conversion. Nothing else runs inside the try, and the placeholder is a
    // literal, so this cannot throw again.
    return '<unrenderable rejection value>'
  }
}

/**
 * A process's start time, as the identity half of (pid, started).
 *
 * A pid is reusable the moment the kernel reaps it, so signalling one that a
 * later process inherited would terminate an unrelated process group. Start
 * time is what distinguishes the original from its replacement: `kill(pid, 0)`
 * answers "does this number exist", which is true for both.
 *
 * Linux reads field 22 of `/proc/<pid>/stat` (starttime in clock ticks); the
 * field is positional after the comm field's closing parenthesis, which is
 * parsed from the LAST such character because a process name may contain one.
 * Darwin has no `/proc`, so the caller gets `undefined` there and `killGroup`
 * signals the pgid without the identity re-check rather than paying a `ps`
 * fork on a teardown path. Any read failure is `undefined` for the same
 * reason: this
 * hardens a narrow race and must never be the thing that breaks teardown.
 * @param pid - the process to read.
 * @returns its start time, or undefined when unavailable.
 */
export function readProcessStart(pid: number): string | undefined {
  /* v8 ignore next -- one arm per platform: the Linux coverage lane always takes the read path, and Darwin always this one. */
  if (process.platform !== 'linux') return undefined
  try {
    const stat = readFileSync(`/proc/${String(pid)}/stat`, 'utf8')
    const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
    // Field 22 overall; the slice above dropped pid and comm, so it is index 19.
    return fields[19]
  } catch {
    return undefined
  }
}

/**
 * Resolve `pythonBin` to one executable absolute path at plugin load. A basename
 * (the default `python3`) searches the current process `PATH`; the child receives
 * no `PATH`, so Node's own lookup would otherwise fall back to the platform
 * default (`/usr/bin:/bin`) and miss interpreters
 * that live only on the caller's `PATH` (Nix, pyenv, Homebrew, conda). An
 * absolute path is verified in place, and an explicitly relative path is first
 * resolved against the load-time working directory. When no candidate is an
 * executable regular file, `undefined` is returned and the load check rejects
 * the configuration: falling back to the bare name would let spawn's scrubbed env
 * execvp silently start a system interpreter from the platform default PATH
 * that the caller never asked for.
 * @param bin - the configured interpreter (absolute path, relative path, or bare command).
 * @returns an absolute path when resolvable, else `undefined`.
 */
export function resolvePythonBin(bin: string): string | undefined {
  const executableFile = (candidate: string): string | undefined => {
    try {
      accessSync(candidate, fsConstants.X_OK)
      return statSync(candidate).isFile() ? candidate : undefined
    } catch {
      // Missing, inaccessible, and non-stat-able candidates are ordinary
      // lookup misses; the constructor reports the final load error.
      return undefined
    }
  }
  if (isAbsolute(bin)) return executableFile(bin)
  if (bin.includes('/')) return executableFile(resolve(bin))
  const path = process.env.PATH
  /* v8 ignore next -- PATH is set in every environment the runtime boots in; the guard is defensive. */
  if (path === undefined) return undefined
  for (const dir of path.split(delimiter)) {
    // An empty PATH segment (a `::`, implicitly CWD on POSIX) and a RELATIVE
    // segment (`bin` or `.`) are skipped: a basename must never resolve against
    // the working directory, and the returned candidate must be an absolute
    // path — spawn() resolves a relative pythonBin against the host CWD, which
    // is outside the seam contract.
    if (dir === '' || !isAbsolute(dir)) continue
    const executable = executableFile(join(dir, bin))
    if (executable !== undefined) return executable
  }
  return undefined
}

/** Lowest CPython version supported by the bootstrap and its traceback behavior. */
const MIN_CPYTHON = { major: 3, minor: 10 } as const

/** Fixed load-time probe bound; a configured executable must not hang plugin activation. */
const PYTHON_PROBE_TIMEOUT_MS = 5_000

/** The only host environment fact exposed to the child. */
function pythonEnvironment(): NodeJS.ProcessEnv {
  return { TMPDIR: tmpdir() }
}

/** Fail load unless `bin` is a responsive CPython 3.10+ interpreter. */
function validatePythonBin(bin: string): void {
  let output: string
  try {
    output = execFileSync(bin, [
      '-I',
      '-c',
      'import sys; print(sys.implementation.name, sys.version_info.major, sys.version_info.minor, sys.version_info.micro)',
    ], {
      encoding: 'utf8',
      env: pythonEnvironment(),
      timeout: PYTHON_PROBE_TIMEOUT_MS,
      // The configured executable is outside our control. Force-kill it at the
      // deadline so a wrapper that ignores SIGTERM cannot block plugin load.
      killSignal: 'SIGKILL',
      maxBuffer: 1_024,
    }).trim()
  } catch (error: unknown) {
    throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} failed the CPython version probe: ${messageOf(error)}`)
  }
  const match = /^(\S+) (\d+) (\d+) (\d+)$/.exec(output)
  if (match === null) {
    throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} did not report a CPython version`)
  }
  const [, implementation, majorText, minorText, patchText] = match
  const major = Number(majorText)
  const minor = Number(minorText)
  if (implementation !== 'cpython') {
    throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython, got ${implementation}`)
  }
  if (major < MIN_CPYTHON.major || (major === MIN_CPYTHON.major && minor < MIN_CPYTHON.minor)) {
    throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(bin)} must be CPython ${MIN_CPYTHON.major}.${MIN_CPYTHON.minor} or newer, got ${implementation} ${majorText}.${minorText}.${patchText}`)
  }
}

/** The marker appended when a diagnostic message is byte-capped host-side. */
const TRUNCATION_MARKER = '… [truncated]'

/**
 * The marker's own UTF-8 byte length, reserved out of the budget so a capped
 * message stays WITHIN `maxValueBytes` rather than exceeding it by the marker.
 * The ellipsis is 3 bytes, so this is 15, not the string's 13 code units.
 */
const TRUNCATION_MARKER_BYTES = Buffer.byteLength(TRUNCATION_MARKER, 'utf8')
// Fatal UTF-8 decoder for fd-3 frames: `toString('utf8')` replaces illegal
// bytes with U+FFFD, which would silently corrupt a completion or binding
// payload a forged frame smuggled in; a fatal decode throws instead and the
// frame is dropped. Non-stream mode keeps it stateless across lines.
const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true })

/**
 * Serialized JSON byte width of one character, given its code point and the
 * one-character string. Control characters below 0x20 escape to `\uXXXX` (6)
 * except the five with short forms `\b \t \n \f \r` (2); `"` and `\` escape to
 * 2; a LONE surrogate escapes to `\uXXXX` (6) under ES2019 well-formed
 * `JSON.stringify`; everything else rides at its raw UTF-8 width.
 * @param code - the character's code point.
 * @param character - the one-character (or one-code-point) string.
 * @returns the character's serialized JSON byte width.
 */
function serializedCharCost(code: number, character: string): number {
  if (code < 0x20) return code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d ? 2 : 6
  if (code === 0x22 || code === 0x5c) return 2
  if (code >= 0xd800 && code <= 0xdfff) return 6
  return Buffer.byteLength(character, 'utf8')
}

/**
 * Serialized JSON-string cost of `text` (the two quotes plus each character's
 * escaped byte width), measured WITHOUT materializing the escaped copy, and
 * abandoned the instant it exceeds `maxBytes`. `JSON.stringify(text)` would
 * allocate the whole escaped form first — up to sixfold a control-char-dense
 * string — so a near-budget line under a large `maxLogBytes` could momentarily
 * allocate over a gigabyte just to measure it. This walks code point by code
 * point (a matched surrogate pair yields its combined code point ≥ 0x10000; a
 * lone surrogate yields a value in 0xD800–0xDFFF that {@link serializedCharCost}
 * charges the full six escaped bytes) and stops at the cap, allocating nothing.
 * @param text - the candidate string.
 * @param maxBytes - the largest serialized size the caller can admit.
 * @returns the exact serialized byte cost, or `undefined` once it exceeds `maxBytes`.
 */
function jsonStringCostUpTo(text: string, maxBytes: number): number | undefined {
  if (maxBytes < 2) return undefined
  let bytes = 2 // the enclosing quotes
  for (const character of text) {
    bytes += serializedCharCost(character.codePointAt(0) as number, character)
    if (bytes > maxBytes) return undefined
  }
  return bytes
}

/**
 * Cross-chunk UTF-8 state for {@link accrueStrayCost}: `expected` continuation
 * bytes still needed to finish the in-progress sequence, its total `width`, and
 * `lowerFirst`/`upperFirst`, the valid range for the NEXT continuation byte
 * (only the first continuation of a 3- or 4-byte lead is range-restricted; once
 * consumed, later continuations accept the full 0x80–0xBF). All zero between
 * sequences. Carried on each {@link StrayBuffer} so a multibyte character split
 * across pipe `data` chunks is costed as one character.
 */
interface Utf8CostState { expected: number; width: number; lowerFirst: number; upperFirst: number }

/**
 * Accrue the serialized JSON cost of raw pipe bytes `buf`, decoding UTF-8 the way
 * `toString('utf8')` (WHATWG) would so a byte that renders as U+FFFD is charged
 * the three bytes that replacement character serializes to. A naive tally that
 * charged every byte 1 let a `b"\xff"` flood (every byte illegal → U+FFFD each)
 * grow the residual to a full budget's worth of raw bytes before flushing; near
 * a large `maxLogBytes` that retained ~256 MiB, then `flushStray`'s
 * `Buffer.concat` + `toString` expanded it to a ~1 GiB peak. Charging only the
 * structural width would leave the same gap for structurally-well-formed but
 * ILLEGAL sequences a flood produces just as cheaply — a CESU-8 surrogate
 * (`ED A0 80`) or an overlong (`E0 80 80`) decodes to THREE U+FFFD (cost 9), not
 * one width-3 character, so this validates each lead's first continuation range
 * (WHATWG: `E0`→A0-BF, `ED`→80-9F, `F0`→90-BF, `F4`→80-8F, others 80-BF) and
 * charges 3 per byte of any sequence that breaks. A control byte below 0x20
 * costs 6 (`\uXXXX`) or 2 (five short escapes); `"`/`\` cost 2; ASCII costs 1; a
 * fully valid multibyte sequence costs its byte width (2/3/4). `state` carries
 * the in-progress sequence across chunks; an unfinished tail at stream end is
 * decoded by the final `flushStray` and costed exactly there.
 * @param buf - raw bytes from a stdout/stderr pipe chunk.
 * @param state - the pipe's carried UTF-8 sequence state, mutated in place.
 * @returns the serialized cost accrued by the bytes that resolved in this call.
 */
function accrueStrayCost(buf: Buffer, state: Utf8CostState): number {
  let cost = 0
  let index = 0
  while (index < buf.length) {
    const byte = buf[index] as number
    if (state.expected > 0) {
      // The valid range for THIS continuation: the lead-specific range applies
      // to the first continuation only, then reverts to the full 0x80–0xBF.
      const consumed = state.width - state.expected
      const lower = consumed === 1 ? state.lowerFirst : 0x80
      const upper = consumed === 1 ? state.upperFirst : 0xbf
      if (byte >= lower && byte <= upper) {
        state.expected -= 1
        if (state.expected === 0) {
          cost += state.width
          state.width = 0
        }
        index += 1
        continue
      }
      // The sequence broke. WHATWG's maximal-subpart rule folds the bytes
      // consumed so far into ONE U+FFFD (cost 3), then reprocesses this byte as
      // a fresh start (no index advance). Charging per consumed byte would
      // over-count, which is memory-safe but wrong; folding to one is exact.
      cost += 3
      state.expected = 0
      state.width = 0
      continue
    }
    if (byte < 0x20) {
      cost += byte === 0x08 || byte === 0x09 || byte === 0x0a || byte === 0x0c || byte === 0x0d ? 2 : 6
    } else if (byte === 0x22 || byte === 0x5c) {
      cost += 2
    } else if (byte < 0x80) {
      cost += 1
    } else if (byte >= 0xc2 && byte <= 0xdf) {
      state.expected = 1
      state.width = 2
      state.lowerFirst = 0x80
      state.upperFirst = 0xbf
    } else if (byte >= 0xe0 && byte <= 0xef) {
      state.expected = 2
      state.width = 3
      // Exclude the overlong (E0 80-9F) and CESU-8 surrogate (ED A0-BF) ranges.
      state.lowerFirst = byte === 0xe0 ? 0xa0 : 0x80
      state.upperFirst = byte === 0xed ? 0x9f : 0xbf
    } else if (byte >= 0xf0 && byte <= 0xf4) {
      state.expected = 3
      state.width = 4
      // Exclude the overlong (F0 80-8F) and out-of-range (F4 90-BF) leads.
      state.lowerFirst = byte === 0xf0 ? 0x90 : 0x80
      state.upperFirst = byte === 0xf4 ? 0x8f : 0xbf
    } else {
      // 0x80–0xc1 and 0xf5–0xff never begin a valid sequence: U+FFFD (3).
      cost += 3
    }
    index += 1
  }
  return cost
}

/**
 * Cap a done-frame `error.message` to `maxValueBytes` host-side: a forged done
 * frame can carry an arbitrarily long message, so truncate by RAW UTF-8 byte
 * length and append the shared marker on overflow. Completion VALUES are never
 * truncated — the seam forbids substitution, so an oversized value fails the run
 * as `output-limit` instead (see the done case in `execute`).
 *
 * This is the RECEIVE-side backstop, and it bills by raw bytes on purpose,
 * unlike the producing-side `_cap_message` in `py/bootstrap.py`, which bills by
 * SERIALIZED (JSON-escaped) cost. The split is deliberate: `_cap_message`'s
 * output has to cross fd 3 as a JSON string, so its escaped width is what the
 * frame ceiling bounds; this function's output goes straight into
 * `PtcRunResult.error.message` and never re-crosses a frame-bounded channel, so
 * the honest measure of what it retains is the raw length. An honest child has
 * already capped the diagnostic by serialized cost, and raw length ≤ serialized
 * cost, so a well-formed message passes through unchanged. A forged message with
 * control characters could serialize to roughly six times its raw length, but it
 * is not travelling any capped channel, so the raw-byte bound is the right one:
 * the value it protects is the model-visible size of `error.message`, not a wire
 * width.
 *
 * The marker's bytes are RESERVED from the budget, not added on top: the whole
 * returned string, marker included, is at most `maxValueBytes` bytes. Appending
 * the marker after retaining a full budget's worth of text would overrun the
 * very cap this function exists to enforce. The one exception is a configured
 * cap SMALLER than the marker itself, which leaves no room for message text at
 * all; the marker alone is returned there, so the bound is
 * `max(maxValueBytes, 15)`. Reporting the truncation is worth those 15 bytes,
 * and the default cap is 32 KiB.
 * @param message - the error message from an inbound (possibly forged) done frame.
 * @param maxValueBytes - the configured completion-value budget, reused here.
 * @returns the message unchanged, or its byte-capped form on overflow.
 */
function capMessage(message: string, maxValueBytes: number): string {
  // Code-unit bounds BEFORE any encode, so a forged done frame carrying a
  // message anywhere below the 64 MiB fd-3 frame parse cap cannot force a
  // full-length UTF-8 copy under a 32 KiB cap. One UTF-16 code unit encodes to
  // at least one UTF-8 byte and at most three: three for a non-ASCII BMP
  // character, two apiece for the pair halves sharing an astral code point's
  // four bytes, and three for a LONE surrogate, which `Buffer.from` renders as
  // U+FFFD. So at most maxValueBytes/3 code units cannot overflow the cap and
  // need no encode at all...
  if (message.length * 3 <= maxValueBytes) return message
  // ...and nothing past the first maxValueBytes code units can fit inside it,
  // so only that prefix is ever encoded — at most 3 * maxValueBytes bytes.
  const keep = Math.min(message.length, maxValueBytes)
  const whole = keep === message.length
  const bytes = Buffer.from(whole ? message : message.slice(0, keep), 'utf8')
  // A message that fits is measured against the WHOLE cap: it gets no marker,
  // so reserving marker bytes here would truncate text that was within budget.
  if (whole && bytes.length <= maxValueBytes) return message
  // Past this point the message IS being truncated, so the marker WILL be
  // appended and its bytes come out of the cap instead of sitting on top of it.
  const budget = Math.max(0, maxValueBytes - TRUNCATION_MARKER_BYTES)
  // Trim back to the last complete UTF-8 sequence: a cut through a multibyte
  // character would decode as U+FFFD — corrupting the diagnostic AND
  // exceeding the byte cap, since the replacement character itself encodes
  // to three bytes. Continuation bytes are 0b10xxxxxx; at most three of them
  // precede a lead byte.
  //
  // This also covers a code-unit prefix ending on a HIGH SURROGATE whose low
  // half sits outside it, which `Buffer.from` encodes as U+FFFD: that orphan
  // occupies the last three bytes of `bytes`, and `bytes` is at least
  // `maxValueBytes + 2` long here (one byte per retained unit, three for the
  // orphan), so it starts past `budget` and is always cut. Reserving the
  // marker is what makes that hold; cutting at `maxValueBytes` itself did not,
  // and needed an explicit surrogate check.
  let end = Math.min(budget, bytes.length)
  while (end > 0 && ((bytes[end] as number) & 0b1100_0000) === 0b1000_0000) end--
  return `${bytes.subarray(0, end).toString('utf8')}${TRUNCATION_MARKER}`
}

/**
 * Copy an fd-3 line residual into a fresh, right-sized Buffer so it no longer
 * shares the joined-frame allocation it was sliced from.
 *
 * After the newline loop over a `Buffer.concat` of the pending chunks, the
 * leftover partial line is a `subarray` VIEW onto that concat's backing store.
 * A view keeps the ENTIRE backing allocation alive for as long as it is
 * retained, so carrying the view forward as the next pending chunk would pin a
 * whole large frame's worth of memory behind a tiny trailing fragment — and the
 * `pendingBytes` counter, set to the fragment's own length, would no longer
 * measure the memory actually held. `Buffer.from` allocates exactly
 * `residual.length` bytes and copies, letting the concat allocation be
 * collected; an empty residual carries nothing forward.
 * @param residual - the leftover slice after the last newline (a view).
 * @returns the pending-chunk list to carry forward: `[copy]`, or `[]` when empty.
 */
export function detachResidual(residual: Buffer): Buffer[] {
  return residual.length > 0 ? [Buffer.from(residual)] : []
}

/** One namespace after seam validation: its callables plus the optional typed-rejection contract. */
interface ValidatedNamespace {
  functions: Record<string, PtcBindingFunction>
  errorClass?: PtcBindingErrorClass
}

/**
 * One in-flight run's host-side state, tracked for disposal so teardown can
 * fail every live run as `abort` and AWAIT each child's exit.
 */
interface LiveRun {
  kill(sig: NodeJS.Signals): void
  settle(failure: PtcRunFailure): void
  finished: Promise<void>
}

/**
 * The experimental {@link PtcRuntime} backend (private, not released) registering as `ptcRuntime`. Every
 * cap is validated config; every long-running operation honors the request's
 * `AbortSignal`; every disposer awaits child-process exit.
 */
export class PythonPtcRuntime extends PtcRuntime {
  static Config: z<Config> = z.object({
    cpuSeconds: z.number().default(60),
    maxWallMs: z.number().default(600_000),
    addressSpaceMb: z.number().default(512),
    maxLogBytes: z.number().default(65_536),
    maxValueBytes: z.number().default(32_768),
    graceMs: z.number().default(3_000),
    pythonBin: z.string().default('python3'),
  })

  readonly language = 'python'
  readonly isolation = 'process'

  private readonly config: ResolvedConfig
  private readonly pythonBin: string
  // The frame cap this instance enforces: the protocol cap, or the host's
  // heap-derived parse ceiling when a constrained heap makes the protocol cap
  // unsafe to parse (see {@link hostFrameParseCeiling}). Computed per
  // instance so the config gate and the inbound checks agree.
  private readonly frameParseCapBytes = hostFrameParseCeiling()
  private readonly live = new Set<LiveRun>()
  private disposed = false

  /* jscpd:ignore-start -- parallel to ptc-runtime-node: sibling backends keep symmetric constructor/teardown/run shapes. */
  constructor(ctx: Context, config: Config) {
    super(ctx)
    // Reject at load on Windows: the bootstrap imports the POSIX-only `resource`
    // module for RLIMIT_CPU/RLIMIT_AS, spawns with a positional fd 3, and
    // terminates via negative-PID process-group signals — none of which exist
    // on Windows. Registering ctx.ptcRuntime there would let assembly succeed
    // and defer the failure to the first run. The asymmetry with the worker
    // backend is intentional: that backend is cross-platform; this one is not.
    if (process.platform === 'win32') {
      throw new Error('dsh-ptc-runtime-python: this backend requires a Unix platform (POSIX rlimits, fd-3 stdio, process-group signals); it cannot run on Windows')
    }
    this.config = config as ResolvedConfig
    for (const [key, value] of Object.entries(this.config)) {
      if (typeof value === 'number' && !(Number.isFinite(value) && value > 0)) {
        throw new Error(`dsh-ptc-runtime-python: config.${key} must be a positive number, got ${String(value)}`)
      }
    }
    // cpuSeconds crosses to the child's setrlimit(RLIMIT_CPU) raw; a float
    // raises TypeError inside every child (a late per-run failure). Reject it
    // at load. maxLogBytes/maxValueBytes get their own integer gate below (the
    // child int()-truncates them, so a float would diverge from the host);
    // maxWallMs/graceMs/addressSpaceMb are consumed as numbers where a fraction
    // is harmless.
    if (!Number.isInteger(this.config.cpuSeconds)) {
      throw new Error(`dsh-ptc-runtime-python: config.cpuSeconds must be a positive integer, got ${String(this.config.cpuSeconds)}`)
    }
    // Finite is not the same as representable as an rlimit. `cpuSeconds` and its
    // `+ 1` hard limit both cross to `setrlimit` as integers, and `1e100` clears
    // `Number.isInteger` while being far past the safe range, so it cannot round
    // -trip: the child sees a different number than was configured. The `+ 1` is
    // what gets checked because that is the larger of the two values sent.
    if (!Number.isSafeInteger(this.config.cpuSeconds + 1)) {
      throw new Error(`dsh-ptc-runtime-python: config.cpuSeconds must be at most ${Number.MAX_SAFE_INTEGER - 1} (it and its +1 hard limit cross to setrlimit as exact integers), got ${String(this.config.cpuSeconds)}`)
    }
    // `addressSpaceMb` is multiplied by 1 MiB before it is framed, and a large
    // finite value overflows to `Infinity` there — which `encodeJsonPlain`
    // renders as `null`, so the child receives no limit at all and every run
    // ends in a bootstrap exception rather than a load-time configuration error.
    // Checking the DERIVED byte count is what catches it; the input itself looks
    // ordinary. Safe-integer, not merely finite, since the value must survive
    // the JSON round trip exactly.
    if (!Number.isSafeInteger(this.config.addressSpaceMb * 1024 * 1024)) {
      throw new Error(`dsh-ptc-runtime-python: config.addressSpaceMb must be at most ${Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024))} (its byte count crosses the wire as an exact integer), got ${String(this.config.addressSpaceMb)}`)
    }
    // `pythonBin` reaches `spawn` as the executable path, where values the
    // string schema admits fail late and unhelpfully. An empty string makes
    // `spawn` throw `ERR_INVALID_ARG_VALUE` synchronously, and an embedded NUL
    // throws `ERR_INVALID_ARG_TYPE` — both from inside `run()`, so the method
    // REJECTS instead of resolving the `worker-exit` the seam promises for a
    // child that cannot start. A basename with no `PATH` match would silently
    // fall to execvp's platform default `PATH` under the minimal spawn
    // environment (see the resolvePythonBin JSDoc), so it is rejected here
    // too. All three are self-contained configuration errors that fail at
    // load.
    if (this.config.pythonBin === '' || this.config.pythonBin.includes('\0')) {
      throw new Error(`dsh-ptc-runtime-python: config.pythonBin must be a non-empty path without NUL bytes, got ${JSON.stringify(this.config.pythonBin)}`)
    }
    // `maxWallMs` and `graceMs` are armed with setTimeout, which clamps any
    // delay past MAX_TIMER_DELAY_MS to 1 ms without a word — turning a
    // generous ceiling into an instant timeout and a generous grace period into
    // an instant SIGKILL. `graceMs` is checked against the margin the
    // close-deadline adds on top, since that sum is what gets armed.
    if (this.config.maxWallMs > MAX_TIMER_DELAY_MS) {
      throw new Error(`dsh-ptc-runtime-python: config.maxWallMs must not exceed ${MAX_TIMER_DELAY_MS} (setTimeout clamps a larger delay to 1ms), got ${String(this.config.maxWallMs)}`)
    }
    if (this.config.graceMs + CLOSE_REAP_MARGIN_MS > MAX_TIMER_DELAY_MS) {
      throw new Error(`dsh-ptc-runtime-python: config.graceMs must not exceed ${MAX_TIMER_DELAY_MS - CLOSE_REAP_MARGIN_MS} (its close deadline adds ${CLOSE_REAP_MARGIN_MS}ms, and setTimeout clamps a larger delay to 1ms), got ${String(this.config.graceMs)}`)
    }
    // The output caps are budgets for a payload that has to cross fd 3 inside
    // one frame, and the framing ceiling is fixed. A cap above what a frame can
    // carry is unsatisfiable: a completion or log entry that the cap admits
    // arrives as an over-ceiling frame and fails the run as `worker-exit`
    // instead of the `output-limit` the cap describes — a silent inversion, so
    // it fails at load. Both budgets are metered in SERIALIZED (JSON-escaped)
    // bytes — the host log ledger charges the serialized cost via
    // `jsonStringCostUpTo`, which walks to the cap without allocating the escaped
    // copy, `checkDoneValue` measures the escaped form, and the producing-side
    // `_cap_message` in the child also caps by serialized cost (which is why a
    // capped diagnostic still fits its frame) — so a payload admitted under the
    // cap occupies at most `cap + envelope` bytes on the wire; escaping is
    // already inside the charge and must not be multiplied in again. The
    // receive-side `capMessage` backstop is the one exception to this argument:
    // it bills a forged `done.error.message` by RAW bytes, but that output goes
    // into `PtcRunResult.error.message` and never re-crosses a frame-bounded
    // channel, so it is not part of the wire-width bound (see its JSDoc). The
    // admissible cap is therefore `parse-cap - envelope`: the receive path
    // rejects raw frames past the effective parse cap (`frameParseCapBytes` —
    // the protocol cap, or the host's heap-derived ceiling when a constrained
    // heap makes the protocol cap unsafe to parse; see hostFrameParseCeiling)
    // before decoding (the run settles as a worker-exit; a hostile
    // compact-wide-frame OOM guard), so a budget must not exceed what an
    // honest child's frame can actually carry through that parser.
    for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
      // Require an integer: the child reads these budgets through `int(...)`,
      // which silently floors a float, so `maxLogBytes: 3.5` would truncate at 3
      // bytes child-side while the host meters and marks at 3.5 — the two sides
      // enforcing different public config. Reject the float at load, as the
      // Node backend does for its byte budgets.
      if (!Number.isInteger(this.config[key])) {
        throw new Error(`dsh-ptc-runtime-python: config.${key} must be a positive integer (the child reads it as an int, so a float diverges from the host), got ${String(this.config[key])}`)
      }
      const limit = this.frameParseCapBytes - FRAME_ENVELOPE_BYTES
      if (this.config[key] > limit) {
        // Only a host whose heap is below the protocol cap reaches the
        // heap-constrained note; the constrained-heap rejection is exercised
        // by the subprocess load test, but subprocess runs are not
        // coverage-instrumented, so the note's arm is not schedulable from the
        // instrumented suite (whose heap never binds).
        /* v8 ignore next -- the heap-constrained message arm needs a host heap below the protocol cap. */
        const heapNote = this.frameParseCapBytes < FRAME_PARSE_CAP_BYTES ? ` — this host's heap limits the parse to ${this.frameParseCapBytes} bytes, so the protocol cap of ${FRAME_PARSE_CAP_BYTES} would be unsafe` : ''
        throw new Error(`dsh-ptc-runtime-python: config.${key} must not exceed ${limit} (a payload that large cannot cross the fd-3 frame PARSER, which rejects raw frames past ${this.frameParseCapBytes} bytes before decoding to bound host memory${heapNote} — a larger budget would admit a config whose honest child frames the host then rejects as a worker-exit), got ${String(this.config[key])}`)
      }
      // Reject a log budget too small to honor: the truncation marker alone
      // must serialize within the budget, or a marker-only truncated run
      // returns more than the configured cap. (With admitted entries the
      // marker is envelope, so the serialized logs run to
      // `maxLogBytes + marker + envelope`.)
      if (key === 'maxLogBytes' && this.config[key] < MIN_LOG_BYTES) {
        throw new Error(`dsh-ptc-runtime-python: config.maxLogBytes must be at least ${MIN_LOG_BYTES} (a smaller budget cannot serialize the truncation marker itself, so a marker-only truncated run would return more than the configured cap), got ${String(this.config[key])}`)
      }
    }
    // The child builds, charges, and frames a `maxLogBytes` log entry or a
    // `maxValueBytes` completion value under `RLIMIT_AS`, and both paths trigger
    // on CHARACTER count against a serialized-BYTE budget. An astral character is
    // one character but four bytes of `str` storage and four UTF-8 bytes, so a
    // budget's worth of them peaks at three simultaneous ~4x copies (the caller's
    // write argument, the line slice or joined pending handed to push, and the
    // encode push takes to charge and ship it). A budget approaching
    // `addressSpaceMb` therefore makes a LEGITIMATE near-budget output breach the
    // address space and die as `worker-exit` instead of truncating (log) or
    // failing as `output-limit` (value). Metering every child write against the
    // address space at runtime is the wrong fix — an exact serialized-cost check
    // is either a full encode (the allocation being avoided) or a per-character
    // Python loop that burns the CPU budget — so the incompatible pair is rejected
    // at load: each budget times the worst-case multiple must fit the address
    // space. Checked on every platform, not just where `RLIMIT_AS` is enforced:
    // the incompatibility is a property of the config values, and the child OOMs
    // on a Linux deployment regardless of the host that assembled the config, so a
    // uniform load-time rejection is the fail-loud contract (Darwin skips only the
    // runtime `setrlimit`).
    const addressSpaceBytes = this.config.addressSpaceMb * 1024 * 1024
    // Room left for the peak output allocation after the interpreter's own fixed
    // footprint. A budget must fit MULTIPLE times over into THIS, not the whole
    // address space, so a budget sized right at `addressSpaceMb / MULTIPLE` — which
    // the multiple alone would admit — cannot leave the peak plus the interpreter
    // over the limit.
    const budgetableBytes = addressSpaceBytes - INTERPRETER_BASELINE_BYTES
    // The largest budget that fits: the peak (budget * MULTIPLE) must leave room,
    // so a budget whose peak exactly equals `budgetableBytes` is rejected — that
    // peak plus the reserved baseline is the whole address space, the RLIMIT_AS
    // edge. `ceil(budgetableBytes / MULTIPLE) - 1` is the last integer strictly
    // under `budgetableBytes / MULTIPLE`.
    // Reject a too-small address space on its own terms FIRST. Once
    // `budgetableBytes` is zero or negative no budget can pass, and the loop
    // below would report "a limit of -1" (or -2796203 at addressSpaceMb 32) while
    // naming `maxLogBytes` -- pointing the operator at the knob that is not the
    // problem. The baseline is what `addressSpaceMb` must clear here.
    if (budgetableBytes <= 0) {
      throw new Error(`dsh-ptc-runtime-python: config.addressSpaceMb must exceed the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline with room for the output budgets, so the child has address space left to build and encode them; got ${String(this.config.addressSpaceMb)} MiB (${addressSpaceBytes} bytes)`)
    }
    const admissibleBudget = Math.ceil(budgetableBytes / OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE) - 1
    for (const key of ['maxLogBytes', 'maxValueBytes'] as const) {
      if (this.config[key] * OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE >= budgetableBytes) {
        throw new Error(`dsh-ptc-runtime-python: config.${key} times the ${OUTPUT_BUDGET_WORST_CASE_ADDRESS_SPACE_MULTIPLE}x worst-case Unicode expansion must fit within the ${budgetableBytes} bytes left after the ${INTERPRETER_BASELINE_BYTES}-byte interpreter baseline within the ${addressSpaceBytes}-byte addressSpaceMb, so a near-budget output truncates rather than breaching RLIMIT_AS as worker-exit; got ${String(this.config[key])} against a limit of ${admissibleBudget}`)
      }
    }
    // Resolve and validate the executable ONCE, after the pure config checks.
    // Re-resolving a basename in each run would let a later PATH change silently
    // switch interpreters, while an unchecked explicit path would turn
    // self-contained misconfiguration into a late worker-exit. A missing or
    // unsupported interpreter is a load failure. Later filesystem mutation is
    // outside config validation; a missing executable settles as worker-exit.
    const pythonBin = resolvePythonBin(this.config.pythonBin)
    if (pythonBin === undefined) {
      const explicit = isAbsolute(this.config.pythonBin) || this.config.pythonBin.includes('/')
      throw new Error(`dsh-ptc-runtime-python: config.pythonBin ${JSON.stringify(this.config.pythonBin)} ${explicit ? 'is not an executable regular file' : 'does not resolve on PATH'}`)
    }
    validatePythonBin(pythonBin)
    this.pythonBin = pythonBin
    ctx.effect(() => () => this.teardown(), 'python ptc-runtime teardown')
  }

  /**
   * Dispose to quiescence: fail every in-flight run as aborted and AWAIT each
   * child's exit so no subprocess that stays in the child's process group
   * outlives the fiber. A descendant that escaped the group with `setsid()` /
   * `start_new_session=True` is unreachable by `kill(-pid)` and is the documented
   * exception (see the package README's Known Limitations); the process-group
   * teardown reaps everything that stays in the group.
   */
  private async teardown(): Promise<void> {
    this.disposed = true
    const runs = [...this.live]
    for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
    // Awaiting `finished` is also what clears staging: that promise resolves
    // inside the run's own `settle`, which removes its directory first. So there
    // is deliberately no sweep here — a second pass could only ever find an
    // empty set, and an unreachable cleanup path is worse than none, since it
    // reads as the real guarantee while never running.
    await Promise.all(runs.map(run => run.finished))
  }

  /**
   * Resolve directory and the experimental provider's configured wall deadline.
   * @param request - Program inputs; explicit sandbox or timeout overrides are unsupported.
   * @returns Complete inputs for the provider's run method.
   * @throws When a requested override is unsupported or cwd is relative.
   */
  resolve(request: PtcRunRequest): PtcRunSpec {
    if (request.sandboxPolicy !== undefined) throw new Error('dsh-ptc-runtime-python: sandbox policy is unsupported')
    if (request.timeoutMs !== undefined) throw new Error('dsh-ptc-runtime-python: per-call timeout is unsupported')
    const cwd = request.cwd ?? process.cwd()
    if (!isAbsolute(cwd)) throw new Error('dsh-ptc-runtime-python: cwd must be absolute')
    return { ...request, cwd, timeoutMs: this.config.maxWallMs }
  }

  /**
   * Execute a resolved Python program; this experimental provider has no file confinement.
   * @param request - Resolved cwd, provider deadline, program and bindings.
   * @returns Captured output and the program outcome.
   */
  async run(request: PtcRunSpec): Promise<PtcRunResult> {
    if (request.sandboxPolicy !== undefined || request.timeoutMs !== this.config.maxWallMs) throw new Error('dsh-ptc-runtime-python: unsupported execution policy or timeout')
    if (this.disposed) throw new Error('dsh-ptc-runtime-python: run() after disposal')
    const bindings = this.validateBindings(request)
    if (request.signal?.aborted) {
      return { logs: [], error: { kind: 'abort', message: messageOf(request.signal.reason) } }
    }
    let bootstrapPath: string
    try {
      // The interpreter is an external process, so the entry script has to sit
      // on the real filesystem; see materializePyScripts. One copy PER RUN,
      // synchronously, so no async boundary opens before `execute` registers the
      // run and installs the abort listener.
      bootstrapPath = materializePyScripts()
    } catch (error: unknown) {
      // A full or read-only temp filesystem, or a packaged asset the deployment
      // failed to ship, is a SUBSTRATE failure — the same class as a child that
      // cannot start. The seam permits rejection only for misuse, so this
      // resolves as `worker-exit` rather than throwing out of `run()`.
      return { logs: [], error: { kind: 'worker-exit', message: `failed to stage the python bootstrap: ${messageOf(error)}` } }
    }
    return await this.execute(request, bindings, bootstrapPath)
  }
  /* jscpd:ignore-end */

  /**
   * Reject (seam misuse) malformed binding namespaces: non-identifier or
   * reserved globals/error classes, duplicates, and colliding or
   * runtime-owned injected globals.
   */
  private validateBindings(request: PtcRunRequest): Map<string, ValidatedNamespace> {
    const bindings = new Map<string, ValidatedNamespace>()
    // Every name the bootstrap injects into the program's one global namespace:
    // namespace globals plus error-class names. They must be a collision-free
    // set that avoids the runtime's own slots, or a later injection silently
    // overwrites an earlier one (or the completion/builtins slot) and the run
    // fails obscurely at execution time.
    const injectedGlobals = new Set<string>()
    const claimGlobal = (name: string, role: string): void => {
      if (RUNTIME_OWNED_GLOBALS.has(name)) {
        throw new Error(`dsh-ptc-runtime-python: ${role} ${JSON.stringify(name)} collides with a runtime-owned global`)
      }
      if (injectedGlobals.has(name)) {
        throw new Error(`dsh-ptc-runtime-python: ${role} ${JSON.stringify(name)} collides with another injected global`)
      }
      injectedGlobals.add(name)
    }
    for (const namespace of request.bindings) {
      // Snapshot the caller-supplied fields into plain values ONCE. The
      // namespace and errorClass objects may expose `global`/`name`/
      // `memberNameProperty` through getters: validation reads each several
      // times, and the ORIGINAL errorClass object would otherwise be retained
      // for the boot frame, whose JSON.stringify re-reads it after validation.
      // A getter that changes or throws on a later read would turn the
      // seam-misuse rejection into a worker-exit (or inject a different name
      // than validation approved); reading each field once here and keeping
      // the plain copy makes validation and the boot frame agree.
      const global = namespace.global
      if (!IDENTIFIER.test(global) || RESERVED_NAMES.has(global)) {
        throw new Error(`dsh-ptc-runtime-python: binding global ${JSON.stringify(global)} is not a usable Python identifier`)
      }
      if (bindings.has(global)) {
        throw new Error(`dsh-ptc-runtime-python: duplicate binding global ${JSON.stringify(global)}`)
      }
      claimGlobal(global, 'binding global')
      // The error class becomes a program global and its member property an
      // attribute name, so both face the Python identifier rules; the member
      // additionally must be assignable on a BaseException instance.
      const errorClass = namespace.errorClass
      let validatedErrorClass: PtcBindingErrorClass | undefined
      if (errorClass) {
        const name = errorClass.name
        const memberNameProperty = errorClass.memberNameProperty
        if (!IDENTIFIER.test(name) || RESERVED_NAMES.has(name)) {
          throw new Error(`dsh-ptc-runtime-python: errorClass.name ${JSON.stringify(name)} is not a usable Python identifier`)
        }
        // Any non-empty own attribute name is settable via setattr (the
        // program reads exotic names like `tool-name` with getattr), matching
        // the seam contract and the Node backend — only the seam-excluded
        // and protocol-reserved members below are refused.
        if (memberNameProperty.length === 0) {
          throw new Error('dsh-ptc-runtime-python: errorClass.memberNameProperty must be a non-empty attribute name')
        }
        if (EXCEPTION_RESERVED_MEMBERS.has(memberNameProperty) || DUNDER.test(memberNameProperty)) {
          throw new Error(`dsh-ptc-runtime-python: errorClass.memberNameProperty ${JSON.stringify(memberNameProperty)} is a reserved error member and cannot be assigned`)
        }
        claimGlobal(name, 'errorClass.name')
        validatedErrorClass = { name, memberNameProperty }
      }
      // Snapshot the callables into a plain own-property record before the
      // child can dispatch. `namespace.functions` is caller-supplied, so it may
      // expose members through getters or a Proxy; reading one of them inside
      // the fd-3 `data` callback would throw OUTSIDE the dispatcher's try and
      // terminate the host (defensive-patterns contain-callback-exceptions).
      // Reading every member here, in run()'s synchronous validation segment,
      // turns that throw into the seam-misuse rejection run() reserves for
      // malformed bindings. The snapshot is also the single key set the boot
      // frame advertises AND dispatch reads, so a getter whose keys differ
      // between reads cannot desynchronize the child's allowed names from what
      // the host will actually call. The record is null-prototype: the seam
      // contract treats member names like `__proto__` or `constructor` as
      // ordinary own properties, and a plain `{}` assignment of `__proto__`
      // would hit the prototype setter instead of creating the own property.
      const functions = Object.create(null) as Record<string, PtcBindingFunction>
      for (const name of Object.keys(namespace.functions)) {
        // Only callables enter the snapshot: a getter exposing a non-function
        // member would otherwise assign a value the dispatcher's `typeof fn
        // !== 'function'` check rejects anyway, and keeping it out of the
        // snapshot keeps the boot frame's name list and the dispatch key set
        // one and the same.
        const fn = namespace.functions[name]
        if (typeof fn === 'function') functions[name] = fn
      }
      bindings.set(global, { functions, ...validatedErrorClass ? { errorClass: validatedErrorClass } : {} })
    }
    return bindings
  }

  /** Spawn the child for one validated run and drive it to settlement. */
  private execute(
    request: PtcRunSpec,
    bindings: Map<string, ValidatedNamespace>,
    bootstrapPath: string,
  ): Promise<PtcRunResult> {
    // This run's own staging directory, removed at settlement.
    const bootstrapDir = dirname(bootstrapPath)
    // Explicit pipe count of 4 puts the framed-JSON channel at fd 3 in the child.
    // The constructor resolved and validated the interpreter once; runs keep that
    // exact path even if the host later changes PATH.
    // `spawn` can throw SYNCHRONOUSLY — a descriptor-exhausted host (EMFILE) or a
    // libuv-level failure surfaces here, before the Promise executor and its
    // settlement path exist. Left uncaught it would REJECT run() (the seam
    // permits rejection only for misuse) and strand this run's staging directory,
    // which only settle() removes. Catch it, unlink the directory, and resolve a
    // `worker-exit` — the same class as the async ENOENT `error` event below.
    let child: ChildProcessWithoutNullStreams
    let proto: Duplex | null
    try {
      // `-u` keeps the interpreter's own stdout/stderr UNBUFFERED: a program
      // that writes through `sys.__stdout__`/`sys.__stderr__` (or C-stdio
      // layered on the same fds) must have those bytes visible to the host's
      // stray capture immediately — a block-buffered wrapper would otherwise
      // hold them until an explicit flush, and the host SIGTERMs the child
      // right after the done frame, before any finalization-time flush could
      // run. The `_LogStream` replacement of `sys.stdout`/`sys.stderr` is
      // unaffected (it is a Python object, not the C-level stdio buffer).
      child = spawn(this.pythonBin, ['-u', '-I', bootstrapPath], {
        cwd: request.cwd,
        // Preserve only the platform temp directory. macOS system Python emits a
        // startup warning when TMPDIR is absent; ambient credentials, PATH, HOME,
        // and other host state remain unavailable to model code.
        env: pythonEnvironment(),
        detached: true, // Own process group — kill(-pid, sig) reaches subprocesses the model program spawns.
        stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
      })
      // Fd 3 is a duplex pipe carrying protocol frames. Node types extra stdio
      // entries as `Stream | null`; the runtime shape with `'pipe'` is a duplex,
      // so we narrow at the boundary rather than smearing casts below. Stdout
      // and stderr are guaranteed non-null under `'pipe'` and typed as such.
      proto = child.stdio[3] as Duplex | null
      /* v8 ignore next 3 -- `'pipe'` stdio always populates fd 3; guarding Node's `Stream | null` typing widening. */
      if (proto === null) {
        throw new Error('dsh-ptc-runtime-python: python subprocess spawned without a fd-3 pipe')
      }
      // Close the host's stdin write handle immediately: the program is an
      // async body that reads nothing from fd 0, and a live pipe here would
      // hold a host-side handle open past the run — a setsid-escaped descendant
      // inheriting fd 0 would keep the host process from exiting even after the
      // closeDeadline forced settlement. The child (and any descendant) reads
      // EOF on fd 0 instead, and no host handle survives.
      // oxlint-disable-next-line typescript/no-unnecessary-condition -- the boot-write-failure fake child has no stdin.
      child.stdin?.destroy()
    } catch (error: unknown) {
      try {
        rmSync(bootstrapDir, { recursive: true, force: true })
      } catch {
        // Same swallow as settle()'s removal: `force` already absorbs a missing
        // directory, so only a filesystem-level refusal reaches here, and the
        // staging copy holds nothing but two checked-in scripts.
      }
      return Promise.resolve({ logs: [], error: { kind: 'worker-exit' as const, message: `python spawn error: ${messageOf(error)}` } })
    }

    return new Promise<PtcRunResult>((resolve) => {
      let settled = false
      const logs: string[] = []
      // An unterminated line flushed with the `open` flag: the next log frame
      // appends to it (no fake newline between entries), and finish() pushes
      // the residual if the run ends with it still open. Held as a fragment
      // ARRAY, so k tiny open frames cost O(k) — re-joining and re-walking the
      // whole held text per frame would be O(k * budget).
      let openParts: string[] = []
      // Past MAX_PENDING_CHUNKS, the held fragments are coalesced into sealed
      // blocks (mirroring the fd-3 reader's `blocks` and the stray capture's
      // seal): each fragment is a distinct array slot plus string object
      // header — ~30x overhead the byte cap cannot see — so a budget-sized
      // single-character open flood would otherwise accumulate thousands of
      // slots. Sealing bounds the live fragment count exactly like the
      // sibling paths; the merge reads sealed + current fragments. A block
      // ARRAY (not one repeated string concat) matches the sibling shape and
      // avoids depending on V8 ConsString amortization.
      let openSealed: string[] = []
      // Every truncation arm funnels here: the committed open prefix was
      // ALREADY billed, so it is pushed BEFORE the marker — a flushed line is
      // never lost (only the marker stays last), and no ledger re-charge
      // happens. openParts is emptied here, so no later arm or finish() sees
      // it.
      const truncateLogs = (): void => {
        logsTruncated = true
        if (openSealed.length > 0 || openParts.length > 0) {
          logs.push(openSealed.join('') + openParts.join(''))
          openSealed = []
          openParts = []
        }
        logs.push(logTruncationMarker(this.config.maxLogBytes))
        clearStray(strayOut)
        clearStray(strayErr)
      }

      // One host-side ledger covers normal frames, forged frames, and stray stdout bytes.
      // The ledger starts one byte below maxLogBytes: each entry is charged its
      // JSON-string cost plus one separator byte, and the serialized outer logs
      // array adds one more byte of envelope (two brackets and n-1 commas over n
      // entries' separators), so a result that exactly exhausts the ledger
      // serializes to exactly maxLogBytes; WITHOUT the reserved byte it would
      // serialize to maxLogBytes + 1. Reserving that byte keeps an admitted
      // result within the configured cap; the truncation-marker entry is
      // envelope, not payload, and rides uncharged.
      let logBudget = this.config.maxLogBytes - 1
      let logsTruncated = false
      // Drop a pipe's buffered stray output wholesale: once the ledger has
      // truncated, every byte of it would be no-op'd by admit(), so retaining
      // it (and later Buffer.concat+decoding it in flushStray) would spend host
      // memory on output that can never be admitted. Called from every arm that
      // marks the ledger truncated — admit()'s two ceilings and the child-marker
      // frame arm — so the end-path flushStray sees empty buffers and exits.
      const clearStray = (stray: StrayBuffer): void => {
        stray.chunks = []
        stray.blocks = []
        stray.cost = 0
        stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
      }
      const admit = (text: string): void => {
        // Post-truncation admits are no-ops: once the ledger has truncated, the
        // marker is the last entry. Reachable within one `data` callback — a
        // chunk carrying two newline-terminated lines where the first exhausts
        // the budget hits this on the second — so it is a measured branch.
        if (logsTruncated) return
        // Each entry is charged its SERIALIZED cost — JSON.stringify's quotes
        // and escapes plus one separator byte — because the seam bounds the
        // serialized outer logs payload, and control characters expand
        // several-fold under JSON escaping (a "\x00" flood would otherwise
        // admit 6x its charge). The charge also puts a floor under an empty
        // entry (its two quotes plus separator), so a `while True: print()`
        // flood of zero-byte lines exhausts the ledger instead of growing the
        // retained array without ever touching the budget. The one fixed
        // truncation-marker entry is envelope, not payload, and rides
        // uncharged.
        //
        // Cheap lower bound FIRST, before the escaped copy exists: every
        // UTF-16 code unit costs at least one serialized byte (an ASCII
        // character is one byte; a control character is six as `\uXXXX`; a
        // non-ASCII BMP character is two or three; each half of a surrogate
        // pair contributes two of the four bytes its code point encodes to),
        // and the JSON form adds two quotes on top of the separator byte. So
        // `text.length + 3` never exceeds the true cost, and a forged `log`
        // frame carrying a control-heavy string anywhere below the 64 MiB
        // frame parse cap truncates here instead of allocating a
        // hundreds-of-megabytes escaped copy under a small maxLogBytes.
        if (text.length + 3 > logBudget) {
          // Release the buffered stray pipes: their bytes can never be
          // admitted now (see clearStray).
          truncateLogs()
          return
        }
        // Past the lower bound, measure the exact serialized cost without
        // allocating the escaped copy: `jsonStringCostUpTo` walks to the cap and
        // stops, so even a near-budget control-char-dense line never materializes
        // a sixfold-inflated `JSON.stringify` result. `+ 1` for the separator.
        const measured = jsonStringCostUpTo(text, logBudget - 1)
        if (measured === undefined) {
          truncateLogs()
          return
        }
        logBudget -= measured + 1
        logs.push(text)
      }

      // Stray-byte capture: anything the child writes to its stdout/stderr
      // (native prints, C-extension writes) still counts against the ledger.
      //
      // Output is admitted per LINE, not per transport chunk. `logs` entries
      // are joined with `\n` downstream (PTC mode), so each entry must be one
      // line: pushing a raw `data` chunk would turn every arbitrary pipe-read
      // boundary into a model-visible newline, so a single 200 KiB native write
      // split across pipe reads would read back with spurious line breaks. The
      // child's own `log` frames are already line-granular; stray capture
      // matches them by splitting on `\n`.
      //
      // Buffered as raw `Buffer` chunks with a running SERIALIZED-cost counter,
      // exactly like the fd-3 reader below and for the same reasons: a string
      // `+=` accumulator re-copies the whole residual on every pipe chunk
      // (quadratic on a large newline-free write), and scanning it from index 0
      // each chunk is a second quadratic. Appending a chunk is O(1); the split
      // happens only when a `\n` actually arrived. A newline never appears inside
      // a UTF-8 multibyte sequence (continuation bytes are 0x80–0xBF), so
      // splitting on the raw 0x0a byte and decoding each complete line is safe
      // without a streaming decoder — a line's bytes are whole by construction.
      //
      // `chunks` also seals into `blocks` past MAX_PENDING_CHUNKS, mirroring the
      // fd-3 reader: without it a program pacing one-byte newline-free
      // `os.write`s accumulates one Buffer object per write, and the object plus
      // backing-store overhead — which no byte or cost count sees — exhausts the
      // host heap far below the budget. Sealing bounds the live object count.
      interface StrayBuffer { chunks: Buffer[]; blocks: Buffer[]; cost: number; utf8: Utf8CostState }
      const strayOut: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 } }
      const strayErr: StrayBuffer = { chunks: [], blocks: [], cost: 0, utf8: { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 } }
      const captureStray = (stray: StrayBuffer, chunk: Buffer): void => {
        // Once the ledger has truncated, stop buffering: admit() is a no-op past
        // that point, so continuing to accumulate would retain host memory for
        // output that can never be admitted.
        if (logsTruncated) return
        stray.chunks.push(chunk)
        // Track SERIALIZED cost, not raw bytes: a control-char-dense residual
        // (a NUL or illegal-UTF-8 flood) serializes several-fold, so a raw-byte
        // threshold would let it grow to the full budget's worth of RAW bytes
        // before flushing. `accrueStrayCost` decodes UTF-8 structurally across
        // chunks (via `stray.utf8`) so a byte that renders as U+FFFD is charged
        // its three serialized bytes, not one.
        stray.cost += accrueStrayCost(chunk, stray.utf8)
        // Bound the live fragment count (see the seal rationale above), before
        // any concat so an over-count payload is never copied whole first.
        if (stray.chunks.length >= MAX_PENDING_CHUNKS) {
          stray.blocks.push(Buffer.concat(stray.chunks))
          stray.chunks = []
        }
        if (chunk.includes(0x0a)) {
          let buffered = Buffer.concat(stray.blocks.length > 0 ? [...stray.blocks, ...stray.chunks] : stray.chunks)
          stray.blocks = []
          let newline: number
          while ((newline = buffered.indexOf(0x0a)) >= 0) {
            admit(buffered.subarray(0, newline).toString('utf8'))
            buffered = buffered.subarray(newline + 1)
          }
          // Carry the residual as a fresh right-sized copy, not the subarray view
          // (which would pin the whole concat allocation). See detachResidual.
          // The residual begins at a character boundary (a newline is never
          // inside a multibyte sequence), so its cost and UTF-8 state recompute
          // cleanly from a fresh walk.
          // A line admitted inside the loop may have exhausted the ledger and
          // cleared this pipe (see clearStray); the re-retain below must not
          // resurrect the doomed residual.
          // oxlint-disable-next-line typescript/no-unnecessary-condition -- admit() (a closure) sets it.
          if (logsTruncated) return
          stray.chunks = detachResidual(buffered)
          stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
          stray.cost = accrueStrayCost(buffered, stray.utf8)
        }
        // Newline-free residual is bounded by the ledger, not left to grow with
        // the stream: an `os.write(1, b"A"*N)` flood carrying no newline would
        // otherwise accumulate N bytes in host memory before `end`. The bound is
        // on the COMBINED pending cost of both pipes, not each alone: stdout and
        // stderr share one `logBudget`, so checking each against the full budget
        // independently would let both retain nearly a budget's worth at once —
        // ~2x peak, up to ~512 MiB near the ceiling — before either flushed.
        // When the sum would cross the budget, flush both now. admit() charges
        // the exact serialized cost, truncates, and marks the ledger, and the
        // truncation short-circuit above stops buffering on the next chunk.
        // `+ 3` covers the two quotes and one separator admit adds. The two
        // pipes are independent OS streams whose `data` events already interleave
        // nondeterministically with each other and with the child's own fd-3
        // `log` frames, so `logs` carries no cross-pipe ordering guarantee to
        // preserve here; a fixed drain order is as valid as any.
        // Flushing is NOT a stream end: a multibyte UTF-8 character can be split
        // across pipe `data` chunks, so the residual may end mid-sequence. A
        // budget-triggered flush must decode only the complete prefix and carry
        // the incomplete tail forward (≤3 bytes) on the same pipe's residual —
        // decoding it here would render a legal character as U+FFFD in a released
        // entry (see `flushStray`). This is unlike the `end`/closeDeadline paths
        // below, where a trailing incomplete sequence is genuinely truncated input
        // and U+FFFD is honest.
        if (strayOut.cost + strayErr.cost + 3 > logBudget) {
          flushStray(strayOut, true)
          flushStray(strayErr, true)
        }
      }
      // Flush a pipe's residual into `logs`. Called on the combined-budget
      // threshold above, on the pipe's `end` (normal drain), and — for the
      // setsid-escapee path where destroy() forces settlement without an `end` —
      // explicitly in the closeDeadline handler. Idempotent: it clears what it
      // admits, so a later flush is a no-op, and it returns early on an empty
      // buffer so flushing the sibling that had nothing pending is a no-op. The
      // `chunks`/`blocks` guard is the only emptiness check needed — `data` never
      // emits a zero-length Buffer, so a non-empty fragment list always decodes
      // to a non-empty tail.
      //
      // `retainPartialTail` is true only on the budget-triggered path: there the
      // residual can end at an ARBITRARY pipe boundary, so if the incomplete
      // trailing bytes of a UTF-8 lead sequence are pending (`stray.utf8.expected
      // > 0`), they are withheld from the decode and re-carried on `chunks` for a
      // later chunk to complete — decoding them here would render a LEGAL,
      // un-finished character as U+FFFD in an admitted entry, and the next chunk's
      // bytes would then each independently break into more U+FFFD. The withheld
      // tail is `stray.utf8.width - stray.utf8.expected` bytes (the lead plus the
      // continuations consumed so far), at most 3; `stray.utf8` is reset and the
      // withheld tail re-accrued so the next chunk continues the walk correctly.
      // The `end`/closeDeadline paths pass `false`: there a trailing incomplete
      // sequence is real truncated input and the U+FFFD is the honest render.
      function flushStray(stray: StrayBuffer, retainPartialTail?: boolean): void {
        if (stray.chunks.length === 0 && stray.blocks.length === 0) return
        // Concatenate the sealed blocks and the current-chunk residual together
        // unconditionally (no `blocks.length > 0` ternary): a flush can run with
        // either or both present, and a branch on their presence would need a
        // test that flushes exactly at a seal boundary.
        let full = Buffer.concat([...stray.blocks, ...stray.chunks])
        // A budget flush landing exactly between a lead byte and its
        // still-pending continuation requires the combined-cost threshold to trip
        // on a specific mid-multibyte pipe boundary — not deterministically
        // schedulable through the black-box seam, which observes only complete
        // entries. So the retention arm is v8-ignored (exercised by review
        // reasoning over the `stray.utf8` state, not by an in-tree test): it
        // withholds the lead-plus-consumed-continuations tail (≤3 bytes, via
        // `stray.utf8.width - stray.utf8.expected`) from the decode, re-carries it
        // for a later chunk, and re-accrues the pipe's cost/UTF-8 state over it;
        // decoding here would render a LEGAL, unfinished character as U+FFFD in an
        // admitted entry. Every retainPartialTail=false call (the `end`/closeDeadline
        // paths) and a budget flush with no partial tail in flight (`expected === 0`)
        // falls through with `keep` unset: the FULL residual is decoded — there a
        // trailing incomplete sequence is real truncated input and the U+FFFD is the
        // honest render.
        let keep: Buffer | undefined
        /* v8 ignore next 18 -- mid-sequence budget-flush boundary is not schedulable from a test. */
        if (retainPartialTail && stray.utf8.expected > 0) {
          const drop = Math.min(stray.utf8.width - stray.utf8.expected, full.length)
          keep = full.subarray(full.length - drop)
          full = full.subarray(0, full.length - drop)
          stray.chunks = detachResidual(keep)
          // Re-accrue the withheld tail from a FRESH state: `stray.utf8` still
          // holds the whole-pending state (`expected > 0`, i.e. the tail is
          // mid-sequence), so metering `keep` against it would charge the carried
          // LEAD byte as an illegal continuation. Reset, then walk `keep` so the
          // resumed sequence re-claims its own lead.
          stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
          stray.cost = accrueStrayCost(keep, stray.utf8)
          stray.blocks = []
          // Do not admit an EMPTY entry: when the whole residual is a single
          // unfinished multibyte sequence, `full` was drained into `keep` and no
          // complete byte stream remains to admit. `admit('')` would push a
          // model-visible bogus empty line (logs are joined with '\n' downstream).
          if (full.length > 0) admit(full.toString('utf8'))
        } else {
          stray.chunks = []
          stray.cost = 0
          stray.utf8 = { expected: 0, width: 0, lowerFirst: 0, upperFirst: 0 }
          stray.blocks = []
          admit(full.toString('utf8'))
        }
      }
      child.stdout.on('data', (chunk: Buffer) => { captureStray(strayOut, chunk) })
      child.stderr.on('data', (chunk: Buffer) => { captureStray(strayErr, chunk) })
      child.stdout.on('end', () => { flushStray(strayOut) })
      child.stderr.on('end', () => { flushStray(strayErr) })

      // Line-framed JSON reader over fd 3. The unframed buffer is bounded: a
      // hostile program can loop `os.write(3, b"A"*4096)` with no newline to
      // exhaust HOST memory, which the child's RLIMIT_AS does not cover. It is
      // a memory-safety bound only: legitimate `call` frames may be large
      // (binding traffic has no seam byte cap), so it never keys off
      // maxValueBytes.
      // Buffered as raw chunks with a running byte counter: appending is O(1)
      // per chunk (a string `+=` accumulator would re-copy the whole prefix on
      // every pipe chunk — quadratic on a large frame), joins happen only when
      // a newline actually arrived, and the ceiling check reads the counter.
      let pendingChunks: Buffer[] = []
      // Fragments already merged into finished blocks. Kept separate from
      // `pendingChunks` so sealing never re-copies what earlier seals produced;
      // the two together are the unframed buffer, and `pendingBytes` counts both.
      let sealedBlocks: Buffer[] = []
      let pendingBytes = 0
      proto.on('data', (chunk: Buffer) => {
        // Once settled, stop accumulating: a hostile child that keeps flooding
        // fd 3 between finish() and close must not regrow the host buffer.
        /* v8 ignore next -- post-settlement data needs the child to outrace close after we decided. */
        if (settled) return
        // Schedule ONE post-batch outstanding-call check per macrotask. The
        // check must see the TRUE count — the live count is inflated by this
        // batch's own frames (the finallys run on the microtask queue, which
        // drains only when the macrotask ends), and a per-event snapshot is
        // stale when flowing mode fires several 'data' events within one
        // macrotask before any microtask drains. setImmediate runs after the
        // current macrotask's microtasks, so the count is exact; the flag
        // dedupes the check across the events of one macrotask. The threshold
        // is STRICT: exactly MAX_PENDING_REPLIES outstanding calls are allowed,
        // so a program that returns with calls it never awaited still
        // completes (the done frame settles the run; the check no-ops on
        // `settled`).
        if (!postBatchCheckPending) {
          postBatchCheckPending = true
          setImmediate(() => {
            postBatchCheckPending = false
            /* v8 ignore next -- the done frame can settle the run between the schedule and this callback. */
            if (settled) return
            if (pendingCalls > MAX_PENDING_REPLIES) {
              finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
            }
          })
        }
        pendingChunks.push(chunk)
        pendingBytes += chunk.length
        // Check the counter BEFORE the join, not the joined line afterwards:
        // Buffer.concat allocates a second copy of everything held, so a line
        // measured after the concat had already cost twice the ceiling — the
        // ceiling this check exists to enforce. The counter is exact and free,
        // and the retained chunks are released here so the rejected payload is
        // not still held while the run settles.
        //
        // The counter charges the whole unframed buffer, which over-counts by at
        // most the newline-bearing chunk's own length (one pipe read): the
        // residual carried in is always a partial line, so nothing but the
        // current line can be larger than that. That over-count is deliberate and
        // load-bounded on the OTHER side: the config cap is `parse-cap - envelope`,
        // and a legitimate near-cap frame plus a following chunk's leading bytes
        // could in principle nudge the counter over the cap for one read window
        // — but only when maxLogBytes/maxValueBytes is configured within one
        // pipe read of the 64 MiB cap, orders of magnitude past the 32/64 KiB
        // defaults.
        //
        // The cap is enforced ONLY when the held bytes are still a single
        // unframed line (this chunk carries no newline, and earlier
        // newline-bearing chunks were joined immediately): a frame past the cap
        // would otherwise be fully `Buffer.concat`-ed (a second copy of its
        // bytes) and only then dropped in the line loop — the peak-memory
        // doubling this pre-concat check exists to prevent. Dropping the
        // oversized unframed buffer before the join keeps the peak at one copy
        // of the wire bytes. When this chunk DOES carry a newline the buffer
        // holds several frames, so the FIRST-FRAME check below (not this
        // counter, which charges them all) decides.
        if (pendingBytes > this.frameParseCapBytes && !chunk.includes(0x0a)) {
          pendingChunks = []
          sealedBlocks = []
          pendingBytes = 0
          finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
          return
        }
        // Bound the FRAGMENT COUNT as well as the byte total, but only AFTER the
        // ceiling check above: sealing first would `Buffer.concat` an already
        // over-ceiling payload and allocate a second copy of it before the
        // rejection ran, which is the peak-memory doubling that check exists to
        // prevent.
        //
        // Fragment count needs its own bound because the ceiling meters payload
        // bytes only, while each retained chunk is a separate Buffer with object
        // and backing-store overhead no byte count sees: 5000 single-byte
        // newline-free writes produced 5000 chunks holding 5031 bytes, so a
        // program pacing such writes could accumulate millions of objects inside
        // the wall budget and exhaust the host heap far below the ceiling.
        //
        // Sealing appends to a list of finished blocks instead of re-merging
        // everything held. Concatenating the whole buffer at each threshold
        // re-copied the entire accumulated prefix every time, so the cumulative
        // copy volume was quadratic, not the amortized O(1) an earlier revision
        // of this comment claimed: 10 MiB trickled a byte at a time copies
        // 53.7 GB that way, and 64 MiB copies 2.2 TB. Here each byte is copied
        // once into its block and never again, so the total stays linear, and the
        // block list is itself bounded — every block holds at least
        // `MAX_PENDING_CHUNKS - 1` bytes, so reaching the 64 MiB cap admits
        // at most a few hundred thousand of them.
        // Sealing runs ONLY on a newline-free chunk, and after the newline
        // branch below: a chunk carrying a newline must reach the join (and its
        // first-frame check) rather than being sealed into a block the check
        // would then not scan for newlines. That keeps the invariant
        // `sealedBlocks hold newline-free prefixes only` true, so the
        // first-frame scan below can charge each sealed block's whole length
        // toward the first frame without missing a newline inside it.
        if (chunk.includes(0x0a)) {
          // First-FRAME check before the join: measure the bytes up to the
          // first newline across the held chunks. The byte counter cannot
          // serve here — it charges the whole buffer, which legitimately
          // holds several frames each within the cap. A first frame past the
          // cap is dropped before the join (one copy of its wire bytes);
          // later frames in the same buffer are handled line by line in the
          // loop below.
          let firstFrameLen = 0
          let sawNewline = false
          // Sealed blocks hold newline-free prefixes only (see the sealing
          // gate below), so they are entirely part of the first frame.
          for (const b of sealedBlocks) firstFrameLen += b.length
          for (const c of pendingChunks) {
            const nl = c.indexOf(0x0a)
            if (nl >= 0) {
              firstFrameLen += nl
              sawNewline = true
              break
            }
            firstFrameLen += c.length
          }
          if (sawNewline && firstFrameLen > this.frameParseCapBytes) {
            pendingChunks = []
            sealedBlocks = []
            pendingBytes = 0
            finish({ error: { kind: 'worker-exit', message: `protocol frame exceeded ${this.frameParseCapBytes} bytes on fd 3` } })
            return
          }
          let buffered = Buffer.concat(sealedBlocks.length > 0 ? [...sealedBlocks, ...pendingChunks] : pendingChunks)
          sealedBlocks = []
          let newline: number
          while ((newline = buffered.indexOf(0x0a)) >= 0) {
            const line = buffered.subarray(0, newline)
            buffered = buffered.subarray(newline + 1)
            /* v8 ignore next -- an empty line comes only from a forged `\n\n` write. */
            if (line.length === 0) continue
            // No per-line cap check here: the pre-join counter (single unframed
            // line) and the first-frame check (newline-bearing chunk) above
            // reject any frame past FRAME_PARSE_CAP_BYTES before this join, so
            // every line in this loop is within the cap by construction — a
            // per-line check would be dead code.
            // `toString('utf8')` would silently REPLACE illegal bytes with
            // U+FFFD, corrupting a completion or binding payload a forged
            // frame smuggled in (the honest child's lossless encoder never
            // emits non-UTF-8, so such a frame is hostile traffic). The fatal
            // decode throws on them and the frame is dropped — not accepted
            // with a mangled value — the same treatment as the unsafe-integer
            // check below.
            let text: string
            try {
              text = UTF8_FATAL.decode(line)
            } catch {
              continue
            }
            // JSON.parse would silently ROUND an integer token outside the
            // safe range before validation could see it, so a forged frame
            // could smuggle a corrupted value into a dispatch or completion.
            // An honest child never emits one (its validator rejects unsafe
            // ints), so such a frame is hostile traffic: drop it like any
            // other junk frame.
            if (hasUnsafeIntegerToken(text)) continue
            let parsed: unknown
            try {
              parsed = JSON.parse(text) as unknown
            } catch {
              continue // Junk frames drop silently (hostile-peer stance).
            }
            const message = validateChildFrame(parsed)
            if (message) handleFrame(message)
          }
          // Carry the residual forward as a fresh, right-sized copy, NOT the
          // `subarray` view: a view keeps the whole joined-frame allocation from
          // the `Buffer.concat` above alive, so a large frame followed by a tiny
          // trailing fragment would pin megabytes while `pendingBytes` reported
          // only the fragment's length. See {@link detachResidual}.
          pendingChunks = detachResidual(buffered)
          pendingBytes = buffered.length
        } else if (pendingChunks.length >= MAX_PENDING_CHUNKS) {
          // A newline-free run past the fragment-count bound: seal the held
          // chunks into one finished block (amortized O(1) per byte, see the
          // comment above the count bound) and keep accumulating. The gate on
          // `chunk.includes(0x0a)` is the ELSE half of the newline branch, so a
          // newline-bearing chunk never lands in a sealed block.
          sealedBlocks.push(Buffer.concat(pendingChunks))
          pendingChunks = []
        }
      })

      // Duplicate-call suppression against the honest child's id SEQUENCE, not
      // a set of every id seen. `dispatch` sends consecutive ids from 0 with no
      // gaps — it advances its counter only after the write succeeds, so a call
      // rejected before reaching the wire consumes nothing — which makes the
      // next legitimate id exactly `nextCallId`.
      //
      // Retaining a set instead let a program write an unbounded run of unique
      // forged ids, each below the 64 MiB per-frame parse cap so nothing
      // rejected them, and grow host memory for the whole run. Accepting any
      // id above a high-water mark would have been just as wrong in the other
      // direction: one forged `{"id": 9999}` would starve every honest call
      // after it. The exact successor is the only test that both bounds the
      // retained state to one number and cannot be poisoned by a forgery.
      let nextCallId = 0

      // Set by run() when the boot frame is written; the fd-3 handler calls it
      // on boot-ack to send the run frame (see the seam's boot->boot-ack->run
      // order). scoped per run. An object holder so the cross-closure
      // assignment is a property write (eslint's prefer-const cannot see the
      // reassignment through the closure).
      const bootAckGate: { run?: () => void } = {}
      const handleFrame = (message: ChildToHost): void => {
        /* v8 ignore next -- late frame after settlement; defensive against forged post-settlement traffic. */
        if (settled) return
        switch (message.type) {
          case 'boot-ack':
            // The child accepted the boot frame (namespaces built); the run
            // frame goes out now, not with the boot frame.
            bootAckGate.run?.()
            return
          case 'log':
            if (message.truncated === true) {
              // The CHILD ledger hit its cap. Its marker is the last log text
              // there will be, so record it and stop host capture at the same
              // point: admitting it as ordinary text left the host budget open,
              // so later direct `os.write(1, ...)` bytes were retained AFTER the
              // marker and a host-side exhaustion could append a second one.
              // Both ledgers are keyed to the same `maxLogBytes`, so one marker
              // describes the run.
              if (!logsTruncated) {
                // The host's OWN marker, never the frame's text. `truncated` is
                // attacker-reachable, so trusting the text let a program write
                // `{"type":"log","truncated":true,"text":<1 MiB>}` and land all
                // of it in `logs` under a 64-byte `maxLogBytes` — measured, the
                // whole megabyte was retained, bypassing `admit` and its
                // ceiling. Both ledgers key off the same `maxLogBytes`, so the
                // marker the host generates says the same thing the child's
                // would have.
                truncateLogs()
              }
              return
            }
            if (message.open === true) {
              // An explicit flush of an unterminated line: hold it so the next
              // frame appends to the SAME entry (print('a', end='', flush=True)
              // followed by print('b') reads back as one 'ab' entry, not a fake
              // newline). Billed INCREMENTALLY so k tiny frames cost O(k), not
              // O(k * budget) (re-walking the whole held text per frame): the
              // first fragment is charged the full JSON-string cost plus the
              // separator (quotes + content + newline), each continuation only
              // its content (jsonStringCostUpTo includes the two quotes), and
              // the closing frame only its own content — the merged entry's
              // wire cost is billed exactly once, split across the fragments.
              // Caps: the first fragment's exact-cost walk uses logBudget - 1
              // (the ledger's reserved byte, matching admit), a continuation's
              // logBudget + 2 (a continuation is billed WITHOUT quotes, so its
              // billed cost cost - 2 fits exactly when the walk's cost is at
              // most logBudget + 2).
              if (!logsTruncated) {
                // An EMPTY first open frame (openParts empty AND text '') bills
                // cost + 1 = 3 but establishes no hold (the push is skipped),
                // so the next frame is billed as a new first fragment. Not
                // reachable from an honest child (_LogStream.write('') returns
                // early; flush_line pushes only non-empty pending); for a
                // forged frame it is a bounded over-charge in the safe
                // direction (a flood exhausts the ledger into truncation).
                const cap = openParts.length === 0 ? logBudget - 1 : logBudget + 2
                const cost = jsonStringCostUpTo(message.text, cap)
                if (cost === undefined) {
                  truncateLogs()
                } else {
                  const bill = openParts.length === 0 ? cost + 1 : Math.max(cost - 2, 0)
                  logBudget -= bill
                  // A zero-content continuation (text '') bills 0; holding it
                  // would grow the fragment array without touching the ledger,
                  // so a forged empty-open flood could grow host memory — skip
                  // the push, the merge result is unchanged.
                  if (message.text !== '') {
                    if (openParts.length >= MAX_PENDING_CHUNKS) {
                      openSealed.push(openParts.join(''))
                      openParts = []
                    }
                    openParts.push(message.text)
                  }
                }
              }
              return
            }
            if (openParts.length > 0) {
              // Closing frame: the held fragments are already billed; bill only
              // this frame's own content (the quotes and separator ride on the
              // first fragment) and push the merged entry once. Cap is
              // logBudget + 2 for the same reason as a continuation.
              /* v8 ignore next -- logsTruncated is an invariant false here: an open
               * frame that would trip the ledger resets openParts, so a non-empty
               * hold implies the ledger never truncated. The guard is defensive. */
              if (!logsTruncated) {
                const cost = jsonStringCostUpTo(message.text, logBudget + 2)
                if (cost === undefined) {
                  truncateLogs()
                } else {
                  logBudget -= Math.max(cost - 2, 0)
                  logs.push(openSealed.join('') + openParts.join('') + message.text)
                }
              }
              openSealed = []
              openParts = []
              return
            }
            admit(message.text)
            return
          case 'done': {
            // The call-backlog cap must also hold when the child finishes in
            // the SAME batch as its flood: the post-macrotask check no-ops once
            // this done frame settles the run, so a done arriving right after
            // more than MAX_PENDING_REPLIES call frames in one data event would
            // otherwise complete successfully with the outstanding closures
            // left behind (a single sub-64 KiB write can carry 1025 compact
            // calls plus a done). The strict threshold lets exactly
            // MAX_PENDING_REPLIES outstanding calls — a program that returned
            // without awaiting its calls — complete normally.
            if (pendingCalls > MAX_PENDING_REPLIES) {
              finish({ error: { kind: 'worker-exit', message: `call backlog exceeded ${MAX_PENDING_REPLIES} in-flight binding calls (a binding never settled)` } })
              return
            }
            if (message.error) {
              finish({ error: { kind: message.error.kind, message: capMessage(message.error.message, this.config.maxValueBytes) } })
              return
            }
            if (message.value === undefined) {
              finish({})
              return
            }
            // Re-enforce the completion budget and number losslessness
            // host-side: a forged done frame bypasses the Python-side
            // _done_with_value check, and validateChildFrame no longer scans
            // the value (an unbounded scan would push every member of a wide
            // forgery before any cap ran). checkDoneValue folds both jobs into
            // one bounded, iterative traversal — iterative because the seam's
            // PtcJsonValue has no depth limit and an honest deep-but-small
            // completion must cross intact rather than dying on stringify
            // recursion; bounded because it stops at the cap without
            // materializing the encoding, rejecting a forged value anywhere
            // below the 64 MiB frame parse cap before it forces host-side copies.
            // The seam forbids substituting a rendered/truncated value, so an
            // oversized value fails the run as output-limit and a non-lossless
            // number as invalid-output. The value is JSON-plain by construction
            // (it came from JSON.parse of the frame), the traversal's precondition.
            const check = checkDoneValue(message.value, this.config.maxValueBytes)
            if (!check.ok) {
              finish(check.reason === 'over-budget'
                ? { error: { kind: 'output-limit', message: `completion value exceeded ${this.config.maxValueBytes} bytes` } }
                : { error: { kind: 'invalid-output', message: 'completion value contained a non-lossless number' } })
              return
            }
            finish({ value: message.value as PtcJsonValue })
            return
          }
          case 'call': {
            if (message.id !== nextCallId) return
            nextCallId += 1
            const record = bindings.get(message.global)?.functions
            const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
            if (typeof fn !== 'function') {
              // `call.global` and `call.name` are attacker-controlled strings
              // with no byte cap of their own — only the 64 MiB fd-3 frame
              // parse cap — so each is sliced to `maxValueBytes` CODE UNITS
              // BEFORE it reaches the template. Interpolating them whole would
              // copy them into the message, `JSON.stringify` would copy the
              // escaped form, `encodeJsonPlain` the frame, and the pipe write
              // again: four full-size host allocations off one below-ceiling
              // forgery, past every hostile-peer bound the log and done-error
              // paths apply. Nothing past the first `maxValueBytes` code units
              // of either field can survive the byte cap anyway, so the slices
              // lose only text `capMessage` would drop, and that final cap
              // gives this reply the same budget and marker as a forged done
              // error.
              const cap = this.config.maxValueBytes
              const target = `${message.global.slice(0, cap)}.${message.name.slice(0, cap)}`
              // JSON.stringify on the WHOLE capped target would still allocate
              // the escaped form — up to ~6x under control-heavy input, a
              // multi-hundred-MB spike near the maxValueBytes ceiling that no
              // hostile-peer bound would have admitted. The message only needs
              // to identify the binding, so the escaped form is built from a
              // 1 KiB prefix; capMessage then enforces the reply budget.
              const preview = JSON.stringify(target.slice(0, 1024))
              sendReply({ type: 'reply', id: message.id, ok: false, message: capMessage(`unknown binding ${preview}`, cap) })
              return
            }
            // Count the outstanding binding call before dispatch and release the
            // slot in the async body's finally. The CAP CHECK runs in the data
            // handler's post-macrotask pass (where the finallys have drained),
            // not here: a per-frame check would see every frame of one event as
            // in-flight and false-positive on a legitimate gather of more than
            // MAX_PENDING_REPLIES instant calls.
            pendingCalls += 1
            void (async () => {
              try {
                const resolved = await fn(message.args)
                // Drop a reply the run no longer needs BEFORE snapshotting it.
                // `sendReply` also checks `settled`, but only after this value has
                // been walked and copied: a binding that resolves a wide value
                // after `maxWallMs`, an abort, or dispose already settled the run
                // would spend host heap on a frame that is then discarded, and
                // binding resolution carries no seam-level byte cap to bound it.
                // oxlint-disable-next-line typescript/no-unnecessary-condition -- the run can settle while this binding is awaited.
                if (settled) return
                // The seam requires a lossy resolution to REJECT descriptively,
                // not silently coerce: a raw JSON.stringify would turn NaN/
                // Infinity into null and drop undefined fields. Snapshot through
                // the same lossless-JSON boundary the Node backend uses (also
                // iterative, so a deeply nested value cannot overflow the stack).
                const value = snapshotJsonValue(resolved)
                if (value === undefined) {
                  sendReply({ type: 'reply', id: message.id, ok: false, message: 'binding resolution must be lossless JSON' })
                  return
                }
                sendReply({ type: 'reply', id: message.id, ok: true, value })
              } catch (error: unknown) {
                // Check `settled` before formatting the error: a rejection that
                // arrives after `maxWallMs`, an abort, or dispose has already
                // settled the run, and `messageOf(error)` runs hostile getters
                // before `sendReply` peeks at `settled`. Dropping the framed
                // reply early spares the host heap and time for a run whose
                // outcome is already fixed.
                // (oxlint block-disable so both `v8 ignore next` and the rule
                // suppression land on the `if`: `settled` flips true mid-wait,
                // invisible to the type-aware lint, which narrows it to false.)
                /* oxlint-disable typescript/no-unnecessary-condition */
                /* v8 ignore next -- a rejection arriving after settlement is not schedulable from a test. */
                if (settled) return
                /* oxlint-enable typescript/no-unnecessary-condition */
                sendReply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
              } finally {
                // Release the in-flight slot on every exit — reply written,
                // resolution rejected, or the run settling mid-wait (the
                // `settled` early returns above). Without this, a binding that
                // never resolves would leak its slot past the cap check and the
                // flood bound would erode.
                pendingCalls -= 1
              }
            })()
            return
          }
        }
      }

      // Write one reply frame with the iterative encoder: a binding
      // resolution has no seam-level depth or byte cap, so a deeply nested
      // value must not die on JSON.stringify's recursion. The payload is
      // JSON-plain by construction (snapshotJsonValue output, or literal
      // strings/numbers), which is encodeJsonPlain's precondition. A closed
      // pipe (child already gone) is swallowed since the close path settles
      // the run.
      //
      // Replies are encoded and written ONE AT A TIME, waiting for `drain`
      // whenever fd 3's buffer is full. Binding resolution carries no
      // seam-level byte cap, so a program that resolves several large values in
      // one `asyncio.gather` round would otherwise encode them all in the same
      // turn and queue every frame in the writable stream's buffer -- measured
      // to exhaust a 256 MiB Node heap, which kills the whole host process
      // rather than failing this one run. Pacing changes no model-visible
      // behavior: the child matches each reply to its `call` by id from a pump
      // that reads fd 3 continuously, so arrival order was never observable,
      // and the bindings themselves still run concurrently. Only the host's peak
      // memory and the flush timing change.
      const replyQueue: ReplyMessage[] = []
      // Replies queued but not yet written, tracked separately from
      // `replyQueue.length`: the drain loop clears consumed slots to `undefined`
      // but does not shrink the array until it finishes, so `length` counts
      // consumed frames too. The counter is what the cap in `sendReply` reads.
      let pendingReplies = 0
      // Binding calls dispatched but not yet settled (the async body below
      // still awaits the binding's promise). The reply backlog cap only counts
      // RESOLVED calls — `pendingReplies` grows after the await — so a child
      // flooding calls against a binding that never settles would accumulate
      // one async closure per frame until the wall clock without tripping it.
      // Counted here before dispatch and released in the body's finally; the
      // data handler schedules a post-macrotask check (see there) that settles
      // the run as worker-exit when the true outstanding count passes
      // MAX_PENDING_REPLIES.
      let pendingCalls = 0
      // Dedupes the post-batch outstanding-call check across the 'data' events
      // of one macrotask (see the data handler).
      let postBatchCheckPending = false
      let draining = false
      // Resolve when fd 3 can take another frame, OR when it is gone: a pipe
      // destroyed under the drain (child exited, close-deadline teardown) never
      // emits 'drain' again, so waiting on that event alone would hang the
      // drain forever — `draining` stays true and the unconsumed queue is
      // pinned with the closure. `once` plus the manual detach removes every
      // listener whichever event wins, so a long backpressure wait leaves none
      // behind.
      const waitForDrain = (): Promise<void> => new Promise<void>((resolvePromise) => {
        const finish = (): void => {
          proto.off('drain', finish)
          proto.off('close', finish)
          proto.off('error', finish)
          resolvePromise()
        }
        proto.once('drain', finish)
        proto.once('close', finish)
        proto.once('error', finish)
      })
      const drainReplies = async (): Promise<void> => {
        if (draining) return
        draining = true
        let head = 0
        try {
          while (head < replyQueue.length) {
            // Needs the run to settle between two queued frames. Measured queue
            // depths reach 11 without the wall clock landing inside that window.
            /* v8 ignore next -- see above; not schedulable from a test. */
            if (settled) break
            // A pipe destroyed under us (child exited, close deadline) will
            // never emit 'drain' again; short-circuit before the write so the
            // remaining frames are dropped by the `finally` below.
            if (proto.destroyed) break
            // Read by index, not `shift()`: a large `asyncio.gather` of wide
            // bindings awaiting fd 3's `drain` can queue many frames, and each
            // `shift()` re-slices the remaining array (O(n) per pop, O(n²) over
            // the whole drain). A head cursor keeps the cost linear; the `finally`
            // below discards everything consumed once the drain ends. The consumed
            // slot is CLEARED here (not just advanced past) so a wide payload the
            // pipe has already taken is released immediately: under sustained
            // backpressure the drain loop can live across many `await drain`
            // ticks, and leaving the slot set would pin the written value's bytes
            // in `replyQueue` for the whole busy period, making host memory grow
            // with cumulative processing rather than the current backlog.
            const payload = replyQueue[head] as ReplyMessage
            replyQueue[head] = undefined as unknown as ReplyMessage
            head += 1
            pendingReplies -= 1
            // Compact the consumed prefix once it reaches the backlog bound:
            // the array never shrinks until the drain finishes, and a child
            // that reads replies just fast enough to keep the drain alive but
            // never empty would otherwise grow the backing store linearly with
            // cumulative throughput (consumed slots are undefined, but `length`
            // keeps counting them). The splice is O(head) once per
            // MAX_PENDING_REPLIES consumed frames — amortized O(1) per reply.
            if (head >= MAX_PENDING_REPLIES) {
              replyQueue.splice(0, head)
              head = 0
            }
            // Encode inside the loop, not up front: a queued reply the run no
            // longer needs is dropped by the `settled` check above without ever
            // being serialized.
            if (!proto.write(`${encodeJsonPlain(payload)}\n`)) {
              await waitForDrain()
            }
          }
        } catch {
          // Pipe closed under us (child exited), or `drain` never arrives because
          // the child died. The close path settles the run either way.
        } finally {
          draining = false
          pendingReplies = 0
          replyQueue.length = 0
        }
      }
      const sendReply = (payload: ReplyMessage): void => {
        /* v8 ignore next -- `settled` covers a race where the child exits between decision and write. */
        if (settled) return
        // A child that stops reading fd 3 leaves the drain loop blocked on
        // `drain` forever while its call frames keep resolving into replies:
        // the backlog would grow without bound until the wall clock, pinning
        // every binding result the child provokes. Cap the retained backlog and
        // settle the run as a worker-exit, the same hostile-peer bound the
        // frame cap applies to inbound bytes.
        if (pendingReplies >= MAX_PENDING_REPLIES) {
          finish({ error: { kind: 'worker-exit', message: `reply queue exceeded ${MAX_PENDING_REPLIES} pending frames on fd 3 (the child stopped consuming its replies)` } })
          return
        }
        pendingReplies += 1
        replyQueue.push(payload)
        void drainReplies()
      }

      // Escalate SIGTERM → grace → SIGKILL on the entire process group. Idempotent
      // via `killing`.
      let killing = false
      let graceTimer: NodeJS.Timeout | undefined
      // A backstop for the one case `close` cannot cover: model code that starts
      // a descendant with `os.setsid()`/`start_new_session=True` moves it into a
      // fresh process group, so the SIGTERM/SIGKILL aimed at the child's group
      // (`kill(-pid)`) never reaches it. If that orphan inherited stdout/stderr/
      // fd 3 and outlives the run, those pipes stay open and `close` never fires
      // — leaving run() (and a teardown awaiting `finished`) hung indefinitely.
      // finish() arms this deadline; when it fires we detach our stream handles
      // and settle on the already-decided result regardless of the orphan.
      let closeDeadline: NodeJS.Timeout | undefined
      // The leader's start time, read once while it is certainly alive. `child.pid`
      // keeps its numeric value after the leader is reaped (Node clears the
      // internal handle, not the field), and `close` can trail `exit` by seconds
      // while a pipe-holding descendant keeps the streams open. Signalling
      // `-child.pid` in that window is a RAW syscall -- `child.kill()` would
      // refuse, having dropped its handle, but `process.kill` has no such guard --
      // so a recycled pgid would receive this run's SIGTERM and armed SIGKILL.
      // `groupEmpty()` cannot cover it: it reports whether the group has members,
      // not whether they are OURS, and it runs only after the first signal.
      // The repository already takes this position in
      // packages/subprocess/subprocess-local (`ProcessIdentity`, "preventing
      // teardown escalation after PID reuse"); this is the same guard, kept local
      // because a dependency on that package would be a new architectural edge.
      const leaderStarted = child.pid === undefined ? undefined : readProcessStart(child.pid)
      const killGroup = (sig: NodeJS.Signals): void => {
        try {
          /* v8 ignore next -- undefined pid means spawn never produced a process; finish() short-circuits before reaching kill(). */
          if (child.pid === undefined) return
          // A pid alone cannot answer this: `process.kill(pid, 0)` succeeds just
          // as well for a REPLACEMENT process holding the recycled number. Only
          // the start time distinguishes the two, so a reading that DISAGREES
          // means the number now belongs to another process and must not be
          // signalled.
          //
          // An ABSENT reading is the ordinary case, not a mismatch: once the
          // leader is reaped its `/proc/<pid>/stat` is gone, while the group it
          // led can still hold survivors that this teardown exists to reap. So
          // only a present-and-different reading blocks the signal; undefined
          // falls through, which is also the behavior on platforms with no
          // `/proc` to read.
          const nowStarted = readProcessStart(child.pid)
          // The refusal arm needs a real pid recycled into a new group leader
          // between spawn and teardown, which no test can schedule; the reader
          // itself is covered directly by the process-identity test.
          /* v8 ignore next -- unreachable without real pid reuse; see above. */
          if (leaderStarted !== undefined && nowStarted !== undefined && nowStarted !== leaderStarted) return
          process.kill(-child.pid, sig)
        } catch {
          // ESRCH — the process already died. Nothing to do.
        }
      }
      const kill = (): void => {
        /* v8 ignore next -- kill() is idempotent; tests do not double-invoke it. */
        if (killing) return
        killing = true
        killGroup('SIGTERM')
        // Escalate to SIGKILL after the grace window. The timer is `unref`'d so a
        // pending SIGKILL never keeps the host process alive on its own; the
        // guarantee that a same-group survivor is actually reaped before the fiber
        // goes quiescent is enforced by settle() awaiting the group's death (see
        // there), NOT by this timer firing during host lifetime. A setsid-escaped
        // orphan in a FRESH group is the different case `closeDeadline` in finish()
        // covers, since `close` never fires there.
        graceTimer = setTimeout(() => { killGroup('SIGKILL') }, this.config.graceMs)
        graceTimer.unref()
      }
      // True once the group has no members left: a signal-0 probe to the whole
      // group (`kill(-pid, 0)`) throws ESRCH when empty (EPERM would still mean a
      // member exists). Only meaningful once a spawn produced a pid.
      const groupEmpty = (): boolean => {
        /* v8 ignore next -- pid is always defined once escalation runs; the guard narrows the type. */
        if (child.pid === undefined) return true
        try {
          process.kill(-child.pid, 0)
          return false
        } catch (error: unknown) {
          return (error as NodeJS.ErrnoException).code === 'ESRCH'
        }
      }

      let finishResolve!: () => void
      const finished = new Promise<void>((done) => { finishResolve = done })
      let resolved = false
      // The decided terminal result for a live child, recorded by finish() and
      // read by the `close` handler that settles it once the pipes have drained.
      let decided: Omit<PtcRunResult, 'logs'>

      // The single settlement point: resolve run() with the decided result and
      // mark the fiber quiescent. Idempotent — the first call wins, so a later
      // `close` after done/timeout/abort is absorbed as a no-op.
      const settle = (result: Omit<PtcRunResult, 'logs'>): void => {
        if (resolved) return
        resolved = true
        if (closeDeadline !== undefined) clearTimeout(closeDeadline)
        // The child has exited by now (settle runs on `close`, or on a spawn
        // that produced no pid), so its staging directory is no longer read and
        // this run's copy goes away with it. Removed SYNCHRONOUSLY, before
        // `resolve` below: a fire-and-forget removal left the directory on disk
        // when `run()` resolved, so a caller could not observe the "gone by
        // settlement" contract at all. Two files cost nothing to unlink here.
        try {
          rmSync(bootstrapDir, { recursive: true, force: true })
        } catch {
          // Swallows only a failure to remove this run's staging directory —
          // `force` already absorbs a missing one, so what remains is a
          // filesystem-level refusal. The run's own outcome is already decided
          // and must still be delivered; the directory holds no secret, only a
          // copy of two checked-in scripts. teardown deliberately does not
          // sweep staging (its staging is cleared inside each run's settle), so
          // a removal failure here is the one case the "gone by settlement"
          // contract degrades on.
        }
        resolve({ ...result, logs })
        // Mark the fiber quiescent for THIS run: drop it from `live` and resolve
        // `finished` (what teardown awaits). Deferred until the process group is
        // actually empty — dropping from `live` before then would let a
        // `dispose()` that races a just-resolved run() snapshot an empty `live`
        // and return while a same-group survivor is still alive, making teardown's
        // "no SAME-GROUP subprocess outlives the fiber" guarantee false for that
        // window (a setsid escapee is the documented exception — see teardown's
        // JSDoc). Keeping the run in `live` until the group is reaped is exactly
        // what makes a concurrent teardown await it.
        const finalize = (): void => {
          this.live.delete(live)
          finishResolve()
        }
        // `finished` is what teardown awaits to honor "no same-group subprocess
        // outlives the fiber". When no escalation ran (normal completion, no
        // kill) or the group is already empty, cancel the pending SIGKILL and
        // finalize now. Clearing it is what bounds the PID-reuse hazard: an armed
        // `kill(-pid)` left to fire up to graceMs later could hit a RECYCLED pgid
        // once the kernel reused the leader's pid, SIGKILLing an unrelated group.
        // So the timer stays armed only while a real survivor exists — a
        // same-group descendant that ignored SIGTERM but released the pipes,
        // still alive here because its `close` is what got us to settle. In that
        // case withhold finalize and poll the group on REF'd timers (a
        // short-lived host would otherwise exit before the unref'd SIGKILL fired,
        // reparenting the survivor to init), clearing the timer the moment the
        // group empties. The wait is bounded by `graceMs + CLOSE_REAP_MARGIN_MS`
        // in the normal case; if the host event loop was blocked past both timers
        // the deadline branch below sends SIGKILL itself and grants ONE more reap
        // margin, so the outer bound is `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
        if (!killing || groupEmpty()) {
          if (graceTimer !== undefined) clearTimeout(graceTimer)
          finalize()
          return
        }
        const deadline = Date.now() + this.config.graceMs + CLOSE_REAP_MARGIN_MS
        // Once the deadline forces us to send SIGKILL ourselves, allow one more
        // reap window for the kernel to tear the group down before giving up:
        // SIGKILL is asynchronous, so the group is not gone the instant it is
        // sent. `finalize` only runs on a confirmed-empty group, except at this
        // final hard bound where nothing more can be done.
        let hardDeadline = 0
        const pollGroup = (): void => {
          if (groupEmpty()) {
            // The group is gone; the grace SIGKILL is moot. Cancel it (it may not
            // have fired yet) and finalize. graceTimer is always defined here:
            // pollGroup runs only when `killing` is set, and kill() armed it.
            clearTimeout(graceTimer)
            finalize()
            return
          }
          if (hardDeadline === 0 && Date.now() >= deadline) {
            // Deadline reached with the group still non-empty. This is reachable
            // when the host event loop was blocked past both timers: Node runs
            // this poll before the grace SIGKILL timer, so that SIGKILL may never
            // have fired. Send it HERE (idempotent if the timer already ran) and
            // keep polling for the group to actually empty — finalizing on mere
            // signal delivery would declare quiescence while the group is still
            // dying. Bound the extra wait by one more reap margin.
            killGroup('SIGKILL')
            clearTimeout(graceTimer)
            hardDeadline = Date.now() + CLOSE_REAP_MARGIN_MS
          }
          // Hard bound: the self-sent SIGKILL delivered but `groupEmpty()` still
          // reports the group non-empty for a full extra reap margin. This is
          // reachable, not a kernel quirk: a SIGKILL'd same-group survivor
          // lingers as a ZOMBIE until its parent `wait()`s it, and in a
          // container whose PID 1 does not reap orphans the survivor is
          // reparented to init and never waited, so the signal-0 probe keeps
          // succeeding — the same environment dependence the Agent Note's
          // rejected "assert the reap with process.kill(pid, 0)" alternative
          // documents. The ignore stays because that container cannot be built
          // deterministically across CI platforms, not because the branch is
          // unreachable; finalizing here bounds the wait so such a deployment
          // still goes quiescent within `graceMs + 2 * CLOSE_REAP_MARGIN_MS`.
          /* v8 ignore next 4 -- reachable only in a PID-1-doesn't-reap container (zombie survivor); not deterministically buildable. */
          if (hardDeadline !== 0 && Date.now() >= hardDeadline) {
            finalize()
            return
          }
          setTimeout(pollGroup, GROUP_REAP_POLL_MS)
        }
        pollGroup()
      }

      const finish = (result: Omit<PtcRunResult, 'logs'>): void => {
        if (settled) return
        settled = true
        decided = result
        clearTimeout(wallTimer)
        request.signal?.removeEventListener('abort', onAbort)
        // A spawn failure (ENOENT, EACCES) never produced a pid, so there is no
        // process to kill: settle now. Its `close` still fires later and reaches
        // the idempotent settle() again as a no-op.
        // An unterminated flushed line never got a closing frame; it was
        // billed incrementally, so push it directly (admit would re-bill).
        // logsTruncated implies the hold is already empty (truncateLogs
        // committed and cleared it), so this is reachable only when the run
        // ends with the hold still open and untruncated.
        if (openSealed.length > 0 || openParts.length > 0) {
          logs.push(openSealed.join('') + openParts.join(''))
        }
        openSealed = []
        openParts = []
        if (child.pid === undefined) {
          settle(result)
          return
        }
        // Live child: SIGTERM→grace→SIGKILL, then let `close` (below) settle the
        // run so any `done` frame buffered on fd 3 is handled first and the
        // process is fully reaped before the fiber goes quiescent.
        kill()
        // `close` awaits every stdio stream draining, which a setsid-escaped
        // orphan holding our inherited pipes can prevent forever. Bound that
        // wait: after SIGKILL has had the grace window plus a margin to reap the
        // child itself, force settlement on the decided result. Flush any
        // newline-free stray residual FIRST — a leader that wrote a diagnostic
        // with `os.write(1, ...)` and exited leaves it buffered, and destroying
        // the stream below drops it before an `end`/`close` flush could run, so
        // the diagnostic would be lost from `logs`. Detaching the stream handles
        // then lets `close` land as a no-op if it ever arrives, and stops the
        // orphan's stray output from being accounted against a run that already
        // finished. `unref` so the deadline never keeps the host process alive.
        closeDeadline = setTimeout(() => {
          flushStray(strayOut)
          flushStray(strayErr)
          proto.destroy()
          child.stdout.destroy()
          child.stderr.destroy()
          settle(result)
        }, this.config.graceMs + CLOSE_REAP_MARGIN_MS)
        closeDeadline.unref()
      }

      child.on('error', (error: Error) => {
        finish({ error: { kind: 'worker-exit', message: `python spawn error: ${error.message}` } })
      })
      // `close` (not `exit`) is the settlement trigger: it fires only after the
      // process exits AND every stdio stream — including the fd-3 protocol pipe —
      // has drained, so a `done` frame the child wrote just before exiting is
      // always handled before we settle. macOS can deliver `exit` before that
      // final fd-3 data; keying off `close` makes the ordering irrelevant.
      child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
        // If done/timeout/abort already decided the result, finish() is a no-op
        // and `decided` holds it — a SIGXCPU that arrives after a decision does
        // not override it. Otherwise the child closed before completing: a
        // SIGXCPU close is the kernel's own CPU meter firing — the RLIMIT_CPU
        // soft limit, or the bootstrap's post-settlement getrusage check
        // re-delivering SIGXCPU when a program trapped the soft limit and
        // returned inside the soft-to-hard gap. That kernel-authoritative
        // signal is the ONLY basis for the timeout classification: wall time
        // is not evidence of CPU burn (a sleeping child SIGKILLed by a cgroup
        // OOM killer, an operator, or itself consumed none), so every other
        // signal or code — including an unsolicited SIGKILL, even the
        // hard-limit one — reports as an opaque worker exit.
        //
        // The message names `cpuSeconds` as the CONFIGURED ceiling, not "the
        // budget that fired": the child clamps RLIMIT_CPU to the stricter of
        // `cpuSeconds` and any inherited soft limit, so under a tighter inherited
        // cap SIGXCPU arrives before `cpuSeconds` — the host cannot see the
        // effective value, so it states the ceiling it set rather than a second
        // count it cannot guarantee.
        finish(signal === 'SIGXCPU'
          ? { error: { kind: 'timeout', message: `CPU time exhausted (limit at most the configured ${this.config.cpuSeconds}s; a stricter inherited RLIMIT_CPU can fire sooner)` } }
          : { error: { kind: 'worker-exit', message: `python exited (code=${String(code)}, signal=${String(signal)}) before completing` } })
        settle(decided)
      })

      // Fd-3 and the stdout/stderr pipes emit `error` on early child death
      // (ECONNRESET/EPIPE); swallow them so they do not become uncaught. The
      // authoritative failure signal is `child.on('close')` above.
      const silenceStreamError = (): void => {}
      proto.on('error', silenceStreamError)
      child.stdout.on('error', silenceStreamError)
      child.stderr.on('error', silenceStreamError)

      /* jscpd:ignore-start -- wall-timer/abort/live-run wiring deliberately parallels ptc-runtime-node; see the constructor note. */
      const wallTimer = setTimeout(() => {
        finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
      }, this.config.maxWallMs)

      const onAbort = (): void => {
        finish({ error: { kind: 'abort', message: messageOf(request.signal?.reason) } })
      }
      request.signal?.addEventListener('abort', onAbort, { once: true })

      const live: LiveRun = {
        kill,
        finished,
        settle: (failure: PtcRunFailure) => { finish({ error: failure }) },
      }
      this.live.add(live)
      /* jscpd:ignore-end */

      // Send the boot frame once fd 3 is writable. This runs LAST in run()'s
      // synchronous setup: its failure path calls finish(), which reads
      // wallTimer/onAbort and (through settle) live, so those bindings must
      // already be initialized — issuing the write earlier hit their
      // temporal dead zone and threw a ReferenceError that rejected run()
      // instead of resolving the worker-exit it constructs here.
      const boot: BootMessage = {
        type: 'boot',
        cpuSeconds: this.config.cpuSeconds,
        addressSpaceBytes: this.config.addressSpaceMb * 1024 * 1024,
        maxLogBytes: this.config.maxLogBytes,
        maxValueBytes: this.config.maxValueBytes,
        namespaces: [...bindings].map(([global, namespace]) => ({
          global,
          names: Object.keys(namespace.functions),
          ...namespace.errorClass ? { errorClass: namespace.errorClass } : {},
        })),
      }
      // The run frame is sent only after the child's boot-ack: the seam
      // contract puts `run` after `boot-ack` (the ack confirms the namespaces
      // were accepted), and sending it earlier would let a boot failure race
      // the run frame. The ack handler below writes it.
      let runSent = false
      try {
        proto.write(`${JSON.stringify(boot)}\n`)
      } catch (error: unknown) {
        finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } })
        return
      }
      // Register the ack gate with the frame handler before any data arrives.
      bootAckGate.run = (): void => {
        if (runSent) return
        runSent = true
        try {
          proto.write(`${JSON.stringify({ type: 'run', program: request.program })}\n`)
        } catch (error: unknown) {
          /* v8 ignore next -- the child exited between its ack and this write; the run settles as worker-exit. */
          finish({ error: { kind: 'worker-exit', message: `failed to boot python subprocess: ${messageOf(error)}` } })
        }
      }
    })
  }
}

export default PythonPtcRuntime