Commitdc16d6fdRecorded31 Jul 2026Repositorysigil
fix(shell): avoid pipeline EPIPE guard
Changed
packages/sigil-lib/src/process.c | 25 +++++++++++++++++++++----
packages/sigil-shell/src/sigil/shell.sgl | 4 ++--
packages/sigil-stdlib/src/sigil/process.sgl | 7 +++++++
test/integration/test-cli-exit-contract.sh | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 85 insertions(+), 6 deletions(-)Diff
packages/sigil-lib/src/process.cmodified
@@ -1664,11 +1664,11 @@ static Value native_process_pipe_read(SigilVM *vm, int argc, Value *args)
1664
* returns the number of bytes actually written, which may be 0 (EAGAIN, 1665
* kernel buffer full) or a partial count — the caller awaits writability 1666
* and retries with the remainder. Raises an io-error if the child has−1667
* closed its stdin (EPIPE); SIGPIPE is globally ignored (vm.c) so this−1668
* surfaces as an error the writer-drain's guard swallows rather than a−1669
* signal that would abort the process.+1667
* closed its stdin (EPIPE); SIGPIPE is globally ignored (vm.c), so callers+1668
* may choose the public raising API or the internal false-returning variant. 1669
*/−1671
static Value native_process_pipe_write(SigilVM *vm, int argc, Value *args)+1670
static Value process_pipe_write_impl(SigilVM *vm, int argc, Value *args,+1671
bool epipe_returns_false) 1672
{ 1673
#if defined(__SIGIL_WASM__) || defined(_WIN32) 1674
(void)argc; (void)args;@@ -1705,6 +1705,9 @@ static Value native_process_pipe_write(SigilVM *vm, int argc, Value *args)
1705
if (err == EAGAIN || err == EWOULDBLOCK) { 1706
return sigil_fixnum(0); 1707
}+1708
if (err == EPIPE && epipe_returns_false) {+1709
return SIGIL_FALSE;+1710
} 1711
sigil__vm_error(vm, SIGIL_ERR_IO, "process-pipe-write: %s", strerror(err)); 1712
return SIGIL_UNDEFINED; 1713
}@@ -1712,6 +1715,18 @@ static Value native_process_pipe_write(SigilVM *vm, int argc, Value *args)
1715
#endif 1716
} 1717
+1718
static Value native_process_pipe_write(SigilVM *vm, int argc, Value *args)+1719
{+1720
return process_pipe_write_impl(vm, argc, args, false);+1721
}+1722
+1723
/* Internal exception-free EPIPE path for pipeline pumps. Other failures are+1724
* still programming/platform errors and remain loud. */+1725
static Value native_process_pipe_write_no_epipe(SigilVM *vm, int argc, Value *args)+1726
{+1727
return process_pipe_write_impl(vm, argc, args, true);+1728
}+1729
1730
/* 1731
* Native: process-pipe-close-stdin! process -> boolean 1732
*@@ -2660,6 +2675,8 @@ void sigil__init_sigil_process_module(SigilVM *vm)
2675
SIGIL_ARITY_RANGE(1, 2), "Read bytes from the child's stdout pipe"); 2676
REGISTER_AND_EXPORT("process-pipe-write", native_process_pipe_write, 2677
SIGIL_ARITY_EXACT(2), "Write bytes to the child's stdin pipe");+2678
REGISTER_AND_EXPORT("%process-pipe-write-or-closed", native_process_pipe_write_no_epipe,+2679
SIGIL_ARITY_EXACT(2), "Write bytes, returning false on a closed pipe"); 2680
REGISTER_AND_EXPORT("process-pipe-close-stdin!", native_process_pipe_close_stdin, 2681
SIGIL_ARITY_EXACT(1), "Close the child's stdin pipe (EOF)"); 2682
packages/sigil-shell/src/sigil/shell.sglmodified
@@ -578,8 +578,8 @@
578
(let ((pending (vector-ref buffers i))) 579
(when (> (bytevector-length pending) 0) 580
(let ((written−581
(guard (exn (else #f))−582
(process-pipe-write (vector-ref procs i) pending))))+581
(%process-pipe-write-or-closed+582
(vector-ref procs i) pending))) 583
(if written 584
(vector-set! buffers i (bytevector-copy pending written)) 585
(beginpackages/sigil-stdlib/src/sigil/process.sglmodified
@@ -65,6 +65,7 @@
65
process-stdin-fd 66
process-pipe-read 67
process-pipe-write+68
%process-pipe-write-or-closed 69
process-pipe-close-stdin! 70
71
;; Process predicates@@ -321,6 +322,12 @@
322
(%set-docstring! process-pipe-write) 323
(%set-spec! process-pipe-write '(process? any? -> integer?)) 324
+325
;;; Internal non-raising EPIPE variant used by `(sigil shell)`'s pump.+326
;;; Returns #f when the child has closed its stdin; otherwise has the+327
;;; same partial/non-blocking integer result as process-pipe-write.+328
(%set-docstring! %process-pipe-write-or-closed)+329
(%set-spec! %process-pipe-write-or-closed '(process? any? -> any?))+330
331
;;; Close the child's stdin pipe (idempotent), delivering EOF to the 332
;;; child. 333
(%set-docstring! process-pipe-close-stdin!)test/integration/test-cli-exit-contract.shmodified
@@ -407,6 +407,61 @@ expect_nonzero "sigil bundle on a missing .sgl exits non-zero" "$EP_STATUS" "$co
407
expect_true "bundle refusal names the missing file" \ 408
"$(stream_has "no-such-bundle-target-$NONCE" "$EP_ERR" && echo yes || echo no)" "$combined" 409
+410
# ---------------------------------------------------------------------------+411
# 0zz. A shell pipeline must not corrupt the enclosing exception handler.+412
#+413
# t-50e5 makes a normally-completing tail guard clobber the caller's handler+414
# in native builds. The pipeline pump once used such a guard to catch EPIPE,+415
# so a spawn-failed stage made the NEXT uncaught error disappear with exit 0.+416
# Exercise the trigger and four controls on this exact release artifact.+417
# ---------------------------------------------------------------------------+418
echo "[0zz] shell pipeline preserves the enclosing exception handler"+419
run_shell_raise_case() {+420
local name="$1" body="$2" extra_marker="${3:-}"+421
local script="$WORKDIR/f1-$name.sgl"+422
local stdout_file="$WORKDIR/f1-$name.stdout"+423
local stderr_file="$WORKDIR/f1-$name.stderr"+424
local status stdout stderr+425
+426
cat > "$script" <<EOF+427
(import (sigil shell) (sigil io))+428
$body+429
(println "SGLSH-F1-$name-REACHED")+430
(error "SGLSH-F1-$name-BOOM")+431
(println "SGLSH-F1-$name-CONTINUED")+432
EOF+433
timeout 30 "$SIGIL" "$script" >"$stdout_file" 2>"$stderr_file"; status=$?+434
stdout="$(cat "$stdout_file")"+435
stderr="$(cat "$stderr_file")"+436
+437
expect_nonzero "uncaught raise after $name" "$status" "$stdout$stderr"+438
expect_stream_contains "$name reached the deliberate raise" \+439
"SGLSH-F1-$name-REACHED" "$stdout"+440
expect_stream_contains "$name reported the uncaught error on stderr" \+441
"SGLSH-F1-$name-BOOM" "$stderr"+442
CHECKS=$((CHECKS + 1))+443
if printf '%s%s' "$stdout" "$stderr" | grep -qF -- "SGLSH-F1-$name-CONTINUED"; then+444
FAILURES=$((FAILURES + 1))+445
echo " FAIL $name continued after its uncaught error" >&2+446
else+447
echo " ok $name stopped at its uncaught error"+448
fi+449
if [[ -n "$extra_marker" ]]; then+450
expect_stream_contains "$name exercised the intended setup" "$extra_marker" "$stdout"+451
fi+452
}+453
+454
run_shell_raise_case "nothing" "(begin)"+455
run_shell_raise_case "ok-single" '(begin ($? true))'+456
run_shell_raise_case "spawn-failed-single" \+457
'(begin ($? /tmp/SGLSH-F1-no-such-single-command))'+458
run_shell_raise_case "ok-pipeline" \+459
'(begin ($? (pipe (printf "ok") (cat))))'+460
run_shell_raise_case "spawn-failed-pipeline" \+461
'(let ((result ($? (pipe (printf "input") (/tmp/SGLSH-F1-no-such-pipeline-command) (cat)))))+462
(when (could-not-run? result) (println "SGLSH-F1-PIPELINE-OBSERVED")))' \+463
"SGLSH-F1-PIPELINE-OBSERVED"+464
465
# NOT covered here, deliberately (report follow-up, do not assume coverage): 466
# `sigil channel|registry|project <unknown-sub>` still print help and exit 0 467
# on BOTH backends — that fall-through lives in the external sigil-args