Skip to content

Commit 70598e1

Browse files
feat: cxx_stdlib, the curriculum in CI, and two criteria that machine state decided (#579)
`mcpp::cxx_stdlib()` / `MCPP_CXX_STDLIB` (2026.9.6.3). The engine has resolved this value for a long time -- it is in the cache key, the ABI tag, the toolchain fingerprint and `resolution.json` -- and never handed it to the layer that has to decide on it. `compiler()` cannot answer it: clang links libc++ on one machine and libstdc++ on another and reports "clang" either way, while the two differ in what they accept. llama.cpp-m has the case today, and its refusal lands in the same round. e2e 617 compares the answer against `resolution.json` and against the compiler family; removing the wiring turns it red. The examples say which device ran them. All four islands in 09-heterogeneous printed the same four numbers as their CPU fallback, so a silent fallback was indistinguishable from a device run -- in the one set of examples whose subject is which device ran the computation. The seam carries `saxpy_device_name()`, each backend fills in its own device, and it is set only after a successful call: a device run that did not happen has no device to report. Measured: `device: llvmpipe (LLVM 22.1.8, 256 bits)` against `device: cpu (this build names no accelerator)`. CI builds the curriculum. No job did. `.github/tools/build_examples.sh` enumerates the example ROOTS from the tree and compares them against a build list and a skip table; a root in neither fails the job, and every skip names its reason and where the coverage is. Six of fifteen build, including 05-lib-distribution through its own README's two-step order -- which also checks that the ABI tag its consumer hardcodes is still the one `mcpp pack` produces. The Vulkan example is built and RUN on the lavapipe payload. Two e2e criteria were decided by machine state, and both were green on every runner because a runner installs exactly one of anything. `206_runtime_binding_physics` asserted `inconclusive` on the assumption that the private loader cannot reach a host `libtinfo`; `xim:ncurses` is an ordinary ecosystem package, and a sub-OS that has it puts `libtinfo.so.6` on the artifact's RPATH, where the closure genuinely closes. It now reads the artifact's own search path and asserts in both directions. `168_build_mcpp_musl_host_static` selected its payload with `ls | head -1` -- lexicographic order, hence the oldest -- and on a machine with three musl toolchains chose one that predates the `std` module. It takes the newest, which is what resolution picks when nothing pins a version. The four device examples move to `mcpp:plugins` 0.2.1; the SYCL one also declares `xim:glibc` and `xim:linux-headers`, unpinned, because the device compiler is a second compiler and does not inherit the toolchain the engine configured. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent 6c00d45 commit 70598e1

40 files changed

Lines changed: 823 additions & 54 deletions

.agents/docs/2026-09-06-ecosystem-plan-v3.md

Lines changed: 78 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,45 @@ forced rather than chosen.
235235
| R4 | done | `llvmpipe (LLVM 22.1.8, 256 bits)`, 7/7 layers offloaded, host token 471 = device token 471; the chat example generates 32 tokens |
236236
| R6 | written | a `vulkan` job on a GPU-less runner, gated on the lavapipe payload and `GGML_VK_VISIBLE_DEVICES=0` |
237237
| R7 | done | docs/20 and docs/zh/20, "What a framework looks like on top of this" |
238-
| R5 | blocked on release order | the index entry can only name an artefact that exists, so it follows llama.cpp-m's tag |
238+
| R5 | done, with a second lever | mcpp-index#359 carries `b10069.1`. It also had to raise the index's CI pin: `validate.yml` was on 2026.8.27.2, ten releases behind, and this package's build program calls an accessor from 2026.9.5.2. `min_mcpp` deliberately did NOT move -- see 7e |
239239

240240
**The release order is the reverse of the dependency order**, as it always is
241241
here: mcpp 2026.9.6.2 first (llama.cpp-m's CI pins it), then llama.cpp-m
242242
`b10069.1`, then the index entry that names that tag. The index PR for
243243
`compat:spirv-headers` went first and separately because llama.cpp-m cannot
244244
build without it -- splitting it out was forced by the cycle, not chosen.
245245

246+
## 7e. The index's CI pin is not its floor
247+
248+
`ggml-org:llamacpp@b10069.1` failed three of mcpp-index's workspace jobs with
249+
250+
error: 'toolchain_sysroot' is not a member of 'mcpp'
251+
252+
because `validate.yml` pinned mcpp 2026.8.27.2 while that accessor is
253+
2026.9.5.2+. Two levers exist and they govern different things:
254+
255+
| | governs | moves when |
256+
|---|---|---|
257+
| `index.toml [index] min_mcpp` | descriptor GRAMMAR -- the oldest mcpp able to resolve every descriptor | a descriptor uses a new key, in lock-step with the CI pin |
258+
| `validate.yml MCPP_VERSION` | which mcpp the index builds its members with | the engine moves; a pin that lags validates the index against an engine no user runs |
259+
260+
A build program's API belongs to the second. Raising `min_mcpp` would refuse
261+
the WHOLE index (E0006) to a client on the floor over one package's
262+
build-program call it may never reach, which is the failure mode
263+
`index-floor-must-degrade` names: the index is data, mcpp is the program, and
264+
publishing data must not invalidate the program. Measured: all 218 descriptors
265+
parse under both versions, so the grammar did not move; `b10069` stays
266+
published for a client that cannot use `b10069.1`.
267+
268+
The cost is paid once per raise: the members' caches key on `MCPP_VERSION`, so
269+
the first run after the pin moves rebuilds everything.
270+
271+
This is not a one-off. `b10069.2` calls `mcpp::cxx_stdlib()` (2026.9.6.3), so
272+
the same five-step chain runs again: mcpp release, xim-pkgindex bump, package
273+
change, package release, index bump carrying the new pin. mcpp has no
274+
per-package engine floor, so a client on an older engine gets
275+
`'X' is not a member of 'mcpp'` rather than a refusal that names a version.
276+
246277
### What the angles decide
247278

248279
**Architecture.** The shader pipeline is 136 edges in the build graph, not one
@@ -319,27 +350,52 @@ libraries, is closed: F1 measured 134 device objects inside `libllama.a` with
319350
a consumer's link line, since `cudart_static` plus 186 objects is the largest
320351
link this ecosystem has attempted.
321352

322-
## 7d. Open, with the reason each is open
323-
324-
**`mcpp::toolchain_stdlib()`.** mcpp resolves `stdlibId` and writes it into
325-
`resolution.json`; the build-program environment does not carry it. A build
326-
program driving a SECOND compiler may need it, and llama.cpp-m has the case
327-
today -- `backend-vulkan` cannot compile under libc++ (upstream's own source),
328-
and the refusal it would like to write is impossible because the only available
329-
signal is the compiler's NAME, which would also refuse clang with libstdc++.
330-
Same family as the two defects this round fixed. Deliberately not shipped
331-
alone: without a consumer in the same release it is a recorded field with no
332-
reader, which is the shape this ecosystem keeps finding.
333-
334-
**No CI job builds an example.** e2e 616 checks that the curriculum and its
335-
index agree structurally, and says so: it builds nothing. The Vulkan example
336-
needs no GPU, so it is the one that could join CI first.
337-
338-
**`206_runtime_binding_physics` is decided by machine state.** It asserts a
339-
status that depends on the private loader's default prefix NOT existing, and a
340-
leftover `fromsource-x-glibc@2.39/lib` makes it exist. Green in CI, red on a
341-
developer machine that has one. The criterion should name the directory it
342-
depends on rather than assume its absence.
353+
## 7d. Closed in round 5b, and what remains open
354+
355+
Round 5 left four items recorded rather than done, on the stated ground that
356+
each needed a consumer or a measurement it did not yet have. Three are now
357+
closed; the reasons they were open turned out to be partly wrong, and saying so
358+
is the point of writing them down.
359+
360+
**`mcpp::cxx_stdlib()` -- done, 2026.9.6.3.** Shipped as `MCPP_CXX_STDLIB` and
361+
`mcpp::cxx_stdlib()`, with its consumer in the same round: llama.cpp-m's
362+
`backend-vulkan` refuses a libc++ toolchain by name instead of handing the user
363+
a page of errors from an upstream header. The name changed from the one
364+
recorded here -- `cxx` is in it because `MCPP_TARGET_LIBC` is the C library,
365+
and in an ecosystem that names glibc and musl constantly the two must not share
366+
a word. Its criterion (e2e 617) compares the answer against `resolution.json`
367+
and against the compiler family, and was checked by removing the wiring and
368+
watching it go red.
369+
370+
**No CI job builds an example -- done.** `.github/tools/build_examples.sh`
371+
enumerates the example ROOTS from the tree and compares them against a build
372+
list and a skip table; a root in neither fails the job, and every skip carries
373+
its reason and where the coverage actually is. Six of fifteen build, including
374+
`05-lib-distribution` through its own README's two-step order, which also
375+
checks that the ABI tag the consumer hardcodes is still the tag `mcpp pack`
376+
produces. The Vulkan example is built AND RUN on the lavapipe payload.
377+
378+
That run needed one more change. All four device examples printed the same four
379+
numbers as their CPU fallback, so a run that silently fell back was
380+
indistinguishable from a device run -- in a curriculum whose subject is
381+
heterogeneous compute. The seam now carries `saxpy_device_name()`, each backend
382+
fills in its own device, and `main` prints it after the call, never before.
383+
384+
**`206_runtime_binding_physics` -- done, and the recorded reason was wrong.**
385+
It was not a stale `fromsource-x-glibc` prefix. `xim:ncurses` is an ordinary
386+
ecosystem package, and a sub-OS that has it links `libtinfo.so.6` into the
387+
library view that IS on the artifact's RPATH; there the closure genuinely
388+
closes and `pass` is the correct verdict. The test now reads the artifact's own
389+
runtime search path and decides which verdict the model owes, so it fails in
390+
both directions instead of assuming a directory is absent.
391+
392+
A second test had the same shape and was found while fixing the first:
393+
`168_build_mcpp_musl_host_static` selected its musl payload with `ls | head -1`
394+
-- lexicographic order, hence the OLDEST installed version. On a machine with
395+
13.3.0, 15.1.0 and 16.1.0 it chose 13.3.0, which predates the `std` module, and
396+
the error it produced described the test's own choice. It now takes the newest,
397+
which is what resolution picks when nothing pins a version. Both were green on
398+
every CI runner, because a runner installs exactly one of anything.
343399

344400
**The four examples' CPU fallback does not generalise.** Each writes
345401
`cfg(not(accelerator = "<its own>"))`, which is correct for one backend and

.agents/docs/2026-09-06-round5-verify.sh

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#!/usr/bin/env bash
2-
# Ecosystem verification for round 5: mcpp 2026.9.6.2, compat:spirv-headers,
3-
# and ggml-org:llamacpp's backend-vulkan feature.
2+
# Ecosystem verification for rounds 5 and 5b: mcpp (2026.9.6.2, then
3+
# 2026.9.6.3), compat:spirv-headers, ggml-org:llamacpp's backend-vulkan
4+
# feature, and the build-program API a release carries embedded in its binary.
45
#
56
# # The sandbox has an EMPTY $HOME and a fresh /tmp, so this file is not
67
# # visible from inside it. Pass the script itself in:
78
# B64=$(base64 -w0 <this file>)
8-
# xlings subos use verify-962 --sandbox --cmd \
9-
# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.6.2 bash /tmp/v.sh"
9+
# xlings subos use verify-963 --sandbox --cmd \
10+
# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.6.3 bash /tmp/v.sh"
1011
#
1112
# `xlings subos use <name>` -- the bare `xlings subos <name>` form this header
1213
# used to give is rejected as an unknown subcommand, and a sandbox that has to
@@ -300,6 +301,61 @@ else
300301
tail -20 "$work/consumer.log"
301302
fi
302303

304+
# -- G. the build-program API the published binary carries ------------------
305+
section "G. mcpp::cxx_stdlib() answers, in the published binary"
306+
# A BUILD-PROGRAM API IS PART OF THE PUBLISHED FORM, and nothing above tests
307+
# that. The `mcpp` module a build program imports is EMBEDDED IN THE BINARY --
308+
# so a release whose module source did not travel would compile every project
309+
# in this repository (they use a checkout) and fail for the first user who
310+
# wrote `mcpp::cxx_stdlib()`. That is the shape this ecosystem keeps meeting:
311+
# the development form and the published form are not the same graph.
312+
#
313+
# The criterion is the ANSWER, not the compile: an accessor that exists and
314+
# returns "" would compile here and be useless, so the value is compared
315+
# against `resolution.json`, which the same binary writes from its own
316+
# resolution.
317+
probe="$work/stdlibq"
318+
mkdir -p "$probe/src"
319+
printf 'int main() { return 0; }\n' > "$probe/src/main.cpp"
320+
cat > "$probe/mcpp.toml" <<'TOML'
321+
[package]
322+
name = "stdlibq"
323+
version = "0.1.0"
324+
[targets.stdlibq]
325+
kind = "bin"
326+
main = "src/main.cpp"
327+
TOML
328+
cat > "$probe/build.mcpp" <<'CPP'
329+
#include <cstdio>
330+
#include <string>
331+
import mcpp;
332+
int main() {
333+
// To a file: a build program's stdout reaches the user only when it fails.
334+
std::string out = std::string(mcpp::manifest_dir()) + "/answer.txt";
335+
std::FILE* f = std::fopen(out.c_str(), "w");
336+
if (f == nullptr) return 1;
337+
const char* s = mcpp::cxx_stdlib();
338+
std::fprintf(f, "%s\n", s == nullptr ? "" : s);
339+
std::fclose(f);
340+
return 0;
341+
}
342+
CPP
343+
if (cd "$probe" && "$STORE" build >"$work/stdlibq.log" 2>&1); then
344+
answered=$(cat "$probe/answer.txt" 2>/dev/null | head -1)
345+
engine=$(find "$probe/target" -name resolution.json -print -quit 2>/dev/null \
346+
| xargs -r sed -n 's/.*"stdlib"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
347+
if [ -z "$answered" ]; then
348+
fail "G: mcpp::cxx_stdlib() answered nothing"
349+
elif [ "$answered" != "$engine" ]; then
350+
fail "G: the build program read '$answered', the engine resolved '$engine'"
351+
else
352+
ok "G: mcpp::cxx_stdlib() = '$answered', matching this binary's own resolution.json"
353+
fi
354+
else
355+
fail "G: a build program calling mcpp::cxx_stdlib() did not build against the published $VER"
356+
tail -12 "$work/stdlibq.log"
357+
fi
358+
303359
printf '\n== summary ==\n'
304360
if [ -n "$skipped" ]; then
305361
printf 'NOT RUN, and therefore not verified:%s\n' "$skipped"

.github/tools/build_examples.sh

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env bash
2+
# Build every example in `examples/` that this runner can build.
3+
#
4+
# WHY THIS EXISTS. Until it did, no CI job built any example. e2e 616 checks
5+
# that the curriculum and the table documenting it agree structurally, and says
6+
# so explicitly: it builds nothing. Every example here could have stopped
7+
# compiling and the first report would have come from a reader.
8+
#
9+
# THE LIST IS DERIVED FROM THE TREE. `BUILD` and `SKIP` are compared against
10+
# the roots actually on disk, and a root in neither list fails this script.
11+
# That is the denominator: adding an example forces a decision about whether CI
12+
# can build it, instead of leaving it silently uncovered.
13+
#
14+
# A SKIP CARRIES ITS REASON AND ITS ELSEWHERE. "CI cannot build this" is only
15+
# acceptable when something else does, and each entry says what.
16+
set -uo pipefail
17+
18+
MCPP="${MCPP:?set MCPP}"
19+
cd "$(dirname "$0")/../.."
20+
21+
BUILD=(
22+
examples/01-hello
23+
examples/02-with-deps
24+
examples/03-pack-static
25+
examples/04-workspace
26+
examples/08-build-rules/app
27+
)
28+
29+
# `key|reason`.
30+
SKIP=(
31+
"examples/05-lib-distribution/producer|packed and then consumed by the dedicated step below, which is the order the example's own README gives"
32+
"examples/05-lib-distribution/consumer|same"
33+
"examples/06-openkal-cross|cross-builds to a second target; the payload matrix is what the target-matrix workflow already covers, and repeating it here would double a long job for no new signal"
34+
"examples/07-project-subos|provisions a project-local sub-OS, which e2e 27_self_contained_home covers directly and far more cheaply"
35+
"examples/08-build-rules/rules-embed|a rule package is not a standalone build: its interface imports the bundled mcpp module, which exists only inside a consumer's build. Verified by building 08-build-rules/app, the same way mcpp-plugins verifies its own members"
36+
"examples/08-build-rules/rules-tidy|same"
37+
"examples/09-heterogeneous/cuda/app|needs the CUDA payload set, and a device to run; the rule package is covered by mcpp-plugins' own CI"
38+
"examples/09-heterogeneous/hip/app|same, for the HIP payloads"
39+
"examples/09-heterogeneous/sycl/app|needs the dpcpp payload (over a gigabyte) and a device its runtime accepts"
40+
"examples/09-heterogeneous/vulkan/app|built AND RUN by the next step of this job, on the lavapipe payload, which needs no GPU"
41+
)
42+
43+
# Every ROOT manifest in the tree: a directory with an `mcpp.toml` that has no
44+
# ancestor manifest below `examples/`. A workspace member is not a root.
45+
mapfile -t FOUND < <(
46+
find examples -name mcpp.toml -not -path '*/target/*' | while read -r m; do
47+
d=$(dirname "$m"); p=$(dirname "$d"); root=1
48+
while [ "$p" != "." ] && [ "$p" != "examples" ]; do
49+
[ -f "$p/mcpp.toml" ] && { root=0; break; }
50+
p=$(dirname "$p")
51+
done
52+
[ "$root" = 1 ] && echo "$d"
53+
done | sort -u
54+
)
55+
56+
fail=0
57+
declare -A known=()
58+
for b in "${BUILD[@]}"; do known["$b"]=build; done
59+
for s in "${SKIP[@]}"; do known["${s%%|*}"]=skip; done
60+
61+
echo "== the tree has ${#FOUND[@]} example roots =="
62+
for f in "${FOUND[@]}"; do
63+
if [ -z "${known[$f]:-}" ]; then
64+
echo "ERROR: $f is in neither BUILD nor SKIP."
65+
echo " Add it to one. A skip must say why, and where it is covered instead."
66+
fail=1
67+
fi
68+
done
69+
for k in "${!known[@]}"; do
70+
printf '%s\n' "${FOUND[@]}" | grep -Fxq "$k" || {
71+
echo "ERROR: $k is listed here but is not a root in the tree."
72+
fail=1
73+
}
74+
done
75+
[ "$fail" -eq 0 ] || exit 1
76+
77+
for s in "${SKIP[@]}"; do
78+
echo "SKIP ${s%%|*}: ${s#*|}"
79+
done
80+
81+
built=0
82+
for d in "${BUILD[@]}"; do
83+
echo "== $d =="
84+
if (cd "$d" && "$MCPP" build); then built=$((built + 1)); else
85+
echo "FAIL: $d did not build"; fail=1
86+
fi
87+
done
88+
89+
# ── 05-lib-distribution: pack, then consume ────────────────────────────────
90+
# The consumer names the produced directory by its ABI TAG, which is what the
91+
# README tells a reader to do. Building it here is therefore also a check that
92+
# the tag in the manifest is still the tag `mcpp pack` produces -- a claim the
93+
# example makes in prose and nothing else verified.
94+
echo "== examples/05-lib-distribution (pack, then consume) =="
95+
if (cd examples/05-lib-distribution/producer && "$MCPP" pack mathkit); then
96+
named=$(sed -n 's/.*path = "\(\.\.\/producer[^"]*\)".*/\1/p' \
97+
examples/05-lib-distribution/consumer/mcpp.toml | head -1)
98+
if [ -d "examples/05-lib-distribution/consumer/$named" ]; then
99+
if (cd examples/05-lib-distribution/consumer && "$MCPP" build); then
100+
built=$((built + 1))
101+
else
102+
echo "FAIL: the consumer did not build"; fail=1
103+
fi
104+
else
105+
echo "FAIL: the consumer names '$named', which mcpp pack did not produce."
106+
echo " Produced:"; ls examples/05-lib-distribution/producer/target/dist 2>&1 | sed 's/^/ /'
107+
fail=1
108+
fi
109+
else
110+
echo "FAIL: mcpp pack mathkit"; fail=1
111+
fi
112+
113+
echo "== built $built of $(( ${#BUILD[@]} + 1 )) =="
114+
[ "$built" -eq $(( ${#BUILD[@]} + 1 )) ] || fail=1
115+
exit "$fail"

.github/workflows/ci-linux.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,61 @@ jobs:
243243
"$MCPP" self config --mirror GLOBAL
244244
"$MCPP" build
245245
"$MCPP" run
246+
247+
# THE CURRICULUM WAS NEVER BUILT BY ANY JOB. e2e 616 checks that the examples
248+
# and the table that documents them agree, and says so explicitly: it builds
249+
# nothing. Every example in this repository could therefore stop compiling and
250+
# the only signal would be a user reporting it.
251+
#
252+
# The list is DERIVED FROM THE TREE, not written here. An example that is in
253+
# neither the build list nor the skip table fails this job, so adding one
254+
# forces a decision about whether CI can build it rather than silently
255+
# leaving it uncovered.
256+
examples:
257+
name: "examples: the curriculum builds"
258+
runs-on: ubuntu-24.04
259+
# Generous rather than tight: six cold example builds plus the Vulkan one,
260+
# which provisions the shader compiler and a software driver as payloads.
261+
timeout-minutes: 90
262+
steps:
263+
- uses: actions/checkout@v4
264+
- uses: ./.github/actions/bootstrap-mcpp
265+
266+
- name: Build mcpp from source (self-host)
267+
run: |
268+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
269+
"$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true
270+
"$MCPP" self config --mirror GLOBAL 2>/dev/null || true
271+
"$MCPP" build
272+
MCPP_FRESH=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
273+
cp "$MCPP_FRESH" /tmp/mcpp-fresh
274+
echo "MCPP=/tmp/mcpp-fresh" >> "$GITHUB_ENV"
275+
276+
- name: Build every example the runner can build
277+
run: |
278+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
279+
bash .github/tools/build_examples.sh
280+
281+
- name: "Vulkan example: build the device half and run it on lavapipe"
282+
run: |
283+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
284+
cd examples/09-heterogeneous/vulkan/app
285+
"$MCPP" build
286+
# BOTH STORES, because which one holds the payload is decided by how
287+
# this mcpp resolved its home -- and a `find` over only one of them
288+
# is the defect this repository has already paid for twice (e2e 614,
289+
# llama.cpp-m's Vulkan job). Ask for the file, not for a layout.
290+
icd=$(find "${MCPP_HOME:-$HOME/.mcpp}/registry/data/xpkgs/xim-x-mesa-lavapipe" \
291+
"$HOME/.xlings/data/xpkgs/xim-x-mesa-lavapipe" \
292+
-name 'lvp_icd.x86_64.json' -print -quit 2>/dev/null || true)
293+
[ -n "$icd" ] || { echo "no lavapipe ICD in either store"; exit 1; }
294+
echo "using ICD $icd"
295+
out=$(VK_DRIVER_FILES="$icd" "$MCPP" run 2>&1) || { echo "$out"; exit 1; }
296+
echo "$out"
297+
# The device leg is the point: a run that silently fell back to the
298+
# CPU variant would print the same numbers, so the assertion is that
299+
# the program says which device answered AND that it is the payload's.
300+
echo "$out" | grep -q 'llvmpipe' \
301+
|| { echo "the run did not reach the lavapipe device"; exit 1; }
302+
echo "$out" | grep -q '12 24 36 48' \
303+
|| { echo "the device produced the wrong result"; exit 1; }

0 commit comments

Comments
 (0)