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..4466bf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +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` 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 @@ -112,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. -- Protocol version 1 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/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/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/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/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 1386880..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. @@ -37,8 +36,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. @@ -52,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 9d2999c..e6b2312 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,16 @@ $(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 version $(patsubst v%,%,$*)" \ -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 @@ -59,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-v1.md b/doc/protocol-v1.md index 4c4b1cb..c6c8438 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,8 +15,13 @@ 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. +[`Spawn.Protocol` at v0.2.0](https://github.com/abuild-project/spawn-manager/blob/v0.2.0/src/spawn-protocol.ads). + +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 diff --git a/doc/protocol-v2.md b/doc/protocol-v2.md new file mode 100644 index 0000000..0fedce1 --- /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 [lifecycle contract version 1](lifecycle-v1.md) remains unchanged, as do +failure stages and errno for stdout-open and stderr-duplication errors. + +## 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-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/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..9889277 100644 --- a/tests/protocol-failure-manager.c +++ b/tests/protocol-failure-manager.c @@ -61,17 +61,17 @@ 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. */ 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..ec306de 100644 --- a/tests/spawn-pool-tests.adb +++ b/tests/spawn-pool-tests.adb @@ -914,6 +914,183 @@ 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 log lines and normalize each line ending to 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); + 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 did not connect both descriptors to null"); + + 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 +1578,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..1f39946 100644 --- a/tests/spawn-posix-tests.c +++ b/tests/spawn-posix-tests.c @@ -485,12 +485,62 @@ 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; +} + +/* 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[]) { 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], "null-output") == 0) + return fixture_null_output(); if (strcmp(argv[2], "exit37") == 0) return 37; if (strcmp(argv[2], "signal") == 0) { @@ -810,6 +860,55 @@ 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[] = { + (char *)self, "fixture", "null-output", 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_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"); + 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(self, 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 +1758,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..3b83363 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"); @@ -565,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; @@ -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.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 diff --git a/tools/spawn_manager_processes.adb b/tools/spawn_manager_processes.adb index a4006d2..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) @@ -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;