Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions ci/spawn-edge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ci/spawn-edge — the spawn-edge acceptance gate (spec-32 executable edge)

The **regression tripwire** for the driver spawn-plan bug class:
tamatebako/ruby#121 (`tfs_spawn_plan_apply` dropping the plan's argv[0],
so the child's flag parse shifted and its mounts fell out) and
tamatebako/tebako#691 (`system("xml2rfc", …)` from a dispatched payload
failing with "spawn plan failed without a message") both shipped green
through every lighter gate and surfaced **days later in a full metanorma
compile** — no factory gate exercised "a payload array-spawns a child
through a spec-32 `kind: executable` edge". This harness is that gate.
It is deliberately tiny: two fixture payloads, two spawn forms, seconds
per leg — not a suite.

## What it proves

The consumer payload's manifest declares

```yaml
requires:
- kind: executable
name: spawn-edge-echo
payload: spawn-edge-provider
constraint: ">= 1.0"
expose: [spawn-edge-echo]
critical: true
```

and its entry script (`fixtures/spawn-edge-probe.rb`) array-spawns the
exposed name twice — `system("spawn-edge-echo", "alpha", "beta gamma",
"--flag=x", out: …)` and the `IO.popen` pipe form. The runtime's spawn
hook plans the PROVIDER payload's own dispatch as the child (the provider
image mounted at `/` in the child, the exposed entrypoint run there, the
child's runtime resolved cache-only from the scratch store); the provider
command (`fixtures/spawn-edge-echo.rb`) echoes its argv, and the probe
asserts the child received the exact vector — the space-carrying token
proves no shell re-split, the flag-shaped token proves no option
re-parse, and a shifted/garbled plan (the argv[0] class) mismatches
loudly. A plan failure raises in the parent and is named in the PROBE
line, never an unhandled backtrace.

One `PROBE spawn-edge <leg> ok|fail <detail>` line per leg
(`system-array`, `popen-array`); the harness pins both plus the child's
echoed argv line. Verdict: `SPAWN-EDGE-ACCEPTANCE-OK <ruby> (<triplet>)`
(`SPAWN-EDGE-MSYS-ACCEPTANCE-OK` on windows); a named `FAIL spawn-edge
(…)` otherwise.

## Inputs

Both scripts **build nothing** — the runtime under test is the factory
build leg's own artifact set, the press/readback tool is the leg's
pin-verified tfs CLI:

| env | meaning |
|---|---|
| `RUNTIME_PKG_DIR` | the leg's runtime-packages dir (one `tebako-runtime-<tv>-<lv>-<triplet>[.exe]` + its `.tfs`; on windows also the package-named ruby `.dll`) |
| `RUBY_VERSION` | the leg's ruby version (e.g. `4.0.7`) |
| `TEBAKO_VERSION` | the leg's tebako version (e.g. `0.16.33`) |
| `TFS_CLI` | the leg's pin-verified tfs CLI |
| `SCRATCH` | optional; default `/tmp/spawn-edge[-msys]-scratch-<ruby>-<triplet>` |

The scripts stage a scratch tebako store (spec 05 §3's grammar): the
leg's runtime pair under `runtimes/ruby-<lv>-<tv>-<triplet>/` (on windows
renamed to the scan's synthesized `.exe` spelling, with the PE-named DLL
copy beside it) and the provider under `payloads/spawn-edge-provider/`,
its manifest mirror **copied from the pressed image** (`tfs cat` — the
store's embedded-wins rule, and the readback doubles as the press
assertion). The parent boot is a hand-rolled dispatch, so the spawn
resolves the spec 32 §5 **unlocked** edge cache-only — no
`TEBAKO_SPAWN_LOCK` is composed.

## Run

```sh
RUNTIME_PKG_DIR=/path/to/runtime-packages RUBY_VERSION=4.0.7 \
TEBAKO_VERSION=0.16.33 TFS_CLI=/path/to/tfs \
ci/spawn-edge/run.sh # POSIX (linux-gnu, macos; musl inside alpine)

# windows (msys shell):
RUNTIME_PKG_DIR=… RUBY_VERSION=4.0.7 TEBAKO_VERSION=0.16.33 \
TFS_CLI=/path/to/tfs.exe ci/spawn-edge/run-msys.sh
```

Consumed by the runtime factory's build legs
(tebako-runtime-ruby's `_build-platform.yml`), which run the gate per
ruby line × platform after the boot smoke and before the leg-complete
marker — a red gate ships nothing. Runnable by hand against any built or
published runtime package with the same inputs; delete the scratch dir to
re-run from scratch (every stage rebuilds on every run regardless — all
of it is sub-second).
42 changes: 42 additions & 0 deletions ci/spawn-edge/fixtures/consumer-manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Payload manifest for the spawn-edge CONSUMER image (spec 03), pressed to
# /__tpkg__/manifest.yaml by run.sh / run-msys.sh. The digest placeholders
# are stamped by mkimage at press time (spec 03 §7 fixed-point rule).
#
# The consumer carries the edge under test (spec 32 §1, schema_minor 5):
# a `kind: executable` requirement whose `expose:` list opens the SPAWN
# surface — a bare-name spawn of `spawn-edge-echo` from the probe is
# planned as the provider payload's own spec-17 dispatch in a child
# process. `payload:` pins the provider by name (the store holds exactly
# one candidate, but the pin keeps the edge unambiguous by construction);
# `critical: true` is the producer obligation — a reader predating
# schema_minor 5 must refuse the edge, never skip it silently. The edge
# declares no `mount:` — the provider's VFS surface stays out of the
# parent (spawn-only, the shape the metanorma→xml2rfc edge ships).
identity:
schema_version: 1
kind: app
name: spawn-edge-consumer
version: "1.0.0"
producer: {tool: spawn-edge, tool_version: "1"}
created: "2026-09-30T00:00:00Z"
digest:
tree_hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
blob_sha256: "0000000000000000000000000000000000000000000000000000000000000000"
signing: {state: unsigned}
encryption: {state: none}
provides:
entrypoints:
- name: spawn-edge-probe
path: /spawn-edge-probe.rb
# Declarative only — the harness execs the leg's runtime directly;
# nothing resolves through this constraint.
runtime_requirement: {engine: ruby, constraint: ">= 3.1, < 5.0"}
platforms: universal
capabilities: {exec: true, read: true}
requires:
- kind: executable
name: spawn-edge-echo
payload: spawn-edge-provider
constraint: ">= 1.0"
expose: [spawn-edge-echo]
critical: true
35 changes: 35 additions & 0 deletions ci/spawn-edge/fixtures/provider-manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Payload manifest for the spawn-edge PROVIDER image (spec 03), pressed to
# /__tpkg__/manifest.yaml by run.sh / run-msys.sh. The digest placeholders
# are stamped by mkimage at press time (spec 03 §7 fixed-point rule: the
# tree hash excludes /__tpkg__/; blob_sha256 stays as authored — advisory
# producer provenance, never a verification input for an embedded
# manifest).
#
# The provider is the spawn TARGET: its single entrypoint carries a
# runtime_requirement (spec 32 §1 — a runtime-less entry has no spawn
# form), and the driver's plan reads this declaration from the store
# manifest mirror (spec 32 §2, locked — run.sh installs exactly the
# pressed, stamped copy as the mirror).
identity:
schema_version: 1
kind: app
name: spawn-edge-provider
version: "1.0.0"
producer: {tool: spawn-edge, tool_version: "1"}
created: "2026-09-30T00:00:00Z"
digest:
tree_hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
blob_sha256: "0000000000000000000000000000000000000000000000000000000000000000"
signing: {state: unsigned}
encryption: {state: none}
provides:
entrypoints:
- name: spawn-edge-echo
path: /spawn-edge-echo.rb
# The whole ruby catalog: the gate runs per ruby line, and the
# child's runtime resolves cache-only against this constraint.
runtime_requirement: {engine: ruby, constraint: ">= 3.1, < 5.0"}
# Pressed per leg and never distributed; the platform axis is not what
# this acceptance exercises.
platforms: universal
capabilities: {exec: true, read: true}
10 changes: 10 additions & 0 deletions ci/spawn-edge/fixtures/spawn-edge-echo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

# spawn-edge-echo.rb — the provider payload's exposed command, dispatched
# as a child process through the spec-32 kind: executable edge's spawn
# surface. Echoes its argv on one line; the verdict belongs to the
# consumer (spawn-edge-probe.rb), which compares byte-for-byte. The
# self-locating line proves the child booted with the provider image
# mounted ("/" on POSIX, "A:/" on msys) rather than anything host-side.
puts "SPAWN-EDGE-CHILD argv=#{ARGV.inspect}"
puts "SPAWN-EDGE-CHILD file=#{__FILE__}"
70 changes: 70 additions & 0 deletions ci/spawn-edge/fixtures/spawn-edge-probe.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

# spawn-edge-probe.rb — the consumer payload's entry. Exercises the
# spec-32 spawn surface: this payload's manifest declares a
# `kind: executable` edge with `expose: [spawn-edge-echo]`, so a bare-name
# spawn of "spawn-edge-echo" is intercepted by the runtime's spawn hook
# and re-planned as the provider payload's own dispatch (the provider
# image co-mounted in the child, the exposed entrypoint run there). Two
# legs, both array-form (never a shell — a shell string is a different,
# unplanned surface):
#
# system-array — system("spawn-edge-echo", "alpha", "beta gamma",
# "--flag=x", out: <capture>), the metanorma/xml2rfc
# call shape (tebako#691);
# popen-array — IO.popen(["spawn-edge-echo", ...]) — the pipe form.
#
# Each leg asserts the child exited 0 and echoed the EXACT argv (the
# space-carrying token proves the argv vector survives un-re-split; the
# flag-shaped token proves no option re-parse). The regression this
# tripwire exists for (ruby#121: the plan's argv[0] dropped at apply, so
# the child's flag parse shifted and its mounts/entry fell out) fails
# both legs at once. A plan failure raises in the parent — the rescue
# names it instead of dying on an unhandled exception line.
#
# Prints one `PROBE spawn-edge <leg> ok|fail <detail>` line per leg plus
# a PROBE-DIAG line carrying the captured child output (the proof log is
# the only place the child's stdout lands for the redirect/pipe forms).
# Exits 1 on the first failed leg, 0 when both pass.

COMMAND = "spawn-edge-echo"
ARGS = ["alpha", "beta gamma", "--flag=x"].freeze
WANT = "SPAWN-EDGE-CHILD argv=#{ARGS.inspect}".freeze

def fail!(leg, detail)
puts "PROBE spawn-edge #{leg} fail #{detail}"
exit 1
end

def judge(leg, ok, out)
# Raw lines, not inspect: the proof log is the only place the child's
# stdout lands for the redirect/pipe forms, and the harness pins the
# child's echo line verbatim.
out.each_line { |line| puts "PROBE-DIAG #{leg} child: #{line}" }
fail!(leg, "the child did not exit 0 — its stderr rides this log") unless ok
unless out.lines.map(&:chomp).include?(WANT)
fail!(leg, "child argv mismatch — want #{WANT.inspect}, got #{out.strip.inspect}")
end
puts "PROBE spawn-edge #{leg} ok"
end

# Leg 1: array-form system(), the child's stdout captured through a spawn
# redirect (cwd is the harness-owned run dir — no absolute path crosses
# the spawn boundary, so nothing rides the carried-mount rewrite).
begin
capture = File.expand_path("spawn-edge-system.out", Dir.pwd)
ok = system(COMMAND, *ARGS, out: capture)
out = File.file?(capture) ? File.read(capture) : ""
rescue StandardError => e
fail!("system-array", "the spawn raised #{e.class}: #{e.message.lines.first.to_s.strip}")
end
judge("system-array", ok == true, out)

# Leg 2: array-form IO.popen (the pipe twin).
begin
out = IO.popen([COMMAND, *ARGS], &:read)
status = $?
rescue StandardError => e
fail!("popen-array", "the spawn raised #{e.class}: #{e.message.lines.first.to_s.strip}")
end
judge("popen-array", status&.success? == true, out)
Loading
Loading