From 0a81a169f226e5673c1a7b4ef3d6b1bc74ad1e85 Mon Sep 17 00:00:00 2001 From: mweidle73 Date: Sat, 17 Oct 2026 07:20:25 +0200 Subject: [PATCH 1/4] feat(protocol): Add shared output streams Introduce fixed protocol 2 with an explicit stderr alias of stdout. Open the target once and duplicate fd 1 without changing process ownership, privilege restrictions or the shell request payload. Pin merged frames independently and exercise descriptor offsets, ordered bytes, truncation, timeout and concurrent request isolation. A deliberate second-open mutation fails the direct-core oracle. Update current and historical protocol documentation and Pages gates. The Ada and direct C suites, coverage and documentation pass. --- .github/ci/README.md | 5 +- .github/workflows/pages.yml | 6 + AGENTS.md | 4 +- CHANGELOG.md | 4 + Makefile | 2 +- README.md | 17 +-- WORK_QUEUE.md | 5 +- doc/Makefile | 9 +- doc/protocol-v1.md | 6 +- doc/protocol-v2.md | 178 ++++++++++++++++++++++++++++++ src/spawn-posix.c | 16 ++- src/spawn-posix.h | 5 +- src/spawn-protocol.adb | 21 +++- src/spawn-protocol.ads | 14 ++- tests/protocol-failure-manager.c | 4 +- tests/spawn-pool-tests.adb | 175 +++++++++++++++++++++++++++++ tests/spawn-pool-tests.ads | 3 + tests/spawn-posix-tests.c | 76 +++++++++++++ tests/spawn-protocol-tests.adb | 111 +++++++++++++++---- tests/spawn-protocol-tests.ads | 13 ++- tests/spawn-transport-tests.adb | 2 +- tests/spawn_manager_tests.adb | 6 +- tools/spawn_manager_processes.adb | 8 +- tools/spawn_manager_processes.ads | 4 +- 24 files changed, 622 insertions(+), 72 deletions(-) create mode 100644 doc/protocol-v2.md diff --git a/.github/ci/README.md b/.github/ci/README.md index 71b045e..59445b6 100644 --- a/.github/ci/README.md +++ b/.github/ci/README.md @@ -45,8 +45,9 @@ Build the documentation with the same image: ' ``` -The generated HTML landing page, changelog, work queue and version 1 protocol -and lifecycle contracts are written below `doc/html/`. +The generated HTML landing page, changelog, work queue, historical protocol 1, +current protocol 2 and unchanged lifecycle contract are written below +`doc/html/`. Set `SPAWN_CI_IMAGE` to override the local image name, `DOCKER_PLATFORM` to override the default `linux/amd64` platform, and diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d617267..09a01d1 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -51,6 +51,7 @@ jobs: test -s doc/html/CHANGELOG.html test -s doc/html/WORK_QUEUE.html test -s doc/html/protocol-v1.html + test -s doc/html/protocol-v2.html test -s doc/html/lifecycle-v1.html test -s doc/html/styles.css grep -Fq 'Ada Spawn Manager' doc/html/index.html @@ -71,6 +72,10 @@ jobs: doc/html/WORK_QUEUE.html grep -Fq '

' \ doc/html/protocol-v1.html + grep -Fq '

' \ + doc/html/protocol-v2.html + grep -Fq "href=\"$source_base_url/src/spawn-protocol.ads\"" \ + doc/html/protocol-v2.html grep -Fq '

' \ doc/html/lifecycle-v1.html unexpected_links=$( @@ -84,6 +89,7 @@ jobs: -e '^CHANGELOG\.html($|#)' \ -e '^WORK_QUEUE\.html($|#)' \ -e '^protocol-v1\.html($|#)' \ + -e '^protocol-v2\.html($|#)' \ -e '^lifecycle-v1\.html($|#)' \ -e '^styles\.css$' || true diff --git a/AGENTS.md b/AGENTS.md index f3d83a2..b5ac427 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ caller and the dedicated process manager. build, test and installation commands. - Read `doc/protocol-v1.md` when changing request or result fields, validation, transport bounds or stream handling. +- Read `doc/protocol-v2.md` for the current fixed version and stderr alias; + version 1 is the historical 0.2.0 contract, not a compatibility mode. - Read `doc/lifecycle-v1.md` when changing processes, descriptors, signals, timeouts, cancellation, cgroup callbacks or pool cleanup. - Update `CHANGELOG.md` for user- or integrator-visible changes. Keep planned @@ -112,7 +114,7 @@ caller and the dedicated process manager. - The command-string API retains `/bin/bash -o pipefail -c` behavior and the manager environment. Structured requests retain exact executable, argument, replacement-environment, working-directory, stream and result semantics. -- Protocol version 1 is fixed and fail-closed. Wire changes require an explicit +- Each protocol version is fixed and fail-closed. Wire changes require an explicit new version, updated format documentation and independent golden-byte tests. - The post-`fork` child enters no Ada runtime code before `execve` or `_exit`. Keep the C boundary small, readable and limited to async-signal-safe child diff --git a/CHANGELOG.md b/CHANGELOG.md index cb42049..c4d76f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Gitlink even when the two Spawn Manager commits are not direct ancestors. ### Added +- Added fixed protocol 2 and an explicit stderr-to-stdout stream policy. Stdout + is opened once and its descriptor duplicated for stderr. Independent streams, + shell behavior, privilege restrictions and supervision remain unchanged; + old protocol versions are rejected rather than negotiated. - Added public contribution guidance and a private vulnerability-reporting security policy for the canonical upstream. - Added lightweight issue and pull-request templates for public contributions. diff --git a/Makefile b/Makefile index c10bff9..601f4c4 100644 --- a/Makefile +++ b/Makefile @@ -59,7 +59,7 @@ tests: $(POSIX_TEST) $(PROTOCOL_FAILURE_MANAGER) spawn_tests spawn_manager @$(OBJDIR)/spawn_manager 47 $(OBJDIR)/spawn_manager_min_spawn & @$(OBJDIR)/test_runner @tests/check_signal_shutdown.sh "$(CURDIR)" - @python3 tests/check_signal_reaping.py "$(abspath $(OBJDIR))/spawn_manager" 1 + @python3 tests/check_signal_reaping.py "$(abspath $(OBJDIR))/spawn_manager" 2 @tests/check_adaflags.sh "$(CURDIR)" @tests/check_install.sh "$(CURDIR)" diff --git a/README.md b/README.md index abecd7b..94b27a1 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,23 @@ The project provides two request styles: - a compatible command-string API that executes `/bin/bash -o pipefail -c COMMAND`; and - a structured API with an explicit executable, ordered argument vector, - complete replacement environment, working directory, independent standard - output and error destinations, and timeout. + complete replacement environment, working directory, independent or + explicitly shared output destinations, and timeout. Both styles use the same bounded, versioned protocol and the same supervised execution core. There is no intermediate `spawn_wrapper` process. ## Status and platform -This revision defines release **0.2.0**. It introduces structured execution -while retaining the existing shell API. Its contents are collected in the +The latest stable release is **0.2.0**. This development revision adds protocol +2 with shared output and requires its matching library and manager. It retains +the existing shell API. Changes are collected in the [changelog](CHANGELOG.md); accepted later work is tracked in the [work queue](WORK_QUEUE.md). The current execution core is Linux-specific. It relies on Unix-domain sockets, process groups, `prctl(2)`, `/proc` and POSIX process and signal -operations. Version 1 sets `no_new_privs` for every child, so shell and +operations. Both versions set `no_new_privs` for every child, so shell and structured requests cannot gain privileges through set-user-ID, set-group-ID or file-capability metadata during `execve`. @@ -59,7 +60,7 @@ instead of making that manager available to another caller. For the exact contracts, see: -- [Protocol version 1](doc/protocol-v1.md) for the shell and structured wire +- [Protocol version 2](doc/protocol-v2.md) for the shell and structured wire formats, bounds and result encoding. - [Lifecycle version 1](doc/lifecycle-v1.md) for process groups, signals, timeouts, reaping, `no_new_privs`, cgroup callbacks and failure handling. @@ -73,7 +74,7 @@ connections, switches descriptors to nonblocking mode and closes them. Spawn Manager also uses Anet's operating-system constants and filesystem utilities. Frame serialization and exact frame transfer deliberately belong to Spawn -Manager. Version 1 does not use `Anet.Streams`, Ada record streaming or the +Manager. Neither version uses `Anet.Streams`, Ada record streaming or the generic `Anet.Sockets.Send` and `Receive` operations for protocol data. Those socket operations each represent one system call, whereas this protocol must resume partial reads and writes, distinguish first-byte and completion @@ -84,7 +85,7 @@ rules with `poll(2)`, `recv(2)` and `send(2)`. This boundary is intentional rather than a replacement for Anet. A future Anet primitive offering the same exact, deadline-bounded nonblocking transfer -semantics could replace the generic I/O part without changing the version 1 +semantics could replace the generic I/O part without changing the current wire format. Frame layout, validation and protocol failure classification would remain Spawn Manager responsibilities. diff --git a/WORK_QUEUE.md b/WORK_QUEUE.md index 1386880..4b0ec57 100644 --- a/WORK_QUEUE.md +++ b/WORK_QUEUE.md @@ -37,8 +37,9 @@ both at one pathname would open and truncate it twice and would not preserve the historical ordered combined build log, so component `make` remains an intentional shell caller. -Define and test one shared open-file-description stream policy, either as an -explicitly compatible V1 completion before release or as protocol V2. Preserve +V2 development implements `Merge_Stdout` for stderr. The released version-1 +wire contract remains unchanged. +Deliver the dependency before the Make caller step. Preserve ordered stdout/stderr, cgroup placement, complete argv logging and Ctrl-C cleanup, then migrate `make -s -jN -C PATH` to structured execution. diff --git a/doc/Makefile b/doc/Makefile index 9d2999c..cb34895 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -15,6 +15,7 @@ HTML_DOCUMENTS = \ $(DESTDIR)/CHANGELOG.html \ $(DESTDIR)/WORK_QUEUE.html \ $(DESTDIR)/protocol-v1.html \ + $(DESTDIR)/protocol-v2.html \ $(DESTDIR)/lifecycle-v1.html \ $(DESTDIR)/styles.css @@ -30,6 +31,7 @@ $(DESTDIR)/index.html: ../README.md Makefile | $(DESTDIR) -e 's|href="CHANGELOG.md"|href="CHANGELOG.html"|g' \ -e 's|href="WORK_QUEUE.md"|href="WORK_QUEUE.html"|g' \ -e 's|href="doc/protocol-v1.md"|href="protocol-v1.html"|g' \ + -e 's|href="doc/protocol-v2.md"|href="protocol-v2.html"|g' \ -e 's|href="doc/lifecycle-v1.md"|href="lifecycle-v1.html"|g' \ -e 's|href="src/spawn-pool.ads"|href="$(SOURCE_BASE_URL)/src/spawn-pool.ads"|g' \ -e 's|href="AUTHORS"|href="$(SOURCE_BASE_URL)/AUTHORS"|g' \ @@ -45,12 +47,15 @@ $(DESTDIR)/WORK_QUEUE.html: ../WORK_QUEUE.md Makefile | $(DESTDIR) $(PANDOC) $(PANDOC_OPTS) --metadata pagetitle="Spawn Manager work queue" \ -o $@ $< -$(DESTDIR)/protocol-v1.html: protocol-v1.md Makefile | $(DESTDIR) - $(PANDOC) $(PANDOC_OPTS) --metadata pagetitle="Protocol version 1" \ +$(DESTDIR)/protocol-%.html: protocol-%.md Makefile | $(DESTDIR) + $(PANDOC) $(PANDOC_OPTS) --metadata pagetitle="Protocol $*" \ -o $@.tmp $< sed \ -e 's|href="../src/spawn-protocol.ads"|href="$(SOURCE_BASE_URL)/src/spawn-protocol.ads"|g' \ -e 's|href="../README.md#anet-boundary"|href="index.html#anet-boundary"|g' \ + -e 's|href="protocol-v1.md|href="protocol-v1.html|g' \ + -e 's|href="protocol-v2.md|href="protocol-v2.html|g' \ + -e 's|href="lifecycle-v1.md|href="lifecycle-v1.html|g' \ $@.tmp > $@ rm -f $@.tmp diff --git a/doc/protocol-v1.md b/doc/protocol-v1.md index 4c4b1cb..53b9714 100644 --- a/doc/protocol-v1.md +++ b/doc/protocol-v1.md @@ -1,5 +1,8 @@ # Spawn Manager protocol version 1 +This is the historical release-0.2.0 contract. Current development uses +[protocol version 2](protocol-v2.md), not a dual-version compatibility mode. + This document defines the byte-level protocol shared by the Spawn Manager library and executable. Version 1 has no handshake, capability negotiation, request identifiers or optional flags. A library and manager from one Abuild @@ -12,7 +15,8 @@ rather than Ada characters. Strings retain their exact non-NUL bytes; the protocol applies no text encoding, shell quoting or normalization. The normative constants and field order are maintained in -[`Spawn.Protocol`](../src/spawn-protocol.ads). Independent golden-byte tests +[`Spawn.Protocol` at v0.2.0](https://github.com/abuild-project/spawn-manager/blob/v0.2.0/src/spawn-protocol.ads). +Independent golden-byte tests pin both request variants, all result variants and every failure-stage value. ## Common frame diff --git a/doc/protocol-v2.md b/doc/protocol-v2.md new file mode 100644 index 0000000..c463985 --- /dev/null +++ b/doc/protocol-v2.md @@ -0,0 +1,178 @@ +# Spawn Manager protocol version 2 + +This document defines the byte-level protocol shared by the Spawn Manager +library and executable. Version 2 has no handshake, capability negotiation, +request identifiers or optional flags. A library and manager from one Abuild +bundle use the same fixed version; a mismatch is rejected. + +The released [version-1 format](protocol-v1.md) remains historical. Version 2 +adds only explicit shared output; field order, bounds and lifecycle remain. + +All offsets are zero-based byte offsets. Every integer is big-endian. Integers +are unsigned unless explicitly marked `i64`; that field is signed +two's-complement and uses the same big-endian byte order. Lengths count bytes +rather than Ada characters. Strings retain their exact non-NUL bytes; the +protocol applies no text encoding, shell quoting or normalization. + +The normative constants and field order are maintained in +[`Spawn.Protocol`](../src/spawn-protocol.ads). Independent golden-byte tests +pin both request variants, all result variants and every failure-stage value. + +## Common frame + +Every request and response is one exact frame: + +| Offset | Size | Field | +| ---: | ---: | --- | +| 0 | 4 | ASCII magic `SPWN` | +| 4 | 2 | protocol version, fixed value `2` | +| 6 | 2 | message kind: `1` shell, `2` exec, `3` result | +| 8 | 4 | payload length, excluding this 12-byte header | + +The fixed maximum frame size is 128 KiB. The pool selects one active bound for +requests and responses when it starts the manager. That bound must be at least +30 bytes, the smallest legal shell-request frame, and cannot exceed the fixed +maximum. Unknown versions or message kinds, inconsistent lengths, invalid +field values and trailing bytes are rejected. + +Reusable encodings are: + +```text +string := u32 byte_length, u8[byte_length] +vector := u32 element_count, element[element_count] +timeout := i64 two's-complement milliseconds +``` + +`-1` is the only negative timeout and means unlimited. Strings are limited to +64 KiB, vectors to 1,024 elements and diagnostics to 4 KiB, subject also to the +active frame bound. + +## Shell request + +A shell request uses message kind `1` and this payload: + +```text +string command +string working_directory +i64 timeout +``` + +The command must contain at least two bytes. It inherits the manager's complete +environment and is normalized to: + +```text +/bin/bash -o pipefail -c command +``` + +Standard input, output and error use `/dev/null` unless the command performs +its own shell redirection. The shell request preserves the public +command-string API and its Bash syntax; it is not a structured request encoded +as a quoted command. + +## Structured exec request + +An exec request uses message kind `2` and this payload: + +```text +string executable +u32 argument_count +string argument[argument_count] +u32 environment_count +repeat environment_count times: + string environment_name + string environment_value +string working_directory +stream standard_output +stream standard_error +i64 timeout +``` + +The executable and working directory are absolute. The executable becomes +`argv[0]`; transmitted arguments start with `argv[1]`. The environment vector +replaces the manager environment completely, and a zero element count selects +an empty environment. Version 2 always connects standard input to `/dev/null`. + +Environment names must be nonempty and must not contain `=` or NUL. Duplicate +names are transmitted in their original order; callers should avoid them +because lookup behavior then belongs to the executed program and its C +runtime rather than to the protocol. + +## Stream encoding + +A stream starts with one mode byte: + +| Mode | Following field | Meaning | +| ---: | --- | --- | +| `0` | none | connect the stream to `/dev/null` | +| `1` | absolute path string | open and truncate without following a final symlink | +| `2` | none | stderr duplicates installed stdout; illegal for stdout | + +An absent output file is created from mode `0666` filtered by the manager's +`umask`, matching normal shell redirection. Truncating an existing file keeps +its mode. A caller needing owner-only output should create the file with mode +`0600` before submitting the request. + +## Shared output semantics + +Stdout is installed first. `Merge_Stdout` on stderr duplicates fd 1 to fd 2, +sharing the same open file description and write position. The file is opened +and truncated only once, and stdout remains open. A null stdout gives stderr +the same null sink. Mode 2 has no path field; a stdout self-reference is rejected. +Equal truncate paths never select this policy implicitly. + +This matches shell `> log 2>&1` at the descriptor level. It preserves the order +of writes reaching the shared sink, not the order of application-side stdio +buffers or atomic multi-line messages from concurrent writers. Separate +capture files joined later cannot implement this contract. Append, named +stdin, privilege exceptions and individual cancellation are not added. +The [version-1 lifecycle](lifecycle-v1.md) remains unchanged; stdout-open and +stderr-duplication failures retain the existing stages and errno. + +## Result + +A result uses message kind `3`. Its payload begins with a one-byte result kind +followed by exactly one alternative: + +| Kind | Name | Remaining payload | +| ---: | --- | --- | +| `0` | exited | `u32 exit_status` | +| `1` | signaled | `u16 signal_number` | +| `2` | timed out | no additional bytes | +| `3` | spawn failed | `u16 stage`, `u32 errno`, diagnostic string | +| `4` | request rejected | diagnostic string | +| `5` | protocol failed | diagnostic string | + +Spawn-failure stage values are fixed by this order: + +| Value | Stage | Value | Stage | +| ---: | --- | ---: | --- | +| 0 | `No_Failure` | 9 | `Duplicate_Stdin` | +| 1 | `Enable_Subreaper` | 10 | `Duplicate_Stdout` | +| 2 | `Create_Error_Pipe` | 11 | `Duplicate_Stderr` | +| 3 | `Fork_Child` | 12 | `Change_Directory` | +| 4 | `Process_Group` | 13 | `Reset_Signals` | +| 5 | `Parent_Death` | 14 | `Close_Descriptors` | +| 6 | `Open_Stdin` | 15 | `Exec_Target` | +| 7 | `Open_Stdout` | 16 | `Wait_Child` | +| 8 | `Open_Stderr` | 17 | `Terminate_Group` | + +Diagnostic text is truncated when necessary so the complete result fits the +active frame bound. The result kind, failure stage and `errno` are never +discarded to make it fit. + +## Transport timing + +Frames use a nonblocking stream socket. Sending a frame and completing a frame +after its first received byte each have a fixed five-second monotonic deadline. +For a finite child timeout, the first result byte is bounded by that timeout +plus the transport allowance. An unlimited child also has an unlimited +first-result-byte wait because version 2 has no heartbeat protocol. + +Anet owns creation, connection and lifetime of the Unix stream socket. Exact +frame transfer is implemented by `Spawn.Transport` on its raw descriptor, +rather than by `Anet.Streams` or the generic one-operation socket I/O calls. +This split keeps partial-I/O retries, monotonic deadlines, frame bounds and +protocol error classification in the component which defines them. The +[project overview](../README.md#anet-boundary) records the rationale and the +conditions under which a future Anet transport primitive could replace that +generic I/O machinery. diff --git a/src/spawn-posix.c b/src/spawn-posix.c index 7d683d1..483cb6b 100644 --- a/src/spawn-posix.c +++ b/src/spawn-posix.c @@ -402,7 +402,7 @@ static void child_exec( } reset_signal_mask(); - /* Install the version-1 stdin, stdout and stderr contract. */ + /* Install stdin and stdout before resolving stderr's optional alias. */ null_fd = open("/dev/null", O_RDWR | O_CLOEXEC); if (null_fd < 0) report_child_error(SPAWN_POSIX_OPEN_STDIN); @@ -418,13 +418,21 @@ static void child_exec( if (output_fd != null_fd && output_fd != STDOUT_FILENO) (void)close(output_fd); - output_fd = stderr_mode == SPAWN_POSIX_NULL_STREAM - ? null_fd : open_output_file(stderr_mode, stderr_path); + /* Duplicating fd 1 shares its open file description and write position. + * Reopening stdout_path would truncate twice and lose ordered log bytes. + */ + if (stderr_mode == SPAWN_POSIX_MERGE_STDOUT) + output_fd = STDOUT_FILENO; + else if (stderr_mode == SPAWN_POSIX_NULL_STREAM) + output_fd = null_fd; + else + output_fd = open_output_file(stderr_mode, stderr_path); if (output_fd < 0) report_child_error(SPAWN_POSIX_OPEN_STDERR); if (duplicate_to(output_fd, STDERR_FILENO) < 0) report_child_error(SPAWN_POSIX_DUP_STDERR); - if (output_fd != null_fd && output_fd != STDERR_FILENO) + if (output_fd != null_fd && output_fd != STDERR_FILENO + && output_fd != STDOUT_FILENO) (void)close(output_fd); if (null_fd > STDERR_FILENO) (void)close(null_fd); diff --git a/src/spawn-posix.h b/src/spawn-posix.h index 397d81d..e9fc961 100644 --- a/src/spawn-posix.h +++ b/src/spawn-posix.h @@ -56,10 +56,11 @@ enum spawn_posix_failure_stage { SPAWN_POSIX_TERMINATE_GROUP = 17 }; -/* Output policies accepted by the version-1 normalized launch request. */ +/* Output policies; MERGE_STDOUT is valid only for stderr. */ enum spawn_posix_stream_mode { SPAWN_POSIX_NULL_STREAM = 0, - SPAWN_POSIX_TRUNCATE_FILE = 1 + SPAWN_POSIX_TRUNCATE_FILE = 1, + SPAWN_POSIX_MERGE_STDOUT = 2 }; /* Complete result written on every return from spawn_posix_execute. */ diff --git a/src/spawn-protocol.adb b/src/spawn-protocol.adb index af25125..6404554 100644 --- a/src/spawn-protocol.adb +++ b/src/spawn-protocol.adb @@ -123,7 +123,7 @@ package body Spawn.Protocol is function Kind_To_Wire (Kind : Message_Kind) return Interfaces.Unsigned_16; - -- Return the assigned version 1 value for Kind. + -- Return the fixed wire value for Kind. function Diagnostic_Field_Length (Value : Ada.Strings.Unbounded.Unbounded_String) @@ -151,7 +151,7 @@ package body Spawn.Protocol is procedure Validate_Diagnostic (Value : Ada.Strings.Unbounded.Unbounded_String); - -- Reject a diagnostic which cannot be represented within version 1. + -- Reject a diagnostic which exceeds the unchanged protocol bounds. procedure Validate_Environment_Name (Value : Ada.Strings.Unbounded.Unbounded_String); @@ -171,12 +171,12 @@ package body Spawn.Protocol is procedure Validate_String (Value : Ada.Strings.Unbounded.Unbounded_String); - -- Reject a string which cannot be represented within version 1. + -- Reject a string which exceeds the unchanged protocol bounds. procedure Validate_Vector_Length (Length : Ada.Containers.Count_Type; Name : String); - -- Reject a vector count beyond the version 1 bound. + -- Reject a vector count beyond the unchanged protocol bound. procedure Decode_Diagnostic (Data : Ada.Streams.Stream_Element_Array; @@ -336,6 +336,9 @@ package body Spawn.Protocol is (Data => Data, Cursor => Cursor, Stream => Decoded.Standard_Error); + if Decoded.Standard_Output.Mode = Merge_Stdout then + raise Request_Error with "stdout cannot refer to itself"; + end if; Require_Remaining (Data => Data, Cursor => Cursor, @@ -620,6 +623,9 @@ package body Spawn.Protocol is Name => "stream"); Stream := (Mode => Truncate_File, Path => Path); end; + when 2 => + Stream := (Mode => Merge_Stdout); + Cursor := Cursor + Stream_Mode_Size; when others => raise Protocol_Error with "unknown stream mode"; end case; @@ -1072,6 +1078,9 @@ package body Spawn.Protocol is Validate_Absolute_Path (Value => Ada.Strings.Unbounded.To_String (Request.Directory), Name => "directory"); + if Request.Standard_Output.Mode = Merge_Stdout then + raise Request_Error with "stdout cannot refer to itself"; + end if; Payload_Length := Payload_Length + Stream_Field_Length (Stream => Request.Standard_Output) + Stream_Field_Length (Stream => Request.Standard_Error); @@ -1197,7 +1206,7 @@ package body Spawn.Protocol is begin Validate_Stream (Stream => Stream); case Stream.Mode is - when Null_Stream => + when Null_Stream | Merge_Stdout => return Stream_Mode_Size; when Truncate_File => declare @@ -1313,7 +1322,7 @@ package body Spawn.Protocol is is begin case Stream.Mode is - when Null_Stream => + when Null_Stream | Merge_Stdout => null; when Truncate_File => Validate_String (Value => Stream.Path); diff --git a/src/spawn-protocol.ads b/src/spawn-protocol.ads index d0c10fb..bcab7ac 100644 --- a/src/spawn-protocol.ads +++ b/src/spawn-protocol.ads @@ -29,7 +29,7 @@ with Interfaces; package Spawn.Protocol is - -- Version 1 wire format + -- Version 2 wire format -- -- All offsets below are zero-based byte offsets. Integers are unsigned -- big-endian unless marked i64. Lengths count payload bytes, not Ada @@ -38,7 +38,7 @@ package Spawn.Protocol is -- Common frame header (12 bytes) -- -- 0 u8[4] magic "SPWN" - -- 4 u16 protocol version, fixed value 1 + -- 4 u16 protocol version, fixed value 2 -- 6 u16 message kind: 1 shell, 2 exec, 3 result -- 8 u32 payload length, excluding this header -- @@ -48,6 +48,8 @@ package Spawn.Protocol is -- vector := u32 element_count, element[element_count] -- stream := u8 mode, where 0 is /dev/null and 1 is followed by -- one absolute-path string opened with truncate semantics + -- Mode 2 has no path and duplicates stdout for stderr; + -- it is never legal in the stdout position. -- timeout := i64 two's-complement milliseconds; -1 means unlimited -- -- Shell request payload (message kind 1) @@ -75,7 +77,7 @@ package Spawn.Protocol is -- i64 timeout -- -- Exec requests replace the complete environment; a zero count means - -- an empty environment. Standard input is always /dev/null in version 1. + -- an empty environment. Standard input remains /dev/null in version 2. -- -- Result payload (message kind 3) -- @@ -115,7 +117,7 @@ package Spawn.Protocol is Character'Pos ('P'), Character'Pos ('W'), Character'Pos ('N')); - Protocol_Version : constant Interfaces.Unsigned_16 := 1; + Protocol_Version : constant Interfaces.Unsigned_16 := 2; Maximum_Frame_Size : constant := 128 * 1024; Maximum_String_Size : constant := 64 * 1024; @@ -152,14 +154,14 @@ package Spawn.Protocol is (Index_Type => Positive, Element_Type => Environment_Entry_Type); - type Stream_Mode is (Null_Stream, Truncate_File); + type Stream_Mode is (Null_Stream, Truncate_File, Merge_Stdout); -- Wire values are the zero-based positions fixed in the format above. type Stream_Specification_Type (Mode : Stream_Mode := Null_Stream) is record case Mode is - when Null_Stream => + when Null_Stream | Merge_Stdout => null; when Truncate_File => Path : Ada.Strings.Unbounded.Unbounded_String; diff --git a/tests/protocol-failure-manager.c b/tests/protocol-failure-manager.c index e4c312e..504e96d 100644 --- a/tests/protocol-failure-manager.c +++ b/tests/protocol-failure-manager.c @@ -67,11 +67,11 @@ int main(int argc, char *argv[]) * stage bytes; the Ada decoder constants are not reused here. */ static const unsigned char protocol_failure_result[] = { - 'S', 'P', 'W', 'N', 0, 1, 0, 3, 0, 0, 0, 9, + 'S', 'P', 'W', 'N', 0, 2, 0, 3, 0, 0, 0, 9, 5, 0, 0, 0, 4, 's', 't', 'o', 'p' }; unsigned char supervision_failure_result[] = { - 'S', 'P', 'W', 'N', 0, 1, 0, 3, 0, 0, 0, 22, + 'S', 'P', 'W', 'N', 0, 2, 0, 3, 0, 0, 0, 22, 3, 0, 17, 0, 0, 0, 5, 0, 0, 0, 11, 'c', 'o', 'n', 't', 'a', 'i', 'n', 'm', 'e', 'n', 't' }; diff --git a/tests/spawn-pool-tests.adb b/tests/spawn-pool-tests.adb index c337231..0bd88a8 100644 --- a/tests/spawn-pool-tests.adb +++ b/tests/spawn-pool-tests.adb @@ -914,6 +914,178 @@ package body Spawn.Pool.Tests is ------------------------------------------------------------------------- + procedure Execute_Merged_Streams + is + Root : constant String := Ada.Directories.Current_Directory + & "/obj/shared ' output-" & Anet.Util.Random_String (Len => 8); + Output_Path : constant String := Root & "/output"; + Created : Boolean := False; + Request : Spawn.Protocol.Exec_Request_Type; + Result : Spawn.Protocol.Result_Type; + + function Read_Log (Path : String) return String; + -- Read actual completed log lines, including their terminating LF. + + procedure Cleanup; + -- Shut down managers before removing only this test's owned directory. + + procedure Cleanup + is + begin + Spawn.Pool.Cleanup; + if Created and then Ada.Directories.Exists (Root) then + Ada.Directories.Delete_Tree (Root); + end if; + end Cleanup; + + function Read_Log (Path : String) return String + is + File : Ada.Text_IO.File_Type; + Text : Unbounded_String; + begin + Ada.Text_IO.Open + (File, Ada.Text_IO.In_File, Path, Form => "shared=no"); + while not Ada.Text_IO.End_Of_File (File) loop + Append (Text, Ada.Text_IO.Get_Line (File) & ASCII.LF); + end loop; + Ada.Text_IO.Close (File); + return To_String (Text); + exception + when others => + if Ada.Text_IO.Is_Open (File) then + Ada.Text_IO.Close (File); + end if; + raise; + end Read_Log; + begin + Ada.Directories.Create_Directory (Root); + Created := True; + Spawn.Pool.Init (Manager_Path => Manager_Path, Manager_Count => 2); + Request.Executable := To_Unbounded_String ("/bin/sh"); + Request.Directory := To_Unbounded_String ("/"); + Request.Timeout := 2_000; + Request.Arguments.Append ("-c"); + Request.Arguments.Append + ("printf 'stdout\n'; printf 'stderr\n' >&2; printf 'after\n'; exit 7"); + Request.Standard_Error := (Mode => Spawn.Protocol.Merge_Stdout); + Request.Standard_Output := (Mode => Spawn.Protocol.Merge_Stdout); + Result := Spawn.Pool.Execute (Request); + Assert (Result.Kind = Spawn.Protocol.Request_Rejected, + "stdout self-reference was not rejected before dispatch"); + + Request.Standard_Output := + (Mode => Spawn.Protocol.Truncate_File, + Path => To_Unbounded_String (Output_Path)); + Result := Spawn.Pool.Execute (Request); + Assert (Result.Kind = Spawn.Protocol.Exited + and then Result.Exit_Status = 7, + "shared log changed exit status or prevented reuse"); + Assert (Read_Log (Output_Path) + = "stdout" & ASCII.LF & "stderr" & ASCII.LF + & "after" & ASCII.LF, + "shared log lost bytes or write order"); + + Request.Standard_Output := (Mode => Spawn.Protocol.Null_Stream); + Result := Spawn.Pool.Execute (Request); + Assert (Result.Kind = Spawn.Protocol.Exited + and then Result.Exit_Status = 7, + "shared null output failed"); + + Request.Standard_Output := + (Mode => Spawn.Protocol.Truncate_File, + Path => To_Unbounded_String (Root & "/missing/log")); + Result := Spawn.Pool.Execute (Request); + Assert (Result.Kind = Spawn.Protocol.Spawn_Failed + and then Result.Failure.Stage = Spawn.Protocol.Open_Stdout, + "shared sink open failure has the wrong stage"); + + Request.Standard_Output := + (Mode => Spawn.Protocol.Truncate_File, + Path => To_Unbounded_String (Output_Path)); + Request.Arguments.Replace_Element + (2, "printf 'before timeout\n' >&2; exec /bin/sleep 60"); + Request.Timeout := 1_000; + Result := Spawn.Pool.Execute (Request); + Assert (Result.Kind = Spawn.Protocol.Timed_Out, + "shared log changed timeout classification"); + Assert (Read_Log (Output_Path) = "before timeout" & ASCII.LF, + "timeout discarded the shared diagnostic"); + + declare + -- Root is immutable. Each caller owns its request and output; the + -- child-side ready barrier requires both managers to be active. + task type Writer is + entry Start (Number : Positive); + entry Join (Success : out Boolean); + end Writer; + + task body Writer + is + Index : Positive := 1; + Passed : Boolean := False; + begin + accept Start (Number : Positive) do + Index := Number; + end Start; + declare + Token : constant String := Ada.Strings.Fixed.Trim + (Index'Image, Ada.Strings.Both); + Path : constant String := Root & "/parallel-" & Token; + Child_Request : Spawn.Protocol.Exec_Request_Type; + Child_Result : Spawn.Protocol.Result_Type; + begin + Child_Request.Executable := To_Unbounded_String ("/bin/sh"); + Child_Request.Directory := To_Unbounded_String ("/"); + Child_Request.Timeout := 2_000; + Child_Request.Standard_Output := + (Mode => Spawn.Protocol.Truncate_File, + Path => To_Unbounded_String (Path)); + Child_Request.Standard_Error := + (Mode => Spawn.Protocol.Merge_Stdout); + Child_Request.Arguments.Append ("-c"); + Child_Request.Arguments.Append + (": > ""$1/ready-$2""; while test ! -e ""$1/ready-1""" + & " || test ! -e ""$1/ready-2""; do /bin/sleep 0.001; done;" + & " printf 'out-%s\n' ""$2"";" + & " printf 'err-%s\n' ""$2"" >&2"); + Child_Request.Arguments.Append ("shared-fixture"); + Child_Request.Arguments.Append (Root); + Child_Request.Arguments.Append (Token); + Child_Result := Spawn.Pool.Execute (Child_Request); + Passed := Child_Result.Kind = Spawn.Protocol.Exited + and then Child_Result.Exit_Status = 0 + and then Read_Log (Path) = "out-" & Token & ASCII.LF + & "err-" & Token & ASCII.LF; + exception + when others => Passed := False; + end; + accept Join (Success : out Boolean) do + Success := Passed; + end Join; + end Writer; + + Writers : array (1 .. 2) of Writer; + Passed : Boolean; + All_Passed : Boolean := True; + begin + for I in Writers'Range loop + Writers (I).Start (I); + end loop; + for Worker of Writers loop + Worker.Join (Passed); + All_Passed := All_Passed and Passed; + end loop; + Assert (All_Passed, "parallel shared logs mixed or lost bytes"); + end; + Cleanup; + exception + when others => + Cleanup; + raise; + end Execute_Merged_Streams; + + ------------------------------------------------------------------------- + procedure Execute_Nonexistent is begin @@ -1401,6 +1573,9 @@ package body Spawn.Pool.Tests is T.Add_Test_Routine (Routine => Execute_Structured'Access, Name => "Execute structured requests"); + T.Add_Test_Routine + (Routine => Execute_Merged_Streams'Access, + Name => "Share ordered structured output streams"); T.Add_Test_Routine (Routine => Execute_Structured_Environment'Access, Name => "Isolate structured environments"); diff --git a/tests/spawn-pool-tests.ads b/tests/spawn-pool-tests.ads index 365a273..aa514f7 100644 --- a/tests/spawn-pool-tests.ads +++ b/tests/spawn-pool-tests.ads @@ -66,6 +66,9 @@ package Spawn.Pool.Tests is procedure Execute_Structured; -- Verify structured results, checked execution and timeout mapping. + procedure Execute_Merged_Streams; + -- Pin ordered shared logs, rejection, timeout and two-manager isolation. + procedure Execute_Structured_Environment; -- Verify replacement environments remain isolated after all outcomes. diff --git a/tests/spawn-posix-tests.c b/tests/spawn-posix-tests.c index 63474b2..49c7265 100644 --- a/tests/spawn-posix-tests.c +++ b/tests/spawn-posix-tests.c @@ -485,12 +485,39 @@ static int fixture_output(int argc, char *argv[]) return 0; } +/* Querying the other fd after each write proves shared open-file-description + * offsets, not just two descriptors which happen to name the same inode. + * The final stdout write also detects accidentally closing fd 1 after dup2. + */ +static int fixture_merged_output(void) +{ + static const char first[] = "stdout-first\n"; + static const char second[] = "stderr-second\n"; + static const char last[] = "stdout-last\n"; + + require(write(STDOUT_FILENO, first, sizeof(first) - 1) + == (ssize_t)(sizeof(first) - 1), "write first shared stdout"); + require(lseek(STDERR_FILENO, 0, SEEK_CUR) == (off_t)(sizeof(first) - 1), + "stderr does not share stdout offset"); + require(write(STDERR_FILENO, second, sizeof(second) - 1) + == (ssize_t)(sizeof(second) - 1), "write shared stderr"); + require(lseek(STDOUT_FILENO, 0, SEEK_CUR) + == (off_t)(sizeof(first) + sizeof(second) - 2), + "stdout does not share stderr offset"); + require(write(STDOUT_FILENO, last, sizeof(last) - 1) + == (ssize_t)(sizeof(last) - 1), "stdout remained open"); + return 7; +} + +/* Dispatch the fixture only after exec, outside the restricted fork window. */ static int fixture(int argc, char *argv[], char *envp[]) { if (argc < 3) return 80; if (strcmp(argv[2], "verify") == 0) return fixture_verify(argc, argv, envp); + if (strcmp(argv[2], "merged") == 0) + return fixture_merged_output(); if (strcmp(argv[2], "exit37") == 0) return 37; if (strcmp(argv[2], "signal") == 0) { @@ -810,6 +837,54 @@ static void test_request_data( pass("argv, replacement environment, cwd and split streams"); } +/* Exercise the direct C contract with independent byte and offset oracles, + * including truncation, retained permissions, null output and misuse. + */ +static void test_merged_output(const char *self, const char *output_path) +{ + char *empty_environment[] = { NULL }; + char *arguments[] = { (char *)self, "fixture", "merged", NULL }; + char *null_arguments[] = { + "/bin/sh", "-c", "printf output; printf error >&2", NULL + }; + struct spawn_posix_result result; + struct stat status; + char stale[256]; + char *content; + int descriptor; + + memset(stale, 'x', sizeof(stale)); + descriptor = open(output_path, O_WRONLY | O_TRUNC); + require(descriptor >= 0, "open stale shared log"); + require(write(descriptor, stale, sizeof(stale)) == (ssize_t)sizeof(stale), + "write stale shared log"); + require(close(descriptor) == 0, "close stale shared log"); + require(spawn_posix_execute(self, arguments, 0, empty_environment, "/", + SPAWN_POSIX_TRUNCATE_FILE, output_path, + SPAWN_POSIX_MERGE_STDOUT, NULL, 1000, &result) == 0, + "execute shared-output fixture"); + require_exited(&result, 7, "preserve nonzero shared-output result"); + content = read_file(output_path); + require(strcmp(content, "stdout-first\nstderr-second\nstdout-last\n") == 0, + "shared output order or truncation differs"); + free(content); + require(stat(output_path, &status) == 0 + && (status.st_mode & 0777) == 0600, "shared log mode changed"); + require(spawn_posix_execute("/bin/sh", null_arguments, 0, + empty_environment, "/", SPAWN_POSIX_NULL_STREAM, NULL, + SPAWN_POSIX_MERGE_STDOUT, NULL, 1000, &result) == 0, + "merge stderr with null stdout"); + require_exited(&result, 0, "null shared-output result"); + require(spawn_posix_execute(self, arguments, 0, empty_environment, "/", + SPAWN_POSIX_MERGE_STDOUT, NULL, + SPAWN_POSIX_NULL_STREAM, NULL, 1000, &result) == 0, + "reject stdout self-reference"); + require(result.kind == SPAWN_POSIX_SPAWN_FAILED + && result.failure_stage == SPAWN_POSIX_OPEN_STDOUT + && result.error_number == EINVAL, "stdout self-reference stage"); + pass("shared output offset, order, truncation and permissions"); +} + static void test_stdin_and_descriptors( const char *self, int inherited_fd) @@ -1659,6 +1734,7 @@ int main(int argc, char *argv[], char *envp[]) test_group_setup_failure_descendants(self, pid_path); require(unlink(pid_path) == 0, "reset group setup descendant pid file"); test_request_data(self, root, stdout_path, stderr_path); + test_merged_output(self, stdout_path); test_stdin_and_descriptors(self, inherited_fd); test_stream_nofollow(self, symlink_path); test_timeout_group(self, pid_path); diff --git a/tests/spawn-protocol-tests.adb b/tests/spawn-protocol-tests.adb index d1ddd1c..90000c1 100644 --- a/tests/spawn-protocol-tests.adb +++ b/tests/spawn-protocol-tests.adb @@ -38,7 +38,7 @@ package body Spawn.Protocol.Tests is is Golden : constant Ada.Streams.Stream_Element_Array (1 .. 62) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#02#, + 16#00#, 16#02#, 16#00#, 16#02#, 16#00#, 16#00#, 16#00#, 16#32#, 16#00#, 16#00#, 16#00#, 16#02#, 16#2f#, 16#78#, 16#00#, 16#00#, 16#00#, 16#01#, @@ -89,11 +89,67 @@ package body Spawn.Protocol.Tests is ------------------------------------------------------------------------- + procedure Exec_Merged_Golden_Data + is + -- Independent byte oracle: mode 1 carries /o, mode 2 carries no path. + -- The frame length equals the old split-stream example by coincidence. + Golden : constant Ada.Streams.Stream_Element_Array (1 .. 62) := + (16#53#, 16#50#, 16#57#, 16#4e#, + 16#00#, 16#02#, 16#00#, 16#02#, + 16#00#, 16#00#, 16#00#, 16#32#, + 16#00#, 16#00#, 16#00#, 16#02#, 16#2f#, 16#78#, + 16#00#, 16#00#, 16#00#, 16#01#, + 16#00#, 16#00#, 16#00#, 16#01#, 16#61#, + 16#00#, 16#00#, 16#00#, 16#01#, + 16#00#, 16#00#, 16#00#, 16#01#, 16#45#, + 16#00#, 16#00#, 16#00#, 16#01#, 16#56#, + 16#00#, 16#00#, 16#00#, 16#01#, 16#2f#, + 16#01#, 16#00#, 16#00#, 16#00#, 16#02#, 16#2f#, 16#6f#, + 16#02#, + 16#ff#, 16#ff#, 16#ff#, 16#ff#, + 16#ff#, 16#ff#, 16#ff#, 16#ff#); + Expected : Exec_Request_Type; + Decoded : Exec_Request_Type; + Data : Ada.Streams.Stream_Element_Array (Golden'Range); + begin + Expected.Executable := Ada.Strings.Unbounded.To_Unbounded_String ("/x"); + Expected.Arguments.Append ("a"); + Expected.Environment.Append + ((Name => Ada.Strings.Unbounded.To_Unbounded_String ("E"), + Value => Ada.Strings.Unbounded.To_Unbounded_String ("V"))); + Expected.Directory := Ada.Strings.Unbounded.To_Unbounded_String ("/"); + Expected.Standard_Output := + (Mode => Truncate_File, + Path => Ada.Strings.Unbounded.To_Unbounded_String ("/o")); + Expected.Standard_Error := (Mode => Merge_Stdout); + Expected.Timeout := -1; + Assert (Exec_Request_Frame_Length (Expected, Maximum_Frame_Size) = 62, + "shared stream frame length differs"); + Encode_Exec_Request (Expected, Maximum_Frame_Size, Data); + Assert (Data = Golden, "shared stream encoding differs from oracle"); + Decode_Exec_Request (Golden, Maximum_Frame_Size, Decoded); + Assert (Decoded = Expected, "shared stream decoding differs"); + + Expected.Standard_Output := (Mode => Merge_Stdout); + begin + declare + Length : constant Positive := Exec_Request_Frame_Length + (Expected, Maximum_Frame_Size); + begin + Fail ("stdout self-reference accepted, length" & Length'Image); + end; + exception + when Request_Error => null; + end; + end Exec_Merged_Golden_Data; + + ------------------------------------------------------------------------- + procedure Exec_Rejects_Invalid_Data is Golden : constant Ada.Streams.Stream_Element_Array (1 .. 62) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#02#, + 16#00#, 16#02#, 16#00#, 16#02#, 16#00#, 16#00#, 16#00#, 16#32#, 16#00#, 16#00#, 16#00#, 16#02#, 16#2f#, 16#78#, 16#00#, 16#00#, 16#00#, 16#01#, @@ -159,13 +215,19 @@ package body Spawn.Protocol.Tests is declare Data : Ada.Streams.Stream_Element_Array := Golden; begin - Data (47) := 2; + Data (47) := 3; Reject_Protocol (Data => Data, Name => "unknown stream mode"); end; + declare + Data : Ada.Streams.Stream_Element_Array := Golden; + begin + Data (47) := 2; + Reject_Request (Data => Data, Name => "stdout self-reference"); + end; declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 22) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#02#, + 16#00#, 16#02#, 16#00#, 16#02#, 16#00#, 16#00#, 16#00#, 16#0a#, 16#00#, 16#00#, 16#00#, 16#02#, 16#2f#, 16#78#, 16#00#, 16#00#, 16#04#, 16#01#); @@ -437,7 +499,7 @@ package body Spawn.Protocol.Tests is is Golden : constant Ada.Streams.Stream_Element_Array (1 .. Header_Size) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#02#, + 16#00#, 16#02#, 16#00#, 16#02#, 16#00#, 16#00#, 16#00#, 16#24#); Data : Ada.Streams.Stream_Element_Array (1 .. Header_Size); Header : Header_Type; @@ -463,7 +525,7 @@ package body Spawn.Protocol.Tests is is Valid : constant Ada.Streams.Stream_Element_Array (1 .. Header_Size) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#00#); Header : Header_Type; @@ -497,8 +559,8 @@ package body Spawn.Protocol.Tests is declare Data : Ada.Streams.Stream_Element_Array := Valid; begin - Data (6) := 2; - Reject (Data => Data, Name => "invalid version"); + Data (6) := 1; + Reject (Data => Data, Name => "released version 1"); end; declare Data : Ada.Streams.Stream_Element_Array := Valid; @@ -518,6 +580,9 @@ package body Spawn.Protocol.Tests is T.Add_Test_Routine (Routine => Exec_Golden_Data'Access, Name => "Encode and decode golden exec request"); + T.Add_Test_Routine + (Routine => Exec_Merged_Golden_Data'Access, + Name => "Encode and decode shared stderr request"); T.Add_Test_Routine (Routine => Exec_Roundtrip_And_Bounds'Access, Name => "Round trip bounded exec request"); @@ -599,7 +664,7 @@ package body Spawn.Protocol.Tests is (Expected => (Kind => Exited, Exit_Status => 16#fedc_ba98#), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#05#, 16#00#, 16#fe#, 16#dc#, 16#ba#, 16#98#), Name => "exit result"); @@ -607,7 +672,7 @@ package body Spawn.Protocol.Tests is (Expected => (Kind => Signaled, Signal_Number => 15), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#03#, 16#01#, 16#00#, 16#0f#), Name => "signal result"); @@ -615,7 +680,7 @@ package body Spawn.Protocol.Tests is (Expected => (Kind => Timed_Out), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#01#, 16#02#), Name => "timeout result"); Check @@ -628,7 +693,7 @@ package body Spawn.Protocol.Tests is ("exec"))), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#0f#, 16#03#, 16#00#, 16#0f#, 16#00#, 16#00#, 16#00#, 16#02#, @@ -642,7 +707,7 @@ package body Spawn.Protocol.Tests is ("bad request")), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#10#, 16#04#, 16#00#, 16#00#, 16#00#, 16#0b#, 16#62#, 16#61#, 16#64#, 16#20#, 16#72#, 16#65#, @@ -655,7 +720,7 @@ package body Spawn.Protocol.Tests is ("bad frame")), Golden => (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#0e#, 16#05#, 16#00#, 16#00#, 16#00#, 16#09#, 16#62#, 16#61#, 16#64#, 16#20#, 16#66#, @@ -667,7 +732,7 @@ package body Spawn.Protocol.Tests is is Golden : constant Ada.Streams.Stream_Element_Array (1 .. 27) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#0f#, 16#03#, 16#00#, 16#0f#, 16#00#, 16#00#, 16#00#, 16#02#, @@ -705,7 +770,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 13) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#01#, 16#06#); begin @@ -726,7 +791,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 14) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#02#, 16#02#, 16#00#); begin @@ -735,7 +800,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 17) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#05#, 16#05#, 16#00#, 16#00#, 16#10#, 16#01#); begin @@ -858,7 +923,7 @@ package body Spawn.Protocol.Tests is is Golden : constant Ada.Streams.Stream_Element_Array (1 .. 38) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#1a#, 16#00#, 16#00#, 16#00#, 16#09#, 16#2f#, 16#62#, 16#69#, 16#6e#, @@ -908,7 +973,7 @@ package body Spawn.Protocol.Tests is is Golden : constant Ada.Streams.Stream_Element_Array (1 .. 38) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#1a#, 16#00#, 16#00#, 16#00#, 16#09#, 16#2f#, 16#62#, 16#69#, 16#6e#, @@ -994,7 +1059,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 28) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#10#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, @@ -1006,7 +1071,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 29) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#11#, 16#00#, 16#00#, 16#00#, 16#01#, 16#78#, 16#00#, 16#00#, 16#00#, 16#00#, @@ -1018,7 +1083,7 @@ package body Spawn.Protocol.Tests is declare Data : constant Ada.Streams.Stream_Element_Array (1 .. 16) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#04#, 16#00#, 16#01#, 16#00#, 16#01#); begin diff --git a/tests/spawn-protocol-tests.ads b/tests/spawn-protocol-tests.ads index bcce41e..490200b 100644 --- a/tests/spawn-protocol-tests.ads +++ b/tests/spawn-protocol-tests.ads @@ -28,7 +28,10 @@ package Spawn.Protocol.Tests is type Testcase is new Ahven.Framework.Test_Case with null record; procedure Exec_Golden_Data; - -- Verify exact version 1 exec-request bytes and decoding. + -- Pin the complete independent-stream exec frame. + + procedure Exec_Merged_Golden_Data; + -- Pin mode 2's exact pathless encoding and reject stdout self-reference. procedure Exec_Rejects_Invalid_Data; -- Verify malformed and semantically invalid exec requests are rejected. @@ -40,7 +43,7 @@ package Spawn.Protocol.Tests is -- Verify fixed and active complete-frame bounds. procedure Header_Golden_Data; - -- Verify exact version 1 header bytes and decoding. + -- Verify exact version 2 header bytes and decoding. procedure Header_Rejects_Invalid_Data; -- Verify malformed magic, version, kind and header length rejection. @@ -49,10 +52,10 @@ package Spawn.Protocol.Tests is -- Register protocol tests. procedure Result_Golden_Data; - -- Verify exact version 1 bytes for every result alternative. + -- Verify exact version 2 bytes for every result alternative. procedure Failure_Stage_Golden_Data; - -- Verify every version 1 spawn-failure stage number independently. + -- Verify every unchanged spawn-failure stage number independently. procedure Result_Rejects_Invalid_Data; -- Verify malformed and semantically invalid results are rejected. @@ -61,7 +64,7 @@ package Spawn.Protocol.Tests is -- Verify every result alternative and the diagnostic boundary. procedure Shell_Golden_Data; - -- Verify exact version 1 shell-request bytes and decoding. + -- Verify exact version 2 shell-request bytes and decoding. procedure Shell_Rejects_Invalid_Data; -- Verify malformed and semantically invalid shell requests are rejected. diff --git a/tests/spawn-transport-tests.adb b/tests/spawn-transport-tests.adb index bced17e..051bfe9 100644 --- a/tests/spawn-transport-tests.adb +++ b/tests/spawn-transport-tests.adb @@ -100,7 +100,7 @@ package body Spawn.Transport.Tests is Test_Frame : constant Ada.Streams.Stream_Element_Array (1 .. 13) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#03#, + 16#00#, 16#02#, 16#00#, 16#03#, 16#00#, 16#00#, 16#00#, 16#01#, 16#02#); diff --git a/tests/spawn_manager_tests.adb b/tests/spawn_manager_tests.adb index f1334d9..0791c76 100644 --- a/tests/spawn_manager_tests.adb +++ b/tests/spawn_manager_tests.adb @@ -107,7 +107,7 @@ package body Spawn_Manager_Tests is declare Empty_Shell : constant Ada.Streams.Stream_Element_Array (1 .. 28) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#01#, 16#00#, 16#01#, + 16#00#, 16#02#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#10#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, 16#00#, @@ -136,7 +136,7 @@ package body Spawn_Manager_Tests is declare Invalid : constant Ada.Streams.Stream_Element_Array (1 .. 12) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#02#, 16#00#, 16#01#, + 16#00#, 16#01#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#00#); Result : Spawn.Protocol.Result_Type; begin @@ -390,7 +390,7 @@ package body Spawn_Manager_Tests is declare Invalid : constant Ada.Streams.Stream_Element_Array (1 .. 12) := (16#53#, 16#50#, 16#57#, 16#4e#, - 16#00#, 16#02#, 16#00#, 16#01#, + 16#00#, 16#01#, 16#00#, 16#01#, 16#00#, 16#00#, 16#00#, 16#00#); Result : Spawn.Protocol.Result_Type; begin diff --git a/tools/spawn_manager_processes.adb b/tools/spawn_manager_processes.adb index a4006d2..e1634ae 100644 --- a/tools/spawn_manager_processes.adb +++ b/tools/spawn_manager_processes.adb @@ -112,6 +112,8 @@ package body Spawn_Manager_Processes is case Stream.Mode is when Spawn.Protocol.Null_Stream => return (Mode => Null_Stream, Path => <>); + when Spawn.Protocol.Merge_Stdout => + return (Mode => Merge_Stdout, Path => <>); when Spawn.Protocol.Truncate_File => return (Mode => Truncate_File, @@ -305,7 +307,7 @@ package body Spawn_Manager_Processes is return CS.chars_ptr is begin - if Stream.Mode = Null_Stream then + if Stream.Mode /= Truncate_File then return CS.Null_Ptr; end if; declare @@ -342,6 +344,9 @@ package body Spawn_Manager_Processes is procedure Validate_Request is begin + if Request.Standard_Output.Mode = Merge_Stdout then + raise Constraint_Error with "stdout cannot refer to itself"; + end if; Require_Absolute (Value => Executable_Value, Name => "executable"); if Request.Environment_Policy = Replace then Require_Absolute (Value => Directory_Value, Name => "directory"); @@ -449,6 +454,7 @@ package body Spawn_Manager_Processes is case Mode is when Null_Stream => return 0; when Truncate_File => return 1; + when Merge_Stdout => return 2; end case; end To_C_Stream_Mode; diff --git a/tools/spawn_manager_processes.ads b/tools/spawn_manager_processes.ads index 1e8d7d7..26d6887 100644 --- a/tools/spawn_manager_processes.ads +++ b/tools/spawn_manager_processes.ads @@ -52,8 +52,8 @@ package Spawn_Manager_Processes is -- Shell requests inherit the immutable manager environment; structured -- requests replace it completely, including with an empty vector. - type Stream_Mode is (Null_Stream, Truncate_File); - -- Version 1 permits only /dev/null or a newly truncated regular output. + type Stream_Mode is (Null_Stream, Truncate_File, Merge_Stdout); + -- Stderr may duplicate stdout; other modes select null or truncate output. type Stream_Specification is record Mode : Stream_Mode := Null_Stream; From 18efbc97578a50ede9b4ebf6d75a56314c40012e Mon Sep 17 00:00:00 2001 From: mweidle73 Date: Sat, 17 Oct 2026 10:20:21 +0200 Subject: [PATCH 2/4] docs(processes): Clarify stream conversion Describe null, truncate and stderr-to-stdout mapping explicitly. Remove the stale two-mode wording without changing execution. --- tools/spawn_manager_processes.adb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/spawn_manager_processes.adb b/tools/spawn_manager_processes.adb index e1634ae..f2a3fbd 100644 --- a/tools/spawn_manager_processes.adb +++ b/tools/spawn_manager_processes.adb @@ -102,7 +102,7 @@ package body Spawn_Manager_Processes is function Convert (Stream : Spawn.Protocol.Stream_Specification_Type) return Stream_Specification; - -- Convert the two fixed protocol stream alternatives. + -- Map null, truncate and stderr-to-stdout policies to the launch model. function Convert (Stream : Spawn.Protocol.Stream_Specification_Type) From e832faf1550688d0041ba4e8a8004a405e2bf3ed Mon Sep 17 00:00:00 2001 From: mweidle73 Date: Sat, 17 Oct 2026 11:04:25 +0200 Subject: [PATCH 3/4] test(processes): Close V2 review gaps Check null-output descriptor identities in direct and manager tests. Make the shared-log permission fixture independent of test order. Both null-output oracles reject a deliberately skipped stderr dup. Align current wire and lifecycle guidance, preserve protocol page titles and clarify unchanged security requirements for both versions. Keep historical protocol documentation explicit rather than obsolete. Refresh the completed organization-transfer follow-up and rebalance documentation paragraphs without changing execution behavior. --- AGENTS.md | 13 +++++------ CONTRIBUTING.md | 2 +- SECURITY.md | 11 +++++----- WORK_QUEUE.md | 37 ++++++++++++++++---------------- doc/Makefile | 4 +++- doc/lifecycle-v1.md | 26 +++++++++++----------- doc/protocol-v2.md | 8 +++---- src/spawn-pool.adb | 8 +++---- tests/protocol-failure-manager.c | 2 +- tests/spawn-pool-tests.adb | 9 ++++++-- tests/spawn-posix-tests.c | 30 +++++++++++++++++++++++--- tests/spawn-protocol-tests.adb | 2 +- tools/spawn_manager.adb | 2 +- 13 files changed, 94 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b5ac427..4466bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,10 +17,10 @@ caller and the dedicated process manager. - Use `README.md` for the component overview, supported platform and normal build, test and installation commands. -- Read `doc/protocol-v1.md` when changing request or result fields, validation, - transport bounds or stream handling. -- Read `doc/protocol-v2.md` for the current fixed version and stderr alias; - version 1 is the historical 0.2.0 contract, not a compatibility mode. +- Read `doc/protocol-v2.md` when changing request or result fields, validation, + transport bounds or stream handling. It defines the current fixed version. +- Use `doc/protocol-v1.md` only for comparison with the historical 0.2.0 wire + contract, not as a current compatibility mode. - Read `doc/lifecycle-v1.md` when changing processes, descriptors, signals, timeouts, cancellation, cgroup callbacks or pool cleanup. - Update `CHANGELOG.md` for user- or integrator-visible changes. Keep planned @@ -114,8 +114,9 @@ caller and the dedicated process manager. - The command-string API retains `/bin/bash -o pipefail -c` behavior and the manager environment. Structured requests retain exact executable, argument, replacement-environment, working-directory, stream and result semantics. -- Each protocol version is fixed and fail-closed. Wire changes require an explicit - new version, updated format documentation and independent golden-byte tests. +- Each protocol version is fixed and fail-closed. Wire changes require an + explicit new version, updated format documentation and independent + golden-byte tests. - The post-`fork` child enters no Ada runtime code before `execve` or `_exit`. Keep the C boundary small, readable and limited to async-signal-safe child operations; explain non-obvious ownership and cleanup steps in comments. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7799f2..3f7d769 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ The shell API retains `/bin/bash -o pipefail -c` behavior. The structured API retains exact executable, argument, environment, directory, stream and result semantics. -Protocol version 1 is fixed and fail-closed. An incompatible wire change +Each protocol version is fixed and fail-closed. An incompatible wire change requires a new protocol version, updated format documentation and independent golden-byte tests. diff --git a/SECURITY.md b/SECURITY.md index 5441d03..b062f91 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,7 +54,7 @@ through, so Spawn Manager must preserve their documented boundaries exactly. - Pool sockets remain private, correctly owned and safely cleaned up. - Structured requests preserve exact arguments and replacement environments without shell parsing. -- Version 1 applies `no_new_privs` to every request child. +- Protocol versions 1 and 2 apply `no_new_privs` to every request child. - Unexpected descriptors are not inherited by request children. - Signal state is normalized without losing manager or child observability. - Each request has a supervised process group with bounded termination and @@ -74,7 +74,8 @@ Examples include: - unintended descriptor or sensitive environment inheritance; - failure to terminate or reap processes which remain within the documented process-group or caller-supplied cgroup containment boundary; -- privilege gain despite the version 1 `no_new_privs` contract; +- privilege gain despite the `no_new_privs` contract in protocol versions + 1 and 2; - unsafe post-fork behavior in the multithreaded caller boundary; - reuse of a manager whose supervision or lifecycle state is uncertain; - argument, environment, stream or working-directory confusion that crosses a @@ -106,9 +107,9 @@ realistic security impact should be reported through a public issue. The local Unix socket is not an authorization boundary against other code already running with the same operating-system identity. -Version 1 has no privilege-gaining execution mode and no individual-request -cancellation operation. Cgroup placement is supplied by trusted caller -callbacks and is not part of the wire protocol. +Protocol versions 1 and 2 have no privilege-gaining execution mode and no +individual-request cancellation operation. Cgroup placement is supplied by +trusted caller callbacks and is not part of the wire protocol. Anet owns socket lifecycle and addressing. Spawn Manager owns frame encoding, exact transfer, validation, deadlines and protocol-failure classification. diff --git a/WORK_QUEUE.md b/WORK_QUEUE.md index 4b0ec57..aa447d3 100644 --- a/WORK_QUEUE.md +++ b/WORK_QUEUE.md @@ -2,7 +2,7 @@ This is the maintained list of accepted follow-up work after the initial structured-execution delivery. It records concrete consumers and known -boundaries without silently extending protocol version 1. Assign a ticket and +boundaries without silently extending released protocols. Assign a ticket and define acceptance evidence before starting an item. ## Post-0.2.0 validation @@ -17,16 +17,15 @@ define acceptance evidence before starting an item. ## Accepted follow-ups -### Neutral GitHub organization +### GitHub organization maintenance -The canonical repository initially remains under its current personal GitHub -namespace so the Codelabs redirect and upstream transition do not depend on a -new governance decision. Reconsider a transfer to a neutral project -organization after its permanent name, repository scope and at least two -trusted organization owners are confirmed. +The canonical repository now lives under `abuild-project`. Keep repository and +Pages URLs, review applications and access permissions aligned with that +organization rather than treating the completed transfer as pending work. -Before a transfer, inventory repository and Pages URLs, Actions permissions, -installed review applications, Dependabot, secrets and branch and tag rules. +Before any later transfer, inventory repository and Pages URLs, +Actions permissions, installed review applications, Dependabot, +secrets, and branch and tag rules. Afterward, republish Pages, update the Codelabs redirect and verify web, clone, fetch, CI and release access through both the old and new repository URLs. @@ -53,25 +52,25 @@ than pretending it is argv data. ### Explicit privilege-gaining execution -Version 1 applies `no_new_privs` to every shell and structured request and has -no exception. Add an opt-in only for a concrete transformation which requires -privilege metadata during `execve`. Specify it in a new protocol contract and -require cgroup containment because privileged exec may clear the direct -leader's parent-death signal. +Protocols 1 and 2 apply `no_new_privs` to every shell and structured request +and have no exception. Add an opt-in only for a concrete transformation which +requires privilege metadata during `execve`. Specify it in a new protocol +contract and require cgroup containment because privileged exec may clear the +direct leader's parent-death signal. ### Individual request cancellation -Version 1 cancels the complete pool through `Cleanup`; it has no request -identifier or single-request cancel operation. Add this only for a concrete -concurrent consumer. Define identity, races, result classification and manager -reuse before choosing a wire format. +Protocols 1 and 2 cancel the complete pool through `Cleanup`; they have no +request identifier or single-request cancel operation. Add this only for a +concrete concurrent consumer. Define identity, races, result classification +and manager reuse before choosing a wire format. ### Descriptor-ceiling fallbacks A failed `/proc/self/fd` scan with a finite soft descriptor limit selects and caches that limit. If the limit is `RLIM_INFINITY`, the current implementation consults `sysconf(_SC_OPEN_MAX)`; its result is not yet an accepted portable -version-1 contract. Add fault injection for an unlimited limit with both +protocol-1/2 contract. Add fault injection for an unlimited limit with both procfs and `close_range` unavailable. The request must fail at descriptor closure instead of attempting an effectively unbounded sweep. diff --git a/doc/Makefile b/doc/Makefile index cb34895..e6b2312 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -48,7 +48,8 @@ $(DESTDIR)/WORK_QUEUE.html: ../WORK_QUEUE.md Makefile | $(DESTDIR) -o $@ $< $(DESTDIR)/protocol-%.html: protocol-%.md Makefile | $(DESTDIR) - $(PANDOC) $(PANDOC_OPTS) --metadata pagetitle="Protocol $*" \ + $(PANDOC) $(PANDOC_OPTS) \ + --metadata pagetitle="Protocol version $(patsubst v%,%,$*)" \ -o $@.tmp $< sed \ -e 's|href="../src/spawn-protocol.ads"|href="$(SOURCE_BASE_URL)/src/spawn-protocol.ads"|g' \ @@ -64,6 +65,7 @@ $(DESTDIR)/lifecycle-v1.html: lifecycle-v1.md Makefile | $(DESTDIR) -o $@.tmp $< sed \ -e 's|href="protocol-v1.md|href="protocol-v1.html|g' \ + -e 's|href="protocol-v2.md|href="protocol-v2.html|g' \ -e 's|href="../src/spawn-pool.ads"|href="$(SOURCE_BASE_URL)/src/spawn-pool.ads"|g' \ $@.tmp > $@ rm -f $@.tmp diff --git a/doc/lifecycle-v1.md b/doc/lifecycle-v1.md index 537a3bb..a4b8d27 100644 --- a/doc/lifecycle-v1.md +++ b/doc/lifecycle-v1.md @@ -1,9 +1,11 @@ # Spawn Manager lifecycle version 1 -This document defines the process ownership, cancellation and containment -contract of Spawn Manager version 1. The public API is declared in -[`Spawn.Pool`](../src/spawn-pool.ads); the request and result encoding is -defined by [protocol version 1](protocol-v1.md). +This document defines lifecycle contract version 1: process ownership, +cancellation and containment. The unchanged lifecycle contract applies to wire +protocol versions 1 and 2; its version is independent of the wire version. +The public API is declared in [`Spawn.Pool`](../src/spawn-pool.ads). Current +request and result encoding is defined by [protocol version 2](protocol-v2.md); +[protocol version 1](protocol-v1.md) describes the historical 0.2.0 wire format. ## Pool and leases @@ -52,9 +54,9 @@ Leader and descendant reaping after termination have fixed grace-period deadlines. An uninterruptible child therefore fail-stops its manager instead of blocking pool cancellation indefinitely. -Version 1 has no independent single-request cancellation operation. -`Spawn.Pool.Cleanup` stops new leases, interrupts all managers, waits for -active callers to leave and cancels the complete pool. +This lifecycle contract has no independent cancellation of a single request. +`Spawn.Pool.Cleanup` stops new leases, interrupts all managers, waits for active +callers to leave and cancels the complete pool. ## Privilege and descendant containment @@ -63,7 +65,7 @@ Set-user-ID, set-group-ID and file-capability metadata cannot grant the child new privileges or clear that signal during `execve`. The restriction is inherited by shell children and structured-exec descendants. -This is a deliberate version-1 compatibility boundary with no opt-out. A +This is a deliberate lifecycle compatibility boundary with no opt-out. A future exception requires a concrete consumer, an explicit request contract and cgroup containment because a privileged exec can clear the direct leader's parent-death signal. @@ -77,7 +79,7 @@ group signal requires separately selected cgroup containment. Cgroup containment is also required for descendants left by uncatchable manager loss such as `SIGKILL` or OOM, and for a descendant that deliberately -escapes with `setpgid()` or `setsid()`. Version 1 does not claim those cases +escapes with `setpgid()` or `setsid()`. This contract does not claim those cases from its process-group fallback alone. ## Signal and fork boundary @@ -102,7 +104,7 @@ group supervision. The manager binds its descriptor ceiling to `/proc/self/fd` only after a complete scan. An open, read or close error with a finite soft descriptor limit selects that limit before `fork`. With `RLIM_INFINITY`, the -implementation consults `sysconf(_SC_OPEN_MAX)` instead; version 1 does not +implementation consults `sysconf(_SC_OPEN_MAX)` instead; this contract does not claim portable support for an effectively unbounded result. If no usable finite ceiling exists and `close_range` is unavailable, the child reports the pre-exec `Close_Descriptors` stage instead of proceeding to `execve`. The Work @@ -126,11 +128,11 @@ socket-path cleanup after the manager has terminated. ## Failure classification -Version 1 reports internal and child-spawn failures through one result kind. +Protocols 1 and 2 use one result kind for internal and child-spawn failures. Stages that can represent manager-side supervision poison the complete pool, including ambiguous error-pipe and process-group failures. This also includes manager-side signal-mask restoration failures reported as `Reset_Signals`. Stages that can only represent child-side pre-exec failures remain reusable. The exact stage encoding is part of the -[protocol result format](protocol-v1.md#result). +[current protocol result format](protocol-v2.md#result). diff --git a/doc/protocol-v2.md b/doc/protocol-v2.md index c463985..0fedce1 100644 --- a/doc/protocol-v2.md +++ b/doc/protocol-v2.md @@ -117,16 +117,16 @@ its mode. A caller needing owner-only output should create the file with mode Stdout is installed first. `Merge_Stdout` on stderr duplicates fd 1 to fd 2, sharing the same open file description and write position. The file is opened and truncated only once, and stdout remains open. A null stdout gives stderr -the same null sink. Mode 2 has no path field; a stdout self-reference is rejected. -Equal truncate paths never select this policy implicitly. +the same null sink. Mode 2 has no path field; a stdout self-reference is +rejected. Equal truncate paths never select this policy implicitly. This matches shell `> log 2>&1` at the descriptor level. It preserves the order of writes reaching the shared sink, not the order of application-side stdio buffers or atomic multi-line messages from concurrent writers. Separate capture files joined later cannot implement this contract. Append, named stdin, privilege exceptions and individual cancellation are not added. -The [version-1 lifecycle](lifecycle-v1.md) remains unchanged; stdout-open and -stderr-duplication failures retain the existing stages and errno. +The [lifecycle contract version 1](lifecycle-v1.md) remains unchanged, as do +failure stages and errno for stdout-open and stderr-duplication errors. ## Result diff --git a/src/spawn-pool.adb b/src/spawn-pool.adb index e9baad9..6721265 100644 --- a/src/spawn-pool.adb +++ b/src/spawn-pool.adb @@ -903,10 +903,10 @@ package body Spawn.Pool is when Protocol.Protocol_Failed => return True; when Protocol.Spawn_Failed => - -- Version 1 retains the detailed stage but deliberately merges - -- the C core's internal and child-spawn result classes. These - -- stages can originate in manager-side supervision; fail closed - -- because the preceding request may not be contained or reaped. + -- The protocol keeps the detailed stage but merges internal and + -- child-spawn result classes. These stages can originate in + -- manager-side supervision; fail closed because the preceding + -- request may not be contained or reaped. case Result.Failure.Stage is when Protocol.No_Failure | Protocol.Enable_Subreaper diff --git a/tests/protocol-failure-manager.c b/tests/protocol-failure-manager.c index 504e96d..9889277 100644 --- a/tests/protocol-failure-manager.c +++ b/tests/protocol-failure-manager.c @@ -61,7 +61,7 @@ static uint32_t decode_u32(const unsigned char *data) int main(int argc, char *argv[]) { /* - * Independent complete V1 result frames: 12-byte big-endian header, + * Independent complete V2 result frames: 12-byte big-endian header, * followed by either kind 5 plus one string or kind 3 plus stage, * errno and one string. The environment selects the latter frame's two * stage bytes; the Ada decoder constants are not reused here. diff --git a/tests/spawn-pool-tests.adb b/tests/spawn-pool-tests.adb index 0bd88a8..ec306de 100644 --- a/tests/spawn-pool-tests.adb +++ b/tests/spawn-pool-tests.adb @@ -924,7 +924,7 @@ package body Spawn.Pool.Tests is Result : Spawn.Protocol.Result_Type; function Read_Log (Path : String) return String; - -- Read actual completed log lines, including their terminating LF. + -- Read log lines and normalize each line ending to LF. procedure Cleanup; -- Shut down managers before removing only this test's owned directory. @@ -986,10 +986,15 @@ package body Spawn.Pool.Tests is "shared log lost bytes or write order"); Request.Standard_Output := (Mode => Spawn.Protocol.Null_Stream); + Request.Arguments.Replace_Element + (2, "test -c /proc/self/fd/1 && test /proc/self/fd/1 -ef /dev/null" + & " && test -c /proc/self/fd/2" + & " && test /proc/self/fd/2 -ef /dev/null || exit 99;" + & " printf output; printf error >&2; exit 7"); Result := Spawn.Pool.Execute (Request); Assert (Result.Kind = Spawn.Protocol.Exited and then Result.Exit_Status = 7, - "shared null output failed"); + "shared null output did not connect both descriptors to null"); Request.Standard_Output := (Mode => Spawn.Protocol.Truncate_File, diff --git a/tests/spawn-posix-tests.c b/tests/spawn-posix-tests.c index 49c7265..1f39946 100644 --- a/tests/spawn-posix-tests.c +++ b/tests/spawn-posix-tests.c @@ -509,6 +509,27 @@ static int fixture_merged_output(void) return 7; } +/* Check the actual null-device identities, not merely a successful write. */ +static int fixture_null_output(void) +{ + struct stat expected; + struct stat output; + struct stat error; + + require(stat("/dev/null", &expected) == 0 && S_ISCHR(expected.st_mode), + "stat expected null device"); + require(fstat(STDOUT_FILENO, &output) == 0 + && S_ISCHR(output.st_mode) && output.st_rdev == expected.st_rdev, + "stdout is not the null device"); + require(fstat(STDERR_FILENO, &error) == 0 + && S_ISCHR(error.st_mode) && error.st_rdev == expected.st_rdev, + "stderr is not the null device"); + require(write_repeated(STDOUT_FILENO, 16, 'o') == 0 + && write_repeated(STDERR_FILENO, 16, 'e') == 0, + "write to shared null output"); + return 0; +} + /* Dispatch the fixture only after exec, outside the restricted fork window. */ static int fixture(int argc, char *argv[], char *envp[]) { @@ -518,6 +539,8 @@ static int fixture(int argc, char *argv[], char *envp[]) return fixture_verify(argc, argv, envp); if (strcmp(argv[2], "merged") == 0) return fixture_merged_output(); + if (strcmp(argv[2], "null-output") == 0) + return fixture_null_output(); if (strcmp(argv[2], "exit37") == 0) return 37; if (strcmp(argv[2], "signal") == 0) { @@ -845,7 +868,7 @@ static void test_merged_output(const char *self, const char *output_path) char *empty_environment[] = { NULL }; char *arguments[] = { (char *)self, "fixture", "merged", NULL }; char *null_arguments[] = { - "/bin/sh", "-c", "printf output; printf error >&2", NULL + (char *)self, "fixture", "null-output", NULL }; struct spawn_posix_result result; struct stat status; @@ -854,8 +877,9 @@ static void test_merged_output(const char *self, const char *output_path) int descriptor; memset(stale, 'x', sizeof(stale)); - descriptor = open(output_path, O_WRONLY | O_TRUNC); + descriptor = open(output_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); require(descriptor >= 0, "open stale shared log"); + require(fchmod(descriptor, 0600) == 0, "prepare private shared log mode"); require(write(descriptor, stale, sizeof(stale)) == (ssize_t)sizeof(stale), "write stale shared log"); require(close(descriptor) == 0, "close stale shared log"); @@ -870,7 +894,7 @@ static void test_merged_output(const char *self, const char *output_path) free(content); require(stat(output_path, &status) == 0 && (status.st_mode & 0777) == 0600, "shared log mode changed"); - require(spawn_posix_execute("/bin/sh", null_arguments, 0, + require(spawn_posix_execute(self, null_arguments, 0, empty_environment, "/", SPAWN_POSIX_NULL_STREAM, NULL, SPAWN_POSIX_MERGE_STDOUT, NULL, 1000, &result) == 0, "merge stderr with null stdout"); diff --git a/tests/spawn-protocol-tests.adb b/tests/spawn-protocol-tests.adb index 90000c1..3b83363 100644 --- a/tests/spawn-protocol-tests.adb +++ b/tests/spawn-protocol-tests.adb @@ -630,7 +630,7 @@ package body Spawn.Protocol.Tests is Golden : Ada.Streams.Stream_Element_Array; Name : String); -- Compare both encoding directions with one independently written - -- complete frame from the version 1 field table. + -- complete frame from the version 2 field table. procedure Check (Expected : Result_Type; diff --git a/tools/spawn_manager.adb b/tools/spawn_manager.adb index 8cddb61..d02be88 100644 --- a/tools/spawn_manager.adb +++ b/tools/spawn_manager.adb @@ -439,7 +439,7 @@ is return (Kind => Spawn.Protocol.Timed_Out); when Spawn_Manager_Processes.Spawn_Failed | Spawn_Manager_Processes.Internal_Error => - -- Version 1 deliberately has one execution-boundary failure + -- The protocol deliberately has one execution-boundary failure -- alternative. Stage and errno retain the actionable failure -- point; the internal-versus-spawn classification is not public. return From 9f30f84005e81e0e6573c1908870ffcfe57977b7 Mon Sep 17 00:00:00 2001 From: mweidle73 Date: Sun, 18 Oct 2026 11:19:14 +0200 Subject: [PATCH 4/4] docs(protocol): Explain historical source link Explain why the version-1 contract pins its normative source to the 0.2.0 release instead of using a checkout-relative link. The Pages build maps relative source links to current master rather than preserving the historical V1 implementation. --- doc/protocol-v1.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/protocol-v1.md b/doc/protocol-v1.md index 53b9714..c6c8438 100644 --- a/doc/protocol-v1.md +++ b/doc/protocol-v1.md @@ -16,8 +16,12 @@ protocol applies no text encoding, shell quoting or normalization. The normative constants and field order are maintained in [`Spawn.Protocol` at v0.2.0](https://github.com/abuild-project/spawn-manager/blob/v0.2.0/src/spawn-protocol.ads). -Independent golden-byte tests -pin both request variants, all result variants and every failure-stage value. + +The release URL deliberately preserves the V1 source. Relative source links +follow the current checkout on GitHub and map to `master` on the Pages site. + +Independent golden-byte tests pin both request variants, all result variants +and every failure-stage value. ## Common frame