feat(session): add the 'process (pipes) session kind
session-open now accepts kind: 'process alongside 'pty. A process session drives a child over stdin/stdout PIPES (via (sigil process)'s new process-spawn-pipe + process-pipe-* natives) instead of a pty — for a control-protocol child like tmux -C that hard-fails on a tty.
The reader-pump/writer-drain/reap are parameterized over five kind-dispatch helpers (session-read-fd/write-fd/read/raw-write/close-io!); everything above them (credit window, data/exit events, teardown ordering) is kind-agnostic. Differences for 'process: exec gates it WITHOUT a pty grant (a pipe session allocates no pty, matching Resource-plane spawning); session-resize is a no-op (no window); a pipe child is not a session leader so signals target the pid, not the process group.
Adds a process-session test group: stream+reap on exec-only grant, denial without exec, bidirectional cat round-trip, resize no-op, and session-close teardown of a long-running child.
src/sigil/system/session.sgl | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
test/test-system.sgl | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 177 insertions(+), 30 deletions(-)src/sigil/system/session.sglmodified
;;; The session's id (a monotonically increasing integer). (define (session-id s) (: any? -> integer?) (vector-ref s 1)) ;;; The session's kind (currently always `pty`). ;;; The session's kind: `'pty` (a pseudo-terminal) or `'process` (a ;;; pipe-based session). (define (session-kind s) (: any? -> symbol?) (vector-ref s 2)) (define (session-proc s) (vector-ref s 3)) ;;; ;;; `spec` is a dict: ;;; ;;; - `kind:` — `'pty` (a pseudo-terminal session; the only kind in ;;; this release. A pipe-based `'process` kind follows.) ;;; - `kind:` — `'pty` (a pseudo-terminal session) or `'process` (a ;;; pipe-based session: stdin/stdout wired to non-blocking pipes, no ;;; tty). Use `'process` for a child that speaks a line/byte protocol ;;; and hard-fails on a pty, e.g. `tmux -C`. ;;; - `argv:` — non-empty command list, e.g. `'("bash" "-l")` ;;; - `cwd:` — child working directory (optional) ;;; - `cols:` / `rows:` — initial terminal size (default 80x24) ;;; - `term:` — TERM value (default "xterm-256color") ;;; - `cols:` / `rows:` — initial terminal size (default 80x24; ;;; ignored by the `'process` kind, which has no window) ;;; - `term:` — TERM value (default "xterm-256color"; `'pty` only) ;;; - `credit:` — initial flow-control window in bytes ;;; ;;; Grant checks: `pty` must be granted, and `exec` must allow ;;; `(car argv)`. Must be called inside `with-async`. ;;; Grant checks: `exec` must allow `(car argv)`; the `'pty` kind also ;;; requires the `pty` grant (a pipe session allocates no pty, so it ;;; needs only `exec`, matching Resource-plane process spawning). Must ;;; be called inside `with-async`. (define (session-open g spec) (: any? dict? -> any?) (unless (in-async-context?) (error "session-open: requires an async context (with-async)")) (let ((kind (dict-ref spec kind:)) (argv (dict-ref spec argv:))) (unless (eq? kind 'pty) (unless (or (eq? kind 'pty) (eq? kind 'process)) (error "session-open: unsupported session kind" kind)) (unless (and (pair? argv) (string? (car argv))) (error "session-open: argv must be a non-empty list of strings" argv)) (grant-assert! g 'pty #t) (when (eq? kind 'pty) (grant-assert! g 'pty #t)) (grant-assert! g 'exec (car argv)) (when (dict-contains? spec cwd:) (grant-assert! g 'fs-read (dict-ref spec cwd:))) (credit (if (dict-contains? spec credit:) (dict-ref spec credit:) *default-credit-window*)) (proc (apply process-spawn-pty (car argv) cols: cols rows: rows cwd: cwd term: term die-with-parent: #t (cdr argv)))) (proc (if (eq? kind 'process) (apply process-spawn-pipe (car argv) cwd: cwd die-with-parent: #t (cdr argv)) (apply process-spawn-pty (car argv) cols: cols rows: rows cwd: cwd term: term die-with-parent: #t (cdr argv))))) (unless (process? proc) (error "session-open: failed to spawn" argv)) (set! *next-session-id* (+ *next-session-id* 1)) (go (writer-drain s)) s)))) ;; ============================================================ ;; Kind-dispatched I/O ;; ============================================================ ;; The pty and process kinds bottom out in different primitives: a pty ;; multiplexes read AND write on one non-blocking master fd, while a ;; process (pipes) session has SEPARATE non-blocking stdout (read) and ;; stdin (write) fds. Everything above these five helpers is kind- ;; agnostic. (define (session-process? s) (eq? (session-kind s) 'process)) ;; The fd to await for READABILITY before reading child output. (define (session-read-fd s) (if (session-process? s) (process-stdout-fd (session-proc s)) (process-pty-fd (session-proc s)))) ;; The fd to await for WRITABILITY before writing child input. (define (session-write-fd s) (if (session-process? s) (process-stdin-fd (session-proc s)) (process-pty-fd (session-proc s)))) ;; Non-blocking read of up to n bytes of child output (empty bv on ;; EAGAIN, eof-object at end of stream). (define (session-read s n) (if (session-process? s) (process-pipe-read (session-proc s) n) (process-pty-read (session-proc s) n))) ;; Non-blocking write of child input; returns bytes written (may be 0 ;; or partial). (define (session-raw-write s data) (if (session-process? s) (process-pipe-write (session-proc s) data) (process-pty-write (session-proc s) data))) ;; Release the write-side fd on teardown: for a pty this closes the ;; master (both directions); for a process it closes the child's stdin, ;; delivering EOF. (The process stdout read fd is released when the ;; process object is finalized.) (define (session-close-io! s) (if (session-process? s) (process-pipe-close-stdin! (session-proc s)) (process-pty-close! (session-proc s)))) ;; ============================================================ ;; The reader pump ;; ============================================================ ;; Read child output from the pty master and deliver data events, ;; respecting the credit window; on EOF, reap and deliver exit. ;; Read child output and deliver data events, respecting the credit ;; window; on EOF, reap and deliver exit. (define (reader-pump s) (let ((proc (session-proc s)) (events (session-events s))) (let ((fd (process-pty-fd proc))) (let ((fd (session-read-fd s))) (let loop () (cond ;; Window exhausted: park until session-credit wakes us. (loop)) (else (await-readable-fd fd) (let ((chunk (process-pty-read proc (let ((chunk (session-read s (min *read-chunk-size* (session-window s))))) (cond ((eof-object? chunk) bytes: chunk)) (loop)))))))))) ;; EOF on the master: reap the child, deliver the exit event, close ;; EOF on the read side: reap the child, deliver the exit event, close ;; the channels. Ordering is load-bearing for teardown safety: ;; reap-child! sets exit-status (and only returns once the child is ;; dead, so the master is writable) BEFORE we close the out-queue and ;; the master. That guarantees we never close the pty master out from ;; under a writer-drain that is still about to await it (a select on a ;; just-closed fd never reports ready and would wedge the goroutine). ;; dead, so the write fd is writable/error-ready) BEFORE we close the ;; out-queue and the write fd. That guarantees we never close the fd out ;; from under a writer-drain that is still about to await it (a select ;; on a just-closed fd never reports ready and would wedge the ;; goroutine). The same invariant holds for both kinds: a pty master ;; with a dead child is writable, and a pipe stdin with a dead child is ;; error-writable (EPIPE), so a parked writer wakes in either case. (define (reap-session s) (let ((proc (session-proc s))) (reap-child! s proc) ;; Release the writer: closing the out-queue wakes a writer parked ;; on the queue receive; exit-status (already set) stops one ;; parked mid-write. Only then close the master. ;; parked mid-write. Only then close the write side. (channel-close! (session-out-queue s)) (process-pty-close! proc) (session-close-io! s) (channel-send (session-events s) (dict type: 'exit session: (session-id s) ;; only reached when the session has NOT exited (checked with no yield ;; point in between), so we never park on a closed fd. (define (writer-drain s) (let ((fd (process-pty-fd (session-proc s)))) (let ((fd (session-write-fd s))) (for-channel (data (session-out-queue s)) (let wloop ((data data)) (unless (session-exit-status s) (define (guarded-pty-write s data) (guard (e (#t 'failed)) (process-pty-write (session-proc s) data))) (session-raw-write s data))) ;; ============================================================ ;; Driving (channel-send (session-out-queue s) data))) ;;; Resize the session's terminal. The kernel delivers SIGWINCH to ;;; the child's foreground process group. ;;; the child's foreground process group. A no-op for the `'process` ;;; kind, which has no pty window. (define (session-resize s cols rows) (: any? integer? integer? -> void?) (unless (session-exit-status s) (unless (or (session-exit-status s) (session-process? s)) (process-pty-resize! (session-proc s) cols rows))) ;;; Send a signal to the session's process group. Accepts thetest/test-system.sglmodified
(let ((status (session-close s))) (assert-true (not (eq? status #f))) (assert-false (session-alive? s))))))));; ============================================================;; Process (pipes) session plane — the P0b `'process` kind, the same;; Session-plane contract carried over stdin/stdout pipes instead of a;; pty (for `tmux -C` and other control-protocol children that hard-fail;; on a tty). No `pty` grant: a pipe session allocates no pty, so `exec`;; alone gates it, matching Resource-plane process spawning.;; ============================================================(test-group "process session" (test "open, stream stdout, reap — no pty grant needed" (let ((g (make-grants))) (grant-add! g "exec:allowlist:sh") ; deliberately NO pty:on (with-async (let ((s (session-open g (dict kind: 'process argv: '("sh" "-c" "echo process-out")))) (acc '()) (exit-code #f)) (assert-true (session? s)) (assert-equal (session-kind s) 'process) (for-channel (ev (session-events s)) (case (dict-ref ev type:) ((data) (set! acc (cons (utf8->string (dict-ref ev bytes:)) acc)) (session-credit s (bytevector-length (dict-ref ev bytes:)))) ((exit) (set! exit-code (dict-ref ev code:))))) (assert-equal exit-code 0) (let ((text (apply string-append (reverse acc)))) (assert-true (string-contains? text "process-out"))))))) (test "the process kind is denied without an exec grant" (let ((g (make-grants))) ;; No exec grant at all: even with pty:on, exec gates the spawn. (grant-add! g "pty:on") (with-async (assert-error (session-open g (dict kind: 'process argv: '("sh" "-c" "true"))))))) (test "write to stdin round-trips through cat (bidirectional pipes)" (let ((g (make-grants))) (grant-add! g "exec:allowlist:cat") (with-async (let ((s (session-open g (dict kind: 'process argv: '("cat")))) (acc '())) ;; cat echoes stdin -> stdout; close stdin to end the stream. (go (begin (sleep 0.2) (session-write s "round-trip-ok\n") (sleep 0.3) (session-close s))) (for-channel (ev (session-events s)) (when (eq? (dict-ref ev type:) 'data) (set! acc (cons (utf8->string (dict-ref ev bytes:)) acc)) (session-credit s (bytevector-length (dict-ref ev bytes:))))) (let ((text (apply string-append (reverse acc)))) (assert-true (string-contains? text "round-trip-ok"))))))) (test "resize is a harmless no-op for a pipe session" (let ((g (make-grants))) (grant-add! g "exec:allowlist:cat") (with-async (let ((s (session-open g (dict kind: 'process argv: '("cat"))))) ;; No pty window exists; resize must neither error nor affect I/O. (session-resize s 132 50) (go (for-channel (ev (session-events s)) #t)) (sleep 0.1) (let ((status (session-close s))) (assert-true (not (eq? status #f))) (assert-false (session-alive? s))))))) (test "session-close terminates a long-running pipe child" (let ((g (make-grants))) (grant-add! g "exec:allowlist:sh") (with-async (let ((s (session-open g (dict kind: 'process argv: '("sh" "-c" "sleep 30"))))) (go (for-channel (ev (session-events s)) #t)) (sleep 0.2) (let ((status (session-close s))) (assert-true (not (eq? status #f))) (assert-false (session-alive? s))))))))