process_monitor.pony

  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
use "backpressure"
use "collections"
use "files"
use "promises"
use @getpid[I32]() if linux

primitive StartProcess
  """
  Fork+exec (posix) or create (windows) a child process and return a live
  `ProcessMonitor` for it, or a `ProcessError` if the process could not be
  started. Every fallible step of starting the process happens here, before the
  monitor exists, so a returned `ProcessMonitor` is always monitoring a running
  child. Use it in place of a constructor:

  ```pony
  match StartProcess(sp_auth, bp_auth, consume notifier, path, args, vars)
  | let pm: ProcessMonitor => // a live child is running
  | let err: ProcessError  => // never started; err says why
  end
  ```

  On a `ProcessError` return, no process was started and none of the notifier's
  callbacks will fire. On Linux, starting a process requires kernel 5.3 or
  newer; on an older kernel, `StartProcess` returns an `UnsupportedKernel`
  error.
  """
  fun apply(
    auth: StartProcessAuth,
    backpressure_auth: ApplyReleaseBackpressureAuth,
    notifier: ProcessNotify iso,
    path: FilePath,
    args: Array[String] val,
    vars: Array[String] val,
    wdir: (FilePath | None) = None)
    : (ProcessMonitor | ProcessError)
  =>
    """
    Start a child process running the executable at `path`.
    """
    // We need permission to execute and the file itself needs to be an
    // executable.
    if not path.caps(FileExec) then
      return ProcessError(
        CapError,
        path.path +
          " is not an executable or we do not have execute capability.")
    end

    let is_file = try FileInfo(path)?.file else false end
    if not is_file then
      // Unable to stat the path given, so it may not exist or may be a
      // directory.
      return ProcessError(
        ExecutableNotFound,
        path.path + " does not exist or is a directory.")
    end

    // On Linux, exit detection uses a pidfd, which needs kernel >= 5.3. Probe
    // on our own pid: ENOSYS means the kernel is too old. Any other probe
    // failure (e.g. out of file descriptors) is left for the child's own
    // pidfd_open to surface as a start failure.
    ifdef linux then
      let probe = @ponyint_pidfd_open(@getpid())
      if probe < 0 then
        if @pony_os_errno() == _ENOSYS() then
          return ProcessError(
            UnsupportedKernel,
            "the kernel does not support pidfd_open (needs Linux >= 5.3).")
        end
      else
        @close(probe.u32())
      end
    end

    // Create the four pipes as iso so they can cross into the actor. On any
    // failure, close the ones already opened.
    let stdin_pipe: _Pipe iso =
      try recover iso _Pipe.outgoing()? end
      else
        return ProcessError(PipeError, "Failed to open pipe for stdin.")
      end
    let stdout_pipe: _Pipe iso =
      try recover iso _Pipe.incoming()? end
      else
        stdin_pipe.close()
        return ProcessError(PipeError, "Failed to open pipe for stdout.")
      end
    let stderr_pipe: _Pipe iso =
      try recover iso _Pipe.incoming()? end
      else
        stdin_pipe.close()
        stdout_pipe.close()
        return ProcessError(PipeError, "Failed to open pipe for stderr.")
      end
    let err_pipe: _Pipe iso =
      try recover iso _Pipe.incoming()? end
      else
        stdin_pipe.close()
        stdout_pipe.close()
        stderr_pipe.close()
        return ProcessError(PipeError, "Failed to open auxiliary error pipe.")
      end

    // Read the far-end fds off the iso pipes (reading a val field off an iso
    // neither aliases nor consumes it) so the child creation takes plain fds
    // and the pipes stay whole to hand to the monitor.
    let in_far = stdin_pipe.far_fd
    let out_far = stdout_pipe.far_fd
    let err2_far = stderr_pipe.far_fd
    let err_far = err_pipe.far_fd

    // Extract the executable path and working directory as sendable values so
    // they can be used inside the `recover` that builds the child.
    let exec_path: String val = path.path.clone()
    let wdir_str: (String val | None) =
      match \exhaustive\ wdir
      | let d: FilePath => d.path.clone()
      | None => None
      end

    ifdef posix then
      let child: _Process iso =
        try
          recover iso
            _ProcessPosix(
              exec_path,
              args,
              vars,
              wdir_str,
              err_far,
              in_far,
              out_far,
              err2_far)?
          end
        else
          stdin_pipe.close()
          stdout_pipe.close()
          stderr_pipe.close()
          err_pipe.close()
          return ProcessError(ForkError)
        end
      ProcessMonitor._create(
        backpressure_auth,
        consume notifier,
        consume child,
        consume stdin_pipe,
        consume stdout_pipe,
        consume stderr_pipe,
        consume err_pipe)
    elseif windows then
      let windows_child: _ProcessWindows iso =
        recover iso
          _ProcessWindows(
            exec_path,
            args,
            vars,
            wdir_str,
            in_far,
            out_far,
            err2_far)
        end
      // Reading the sendable `process_error` field off the iso neither aliases
      // nor consumes it.
      match windows_child.process_error
      | let pe: ProcessError =>
        stdin_pipe.close()
        stdout_pipe.close()
        stderr_pipe.close()
        err_pipe.close()
        return pe
      end
      ProcessMonitor._create(
        backpressure_auth,
        consume notifier,
        consume windows_child,
        consume stdin_pipe,
        consume stdout_pipe,
        consume stderr_pipe,
        consume err_pipe)
    else
      compile_error "unsupported platform"
    end

primitive _Running
primitive _Disposing
primitive _Reaped

type _Lifecycle is (_Running | _Disposing | _Reaped)
  """
  The monitor's lifecycle. A monitor exists only around a running child, so
  there is no "not started" state.

  - `_Running`: child alive, being monitored.
  - `_Disposing`: the client called `dispose()`; the child was killed and the
    pipes closed, and we are awaiting the exit event.
  - `_Reaped`: the child exited and we collected its status (or a reap error);
    absorbing and inert.
  """

actor ProcessMonitor is AsioEventNotify
  """
  Monitors a running child process: forwards its STDOUT/STDERR to a
  `ProcessNotify`, writes to its STDIN, and reports its exit status once it
  exits. Create one with `StartProcess`.

  Exit is detected from a native per-platform event (a pidfd on Linux), not
  from the child's pipes reaching end-of-file, so a child that leaves a
  grandchild holding its stdout/stderr open is still reported as exited
  promptly.
  """
  let _notifier: ProcessNotify
  let _backpressure_auth: ApplyReleaseBackpressureAuth
  let _child: _Process
  var _stdin: _Pipe
  var _stdout: _Pipe
  var _stderr: _Pipe
  var _err: _Pipe
  var _exit_event: AsioEventID = AsioEvent.none()
  let _max_size: USize = 4096
  // Per-pipe bound on the reap-edge drain. A fixed constant of at least one
  // default pipe capacity, so all of a well-behaved child's own output is
  // delivered, while a descendant flooding the pipe the instant the child dies
  // cannot stall teardown. Not a function of the pipe's buffer size, which the
  // child can grow (F_SETPIPE_SZ). This is a security bound.
  let _drain_cap: USize = 1 << 20
  var _read_buf: Array[U8] iso = recover Array[U8] .> undefined(_max_size) end
  var _read_len: USize = 0
  var _expect: USize = 0
  // Backstop for the exit-signal reap retry. Once the OS signals that the child
  // exited, the child is a zombie we own, so waitpid will collect it and the
  // retry converges. The window between the exit notification and waitpid
  // readiness varies by platform — on FreeBSD it can span tens of thousands of
  // actor behavior turns. The cap is set high enough to accommodate the widest
  // observed window with ample margin, while still bounding a genuine
  // non-convergence (a kernel that signals exit but never lets waitpid reap).
  let _reap_retry_cap: U32 = 10_000_000
  embed _pending: List[(ByteSeq, USize)] = _pending.create()
  var _done_writing: Bool = false
  var _backpressure_applied: Bool = false
  var _state: _Lifecycle = _Running

  new _create(
    backpressure_auth: ApplyReleaseBackpressureAuth,
    notifier: ProcessNotify iso,
    child: _Process iso,
    stdin: _Pipe iso,
    stdout: _Pipe iso,
    stderr: _Pipe iso,
    err: _Pipe iso)
  =>
    """
    Build a monitor around an already-running child and its pipes. Called by
    `StartProcess` (and by the in-package test factory). Not public: a monitor
    is only ever created around a live child.
    """
    _backpressure_auth = backpressure_auth
    _notifier = consume notifier
    _child = consume child
    _stdin = consume stdin
    _stdout = consume stdout
    _stderr = consume stderr
    _err = consume err

    // Register each pipe's asio event and close its far end.
    _stdin.begin(this)
    _stdout.begin(this)
    _stderr.begin(this)
    _err.begin(this)

    // Arm the native exit event.
    _exit_event = _child.arm_exit_event(this)

    _notifier.created(this)

    // The child may have exited before we armed the exit event; under
    // edge-triggered notification that readable edge can be missed, so probe
    // once now. A duplicate real event later lands in `_Reaped` and is a no-op.
    _reap_if_exited()

  be print(data: ByteSeq) =>
    """
    Print some bytes and append a newline.
    """
    if (_state is _Running) and (not _done_writing) then
      _write_final(data)
      _write_final("\n")
    end

  be write(data: ByteSeq) =>
    """
    Write to STDIN of the child process.
    """
    if (_state is _Running) and (not _done_writing) then
      _write_final(data)
    end

  be printv(data: ByteSeqIter) =>
    """
    Print an iterable collection of ByteSeqs.
    """
    if _state is _Running then
      for bytes in data.values() do
        _write_final(bytes)
        _write_final("\n")
      end
    end

  be writev(data: ByteSeqIter) =>
    """
    Write an iterable collection of ByteSeqs.
    """
    if _state is _Running then
      for bytes in data.values() do
        _write_final(bytes)
      end
    end

  be done_writing() =>
    """
    Signal that we are finished writing to STDIN. Once any pending writes have
    drained, STDIN is closed so a child waiting on EOF can proceed.
    """
    if _state is _Running then
      _done_writing = true
      if _pending.size() == 0 then
        _close_stdin()
      end
    end

  be dispose() =>
    """
    Terminate the child and stop monitoring. The exit status of the killed
    child is reported through `ProcessNotify.dispose` once its exit event
    fires.
    """
    match \exhaustive\ _state
    | _Running =>
      _state = _Disposing
      _child.kill()
      // Close the pipes (releasing any backpressure). The exit event stays
      // armed so the reap still fires and reports the status.
      _close_stdin()
      _stdout.close()
      _stderr.close()
      _err.close()
    | _Disposing => None // already disposing; do not kill again
    | _Reaped => None    // already reaped; never kill a reaped pid
    end

  fun ref expect(qty: USize = 0) =>
    """
    A `stdout` call on the notifier must contain exactly `qty` bytes. If
    `qty` is zero, the call can contain any amount of data.
    """
    _expect = _notifier.expect(this, qty)
    _read_buf_size()

  be _event_notify(event: AsioEventID, flags: U32, arg: U32) =>
    """
    Handle an asio event from one of the pipes or from the exit source.
    """
    if (_exit_event isnt AsioEvent.none()) and (event is _exit_event) then
      if AsioEvent.disposable(flags) then
        @pony_asio_event_destroy(_exit_event)
        _exit_event = AsioEvent.none()
      elseif AsioEvent.errored(flags) then
        _on_exit_event_error()
      else
        _reap_on_exit_signal(0)
      end
      return
    end

    match event
    | _stdin.event =>
      if AsioEvent.writeable(flags) then
        _pending_writes()
      elseif AsioEvent.errored(flags) then
        _close_stdin()
      elseif AsioEvent.disposable(flags) then
        _stdin.dispose()
      end
    | _stdout.event =>
      if AsioEvent.readable(flags) then
        _read_pipe(_stdout)
      elseif AsioEvent.errored(flags) then
        _stdout.close()
      elseif AsioEvent.disposable(flags) then
        _stdout.dispose()
      end
    | _stderr.event =>
      if AsioEvent.readable(flags) then
        _read_pipe(_stderr)
      elseif AsioEvent.errored(flags) then
        _stderr.close()
      elseif AsioEvent.disposable(flags) then
        _stderr.dispose()
      end
    | _err.event =>
      if AsioEvent.readable(flags) then
        _read_pipe(_err)
      elseif AsioEvent.errored(flags) then
        _err.close()
      elseif AsioEvent.disposable(flags) then
        _err.dispose()
      end
    end

  fun ref _reap_if_exited() =>
    """
    Proactive start-up reap: the child may have exited before the exit event was
    armed. Gated on state so it runs at most once. If the child has not exited,
    do nothing — the exit signal will arrive and drive the reap through
    `_reap_on_exit_signal`.
    """
    if _state is _Reaped then
      return
    end

    match \exhaustive\ _child.wait()
    | let status: ProcessExitStatus =>
      _do_reap(status)
    | WaitpidError =>
      _do_reap_error()
    | _StillRunning =>
      None // child still running; the exit signal will drive the reap
    end

  fun ref _reap_on_exit_signal(attempt: U32) =>
    """
    Reap after the OS signalled that the child exited (a real exit event, or the
    ESRCH-synthesized one on kqueue). Gated on state so it runs at most once.
    `waitpid` can briefly still report the child as running, because the OS exit
    notification arrives ahead of waitpid; on `_StillRunning` retry through a
    self-message, so the reap never blocks a scheduler thread, bounded by
    `_reap_retry_cap`.
    """
    if _state is _Reaped then
      return
    end

    match \exhaustive\ _child.wait()
    | let status: ProcessExitStatus =>
      _do_reap(status)
    | WaitpidError =>
      _do_reap_error()
    | _StillRunning =>
      if attempt < _reap_retry_cap then
        _reap_again(attempt + 1)
      else
        _do_reap_error()
      end
    end

  be _reap_again(attempt: U32) =>
    """
    One yielding hop of the exit-signal reap retry (see `_reap_on_exit_signal`).
    """
    _reap_on_exit_signal(attempt)

  fun ref _do_reap(status: ProcessExitStatus) =>
    """
    The reap edge. One behavior turn, no yield. On a natural exit (`_Running`),
    drain the child's last output before reporting; on a disposed exit
    (`_Disposing`), the client cancelled, so skip the drain.
    """
    let was_running = _state is _Running
    _state = _Reaped
    if was_running then
      _drain()
    end
    _close_all()
    _notifier.dispose(this, status)

  fun ref _do_reap_error() =>
    """
    The reap returned an error rather than a status. Report `failed`; there is
    no exit status to `dispose` with.
    """
    _state = _Reaped
    _close_all()
    _notifier.failed(this, ProcessError(WaitpidError))

  fun ref _on_exit_event_error() =>
    """
    The exit event's subscription errored, so we have lost exit detection. Try
    a last reap; otherwise report failure and stop, without killing the child —
    a monitor-side fault must not change the child's fate.
    """
    if _state is _Reaped then
      return
    end

    match _child.wait()
    | let status: ProcessExitStatus =>
      _do_reap(status)
    else
      _state = _Reaped
      _close_all()
      _notifier.failed(this, ProcessError(WaitpidError))
    end

  fun ref _drain() =>
    """
    Read whatever the child left in its pipes before it exited, so its last
    output is delivered before `dispose`. Draining `_err` is required: an
    exec/chdir failure writes one byte there, and we must report it before
    closing the pipe. Bounded per pipe by `_drain_cap`.
    """
    _read_from(_stdout, true)
    _read_from(_stderr, true)
    _read_from(_err, true)

  fun ref _close_all() =>
    """
    Close all four pipes (both ends) and the exit source. Idempotent.
    """
    _close_stdin()
    _stdout.close()
    _stderr.close()
    _err.close()
    _close_exit_source()

  fun ref _close_stdin() =>
    """
    Close STDIN, release any applied backpressure, and drop pending writes.
    Every running-state stdin close routes through here so backpressure can
    never be stranded.
    """
    _stdin.close()
    _release_backpressure()
    _pending.clear()

  fun ref _close_exit_source() =>
    if _exit_event isnt AsioEvent.none() then
      _child.close_exit_source(_exit_event)
      // Leave `_exit_event` set: it is destroyed on its `disposable` callback.
    end

  fun ref _apply_backpressure() =>
    if not _backpressure_applied then
      _backpressure_applied = true
      Backpressure.apply(_backpressure_auth)
    end

  fun ref _release_backpressure() =>
    if _backpressure_applied then
      _backpressure_applied = false
      Backpressure.release(_backpressure_auth)
    end

  fun ref _read_pipe(pipe: _Pipe) =>
    _read_from(pipe, false)

  fun ref _read_from(pipe: _Pipe, draining: Bool) =>
    """
    Read from a pipe while data is available. Running (`draining = false`):
    yield after 4 kb via a self-message to avoid starving other actors.
    Draining (`draining = true`): no yield, capped at `_drain_cap`, for the
    reap edge which must stay one atomic behavior turn.

    It is safe to use the same buffer for stdout, stderr, and err because of
    causal messaging: events are processed one after another.
    """
    if pipe.is_closed() then return end
    var sum: USize = 0
    while true do
      (_read_buf, let len, let errno) =
        pipe.read(_read_buf = recover Array[U8] end, _read_len)

      let next = _read_buf.space()
      match len
      | -1 =>
        if errno == _EAGAIN() then
          return // nothing to read right now, try again later
        end
        pipe.close()
        return
      | 0 =>
        pipe.close()
        return
      end

      _read_len = _read_len + len.usize()

      let data = _read_buf = recover Array[U8] .> undefined(next) end
      data.truncate(_read_len)

      match pipe.near_fd
      | _stdout.near_fd =>
        if _read_len >= _expect then
          _notifier.stdout(this, consume data)
        end
      | _stderr.near_fd =>
        _notifier.stderr(this, consume data)
      | _err.near_fd =>
        let step: U8 = try data.read_u8(0)? else -1 end
        match step
        | _StepChdir() =>
          _notifier.failed(this, ProcessError(ChdirError))
        | _StepExecve() =>
          _notifier.failed(this, ProcessError(ExecveError))
        else
          _notifier.failed(this, ProcessError(UnknownError))
        end
      end

      _read_len = 0
      _read_buf_size()

      sum = sum + len.usize()
      if draining then
        if sum >= _drain_cap then
          return
        end
      else
        if sum > (1 << 12) then
          // If we've read 4 kb, yield and read again later.
          _read_again(pipe.near_fd)
          return
        end
      end
    end

  fun ref _read_buf_size() =>
    if _expect > 0 then
      _read_buf.undefined(_expect)
    else
      _read_buf.undefined(_max_size)
    end

  be _read_again(near_fd: U32) =>
    """
    Resume reading on a file descriptor after a yield.
    """
    if _state is _Running then
      match near_fd
      | _stdout.near_fd => _read_pipe(_stdout)
      | _stderr.near_fd => _read_pipe(_stderr)
      | _err.near_fd => _read_pipe(_err)
      end
    end

  fun ref _write_final(data: ByteSeq) =>
    """
    Write as much as possible to STDIN if it is open and there are no pending
    writes. Queue everything unwritten and apply backpressure. If STDIN is
    already closed, drop the write rather than queue it — nothing would drain
    the queue or release the backpressure.
    """
    if _stdin.is_closed() then
      return
    end

    if _pending.size() == 0 then
      // Send as much data as possible.
      (let len, let errno) = _stdin.write(data, 0)

      if len == -1 then // write error
        if errno == _EAGAIN() then
          // Resource temporarily unavailable, send data later.
          _pending.push((data, 0))
          _apply_backpressure()
        else
          // Notify caller of error, close fd and done.
          _notifier.failed(this, ProcessError(WriteError))
          _close_stdin()
        end
      elseif len.usize() < data.size() then
        // Send any remaining data later.
        _pending.push((data, len.usize()))
        _apply_backpressure()
      end
    else
      // Send later, when the pipe is available for writing.
      _pending.push((data, 0))
      _apply_backpressure()
    end

  fun ref _pending_writes() =>
    """
    Send any pending data. If any data can't be sent, keep it in _pending.
    Once _pending is non-empty, direct writes get queued there, and they can
    only be written here. If _done_writing is set, close STDIN once the queue
    drains.
    """
    while (not _stdin.is_closed()) and (_pending.size() > 0) do
      try
        let node = _pending.head()?
        (let data, let offset) = node()?

        // Write as much data as possible.
        (let len, let errno) = _stdin.write(data, offset)

        if len == -1 then // OS signals write error
          if errno == _EAGAIN() then
            // Resource temporarily unavailable, send data later.
            return
          else
            // Close pipe and bail out.
            _notifier.failed(this, ProcessError(WriteError))
            _close_stdin()
            return
          end
        elseif (len.usize() + offset) < data.size() then
          // Send remaining data later.
          node()? = (data, offset + len.usize())
          return
        else
          // This pending chunk has been fully sent.
          _pending.shift()?
          if _pending.size() == 0 then
            _release_backpressure()
            // Close STDIN if the client is done writing.
            if _done_writing then
              _close_stdin()
            end
          end
        end
      else
        // handle error
        _notifier.failed(this, ProcessError(WriteError))
        return
      end
    end

  // --- test seam (package-private) ---------------------------------------
  // These exist so a package-private test factory can drive the state machine
  // with a spy `_Process` and placeholder pipes. They have no public surface.
  be _test_trigger_exit() =>
    """
    Simulate the native exit event firing. The spy `_Process.wait()` returns
    the chosen status, driving the reap through the same gated path the real
    event uses.
    """
    _reap_on_exit_signal(0)

  be _test_query_backpressure(p: Promise[Bool]) =>
    """
    Report whether backpressure is currently applied, so a test can assert it
    was released after a terminal edge.
    """
    p(_backpressure_applied)