Skip to content

Commit f5c8524

Browse files
authored
Carry a caller's redirection across a spawn, and let abort end the program (#17)
* Carry a caller's redirection across a spawn, and let abort end the program Three defects reported by a consumer as openkal-linux#13, and each of the three is a place where the specification already carried the atom and this library had no route to it. The specification does not change. WHERE A STARTED PROGRAM'S OUTPUT GOES. okm_spawn.c passed {0, 0, 0} and let a caller's file actions overwrite it. openkal spells inheritance as a handle of zero, and an implementation reads that as the descriptor the calling image holds --- which dup2 cannot change, because it rebinds this library's table and there is no operation that replaces one of a running program's own streams. So dup2(fd, 1) followed by posix_spawn, by system, or by fork and execve started a program writing to the stream this program had been started with, and the file the caller had redirected onto stayed empty. The three positions are now seeded from the descriptor table; the ones that were not redirected are still passed as zero, so a backend without KAL_PROCESS_PROP_STREAM_PASSING still starts every program that does not redirect. Three adjacent defects fell out of the same reading. A file action is now applied in the order it was added --- musl's add* prepend and this library followed next, so a caller's actions were applied in reverse. addopen is implemented, having been refused although kal_fs_open and kal_fs_stream express it exactly. addclose upon one of the three standard positions is refused rather than accepted and not performed, which used to hand a program the standard input its caller had just taken away. WHAT abort DOES. SYS_tkill and SYS_tgkill had no case, so musl's raise answered ENOSYS and abort --- which raises, uninstalls, raises again, and then reaches the line its own comment calls unreachable --- ended in a_crash(). On x86_64 that is hlt, which faults outside ring 0 and is delivered as SIGSEGV. Measured on the host kernel: a program whose entire body is hlt exits 139 with a core dumped. Every uncaught exception, assert, std::terminate and __stack_chk_fail over this port therefore reported a segmentation fault. A signal aimed at this program now performs its default action, and SIGABRT reaches kal_abort, which raises the signal on openkal-linux and ends with a distinguished status elsewhere. Three numbers are musl's own and are refused: pthread_impl.h reserves 32, 33 and 34, and pthread_cancel is pthread_kill(t, SIGCANCEL). ASKING AFTER A CHILD WITHOUT WAITING. waitpid discarded its options and blocked, which is the one thing WNOHANG exists to prevent. kal_timeout_wait_process has been in the specification since 0.8. AND ONE THING A CONSUMER COULD NOT SEE. The default arm of the dispatcher answered ENOSYS in silence; OPENKAL_MUSL_TRACE=enosys now names the operation, once per number, on the standard error stream, and only the operations that have no case rather than the ones that answer ENOSYS by decision. CRITERIA. examples/subprocess grows from 7 observations to 22 and no longer depends on a shell, so every row of the matrix runs the redirection criteria. Measured against port/src at 93c247f with the new probe: seven observations fail, one for each fix, and fifteen controls hold. Continuous integration states the half a program cannot state about itself --- that the redirected bytes did not arrive on the caller's own stream, and that the unredirected ones did. Two capability words are worth naming. kal_process_props is an object and not a function, so the weak reference is tested by taking its address; the twenty-five weak references this port already held are all functions, where the established spelling reads the address anyway. And a stream handle of zero is a valid handle on an implementation whose streams are its own descriptors, so a caller that redirects its output onto its own standard input asks for something kal_spawn_streams cannot express; that spawn is refused rather than answered wrongly, and the collision is reported upstream. * Ask after any child, bound the files a spawn opens, and run the program just built Three findings from reading the change back, and the third is a defect of the criteria rather than of the library. waitpid(-1, ..., WNOHANG) asked after the first recorded child rather than after any of them, so it reported "none has finished" while a later one had --- which is the reading a caller draining its children in a loop acts upon. The blocking form has no such choice to make. examples/subprocess starts a slow program FIRST, so that a library asking only about the earlier slot answers zero until the slow one finishes and then names the wrong program. A set of file actions may name one position more than once, and POSIX says the last such action decides; every file it opened is still open and still has to be released. The bound is stated as eight rather than derived from the three positions, and a sequence that exceeds it is refused rather than truncated. tools/run-probe.sh chose the program to run with `find target | head -1`. `target/` accumulates one directory per configuration --- the version is part of the fingerprint --- so after 0.5.0 became 0.6.0 the search answered with the program built before the change, and two newly added observations did not appear in the output of a run reporting `-- failures: 0 --`. The criteria had not failed; they had not run. It cannot bite in continuous integration, where a fresh checkout builds one configuration, and it bites on the machine where the change is being written. tools/one-artifact.sh asserts the count before anything is read, and the three steps that run a program from an accumulating tree call it.
1 parent 93c247f commit f5c8524

14 files changed

Lines changed: 1799 additions & 68 deletions

File tree

.agents/docs/2026-08-28-issue13-spawn-streams-abort-and-wait.md

Lines changed: 429 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/ci.yml

Lines changed: 151 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,32 @@ jobs:
5050
# interface in whole (clause 3), so "the probe was not run" and "the
5151
# probe was run and the refusal was the expected answer" are
5252
# different outcomes, and only the second is evidence.
53-
- { name: 'linux, gcc', os: ubuntu-24.04, toolchain: 'gcc@16.1.0', target: '', net: 'yes', fork: '--fork', shell: '--shell' }
54-
- { name: 'linux, llvm', os: ubuntu-24.04, toolchain: 'llvm@22.1.8', target: '', net: 'yes', fork: '--fork', shell: '--shell' }
55-
- { name: 'macos, llvm', os: macos-14, toolchain: 'llvm@20.1.7', target: '', net: 'yes', fork: '--fork', shell: '--shell' }
53+
#
54+
# ⭐⭐ `abort` NAMES WHAT EACH BACKEND'S `kal_abort` PRODUCES, AND IT IS
55+
# THREE DIFFERENT THINGS ON PURPOSE.
56+
#
57+
# Until 0.6.0 `abort` did not end the program at all: musl's `raise` is
58+
# `tkill`, this port had no case for it, and `abort` fell through to
59+
# musl's own `a_crash()` --- `hlt` on x86_64 --- so every uncaught
60+
# exception and every `assert` reported a segmentation fault. A row
61+
# asserting only "the end was not an ordinary one" would have passed
62+
# throughout that, because a fault is not an ordinary end either. So
63+
# each row asserts the PARTICULAR end its implementation produces:
64+
#
65+
# --abort-signal SIGABRT: openkal-linux raises it with tgkill
66+
# --abort-status exit 134: openkal-macos ends with a status no
67+
# program returns
68+
# --abort-terminated a termination rather than a return: Windows
69+
# fail-fasts, and the fault this replaces was
70+
# reported there as an exit
71+
- { name: 'linux, gcc', os: ubuntu-24.04, toolchain: 'gcc@16.1.0', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal' }
72+
- { name: 'linux, llvm', os: ubuntu-24.04, toolchain: 'llvm@22.1.8', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-signal' }
73+
- { name: 'macos, llvm', os: macos-14, toolchain: 'llvm@20.1.7', target: '', net: 'yes', fork: '--fork', shell: '--shell', abort: '--abort-status' }
5674
# ⚠️ openkal-windows declines `openkal.space`: this system has no
5775
# primitive that copies an address space and starts a context in the
5876
# copy, and inventing one would be the simulation clause 3.1
5977
# forbids. `--no-fork` asserts the refusal.
60-
- { name: 'windows, gcc', os: windows-2022, toolchain: 'gcc@16.1.0', target: 'x86_64-windows-gnu', net: 'yes', fork: '--no-fork', shell: '--no-shell' }
78+
- { name: 'windows, gcc', os: windows-2022, toolchain: 'gcc@16.1.0', target: 'x86_64-windows-gnu', net: 'yes', fork: '--no-fork', shell: '--no-shell', abort: '--abort-terminated' }
6179
defaults:
6280
run:
6381
shell: bash
@@ -265,6 +283,7 @@ jobs:
265283
for name in kal_net_connect kal_net_listen kal_net_accept kal_net_close \
266284
kal_datagram_open kal_datagram_recv_from \
267285
kal_timeout_read kal_timeout_write kal_timeout_accept \
286+
kal_timeout_wait_process \
268287
kal_space_start kal_process_channel; do
269288
if grep -qE "^ *U $name\$" syms.txt; then
270289
echo "::error::$name is an undefined STRONG reference; an interface a backend may decline has been made mandatory"
@@ -279,6 +298,29 @@ jobs:
279298
[ "$weakfail" = 0 ] || exit 1
280299
echo " ok every optional interface is referenced weakly, and kal_time_sleep is strong"
281300
301+
# ⚠️⚠️ AND ONE OF THEM IS AN OBJECT, WHICH IS NOT TESTED THE WAY THE
302+
# ELEVEN ABOVE ARE.
303+
#
304+
# Every name above is a function, so `if (kal_net_connect)` tests the
305+
# ADDRESS and is correct. `kal_process_props` is a capability word:
306+
# the same spelling would READ it, and where the definition is absent
307+
# the object sits at address zero --- so the test written to prevent a
308+
# null dereference would be one. okm_opt.h takes the address, once, and
309+
# every caller reaches the word through that.
310+
#
311+
# ⚠️ WHAT THIS STEP CAN AND CANNOT SEE. That the reference is weak, it
312+
# can. That the source takes the address rather than reading the value,
313+
# it cannot --- both forms leave a relocation. The rule is stated where
314+
# it is used and asserted here only against the failure a link can
315+
# produce: a strong reference, which would make a backend that declines
316+
# `openkal.process` fail to link every program.
317+
case "$(grep -E '^ *[wv] kal_process_props$' syms.txt | wc -l)" in
318+
0) echo "::error::kal_process_props is not a weak reference in these objects"
319+
grep kal_process_props syms.txt || echo " (it is referenced nowhere)"
320+
exit 1 ;;
321+
*) echo " weak object: kal_process_props" ;;
322+
esac
323+
282324
# ⭐⭐ ASKING WHETHER A STREAM IS A TERMINAL GETS THE RIGHT ANSWER.
283325
#
284326
# musl's `isatty' asks with TIOCGWINSZ; this port answered only TCGETS,
@@ -510,7 +552,109 @@ jobs:
510552
- name: The subprocess probe
511553
env:
512554
MCPP_TARGET: ${{ matrix.target }}
513-
run: bash tools/run-probe.sh examples/subprocess subprocess ${{ matrix.fork }} ${{ matrix.shell }}
555+
run: bash tools/run-probe.sh examples/subprocess subprocess ${{ matrix.fork }} ${{ matrix.shell }} ${{ matrix.abort }}
556+
557+
# ⭐⭐ WHERE A STARTED PROGRAM'S OUTPUT WENT, WHICH THE PROBE CANNOT ASK
558+
# ABOUT ITSELF.
559+
#
560+
# A caller that redirects descriptor 1 and then starts a program used to
561+
# get the program's bytes on the stream IT had been started with, and the
562+
# file it had redirected onto stayed empty (openkal-linux#13). The probe
563+
# asserts the file; it cannot assert the other half, because a program
564+
# cannot read its own output. This log is that output.
565+
#
566+
# ⚠️ BOTH DIRECTIONS, AND THE SECOND IS NOT DECORATION. Requiring only that
567+
# the redirected markers are absent would pass for a library that had
568+
# stopped letting a started program write anywhere at all; requiring only
569+
# that the inherited one is present would pass for the defect. The probe's
570+
# own messages are worded so that neither token can appear in one.
571+
- name: A started program's output went where the caller sent it
572+
working-directory: examples/subprocess
573+
run: |
574+
set -euo pipefail
575+
test -s run.log || { echo "::error::no run.log — nothing to read"; exit 1; }
576+
if grep -q 'redirected-bytes' run.log; then
577+
echo "::error::a started program's output arrived on the caller's own stream"
578+
grep -n 'redirected-bytes' run.log | sed 's/^/ /'
579+
exit 1
580+
fi
581+
grep -q 'inherited-child-bytes' run.log \
582+
|| { echo "::error::a started program that redirected nothing wrote nowhere; the check above proves nothing"
583+
exit 1; }
584+
echo " ok redirected output reached the file; inherited output reached the caller"
585+
586+
# ⭐ AN OPERATION THIS LIBRARY DOES NOT HAVE CAN SAY SO WHEN ASKED.
587+
#
588+
# The default arm of the dispatcher answers ENOSYS in silence, and the only
589+
# way a consumer could learn WHICH operation was missing was to read
590+
# port/src/okm_syscall.c. Two rounds of openkal-linux#13 went on that
591+
# question.
592+
#
593+
# ⚠️ THREE OBSERVATIONS, BECAUSE THE INTERESTING FAILURES ARE THE QUIET
594+
# ONES: a channel that reports nothing when asked, a channel that reports
595+
# when it was not asked, and a channel that reports the same thing once per
596+
# attempt so that a reader counting lines concludes it happened once.
597+
- name: An absent operation reports which one it was, and only when asked
598+
if: runner.os == 'Linux' && matrix.target == ''
599+
run: |
600+
set -euo pipefail
601+
d="$(mktemp -d)"; mkdir -p "$d/src"
602+
cat > "$d/mcpp.toml" <<TOML
603+
[package]
604+
name = "traceprobe"
605+
version = "0.1.0"
606+
607+
[dependencies]
608+
openkal-musl = { path = "$PWD" }
609+
610+
[targets.traceprobe]
611+
kind = "bin"
612+
main = "src/main.c"
613+
614+
[build]
615+
cxx_runtime = "host-coupled"
616+
TOML
617+
sed -i 's/^ //' "$d/mcpp.toml"
618+
# ⭐ `symlinkat` IS THE OPERATION AND THE PROBE PRINTS ITS OWN NUMBER.
619+
#
620+
# openkal has no atom that creates a link --- the manifest and README
621+
# both record that --- so it reaches the default arm rather than a case
622+
# with a decision of its own. The number is printed by the probe rather
623+
# than computed here, so the expectation comes from the same headers
624+
# the dispatcher was compiled with; a number worked out on the host
625+
# would be answering for the host's C library.
626+
printf '%s\n' \
627+
'#include <sys/syscall.h>' \
628+
'#include <fcntl.h>' \
629+
'#include <unistd.h>' \
630+
'#include <stdio.h>' \
631+
'int main(void) {' \
632+
' for (int i = 0; i < 100; i++) (void)!symlinkat("a", AT_FDCWD, "b");' \
633+
' printf("%ld\n", (long)SYS_symlinkat);' \
634+
' return 0;' \
635+
'}' > "$d/src/main.c"
636+
( cd "$d" && mcpp build --toolchain '${{ matrix.toolchain }}' )
637+
bin="$(find "$d/target" -name traceprobe -type f | head -1)"
638+
test -n "$bin" || { echo "::error::the probe did not build"; exit 1; }
639+
640+
want="$("$bin" 2> /dev/null)"
641+
[ -n "$want" ] || { echo "::error::the probe printed no operation number"; exit 1; }
642+
643+
quiet="$("$bin" 2>&1 > /dev/null | wc -l)"
644+
[ "$quiet" = 0 ] \
645+
|| { echo "::error::the channel reported $quiet line(s) without being asked"
646+
"$bin" 2>&1 > /dev/null | sed 's/^/ /'; exit 1; }
647+
648+
asked="$(OPENKAL_MUSL_TRACE=enosys "$bin" 2>&1 > /dev/null)"
649+
echo " what it reported: $asked"
650+
n="$(printf '%s\n' "$asked" | grep -c 'no operation for system call' || true)"
651+
[ "$n" = 1 ] \
652+
|| { echo "::error::expected exactly one report for one hundred attempts, got $n"
653+
exit 1; }
654+
printf '%s\n' "$asked" | grep -q "no operation for system call $want\$" \
655+
|| { echo "::error::the report does not name the operation the probe asked for ($want)"
656+
exit 1; }
657+
echo " ok silent unless asked, one line per operation, and it names the operation"
514658
515659
- name: The same program, built the ordinary way, as a control
516660
run: |
@@ -533,7 +677,7 @@ jobs:
533677
[ -n '${{ matrix.target }}' ] && extra='--target ${{ matrix.target }}'
534678
cd examples/cross-hello
535679
mcpp build $extra
536-
binary="$(find target -type f \( -name 'cross-hello' -o -name 'cross-hello.exe' \) | head -1)"
680+
binary="$(bash "$GITHUB_WORKSPACE/tools/one-artifact.sh" cross-hello)"
537681
"./$binary" 2>&1 | tee out.log
538682
grep -q -- '-- failures: 0 --' out.log
539683
@@ -545,7 +689,7 @@ jobs:
545689
extra=''
546690
[ -n '${{ matrix.target }}' ] && extra='--target ${{ matrix.target }}'
547691
mcpp build $extra
548-
binary="$(find target -type f \( -name 'wordcount' -o -name 'wordcount.exe' \) | head -1)"
692+
binary="$(bash "$GITHUB_WORKSPACE/tools/one-artifact.sh" wordcount)"
549693
WORDCOUNT_VERBOSE=1 "$binary" sample.txt 2>&1 | tee run.log
550694
551695
counts="$(sed -n 's/^lines \([0-9]*\) words \([0-9]*\) bytes \([0-9]*\)$/\1 \2 \3/p' run.log)"

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,7 @@ sample.txt
2626
run.log
2727
out.log
2828
crash.log
29+
30+
# What the object-level checks in continuous integration write beside the
31+
# sources they examine.
32+
syms.txt

0 commit comments

Comments
 (0)