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
(begin
packages/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