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;