The --json machine event stream (v1)
This is the normative specification of the machine-readable event stream
mtest writes under --json PATH or --json -. It is the stable contract a CI
system or other tool consumes instead of parsing the informal console text. The
console reporter's layout is free to change; this stream is not — everything
below is frozen at stream version 1 except where the versioning rules (§11)
explicitly permit additive growth.
The stream is the machine twin of the console: it carries the same typed events the session emits, one event per line. The console renders them as English; this renders them as JSON.
1. Framing
The stream is NDJSON: a sequence of UTF-8 lines, each a single complete JSON
object, each terminated by a single \n (U+000A). There is no wrapping
array, no comma between records, no pretty-printing, and no blank line. Every
string value is valid, escaped UTF-8; a value that originates as raw
child-process bytes is decoded lossily to UTF-8 (invalid sequences become
U+FFFD) before escaping, so a record can never carry a raw control byte or
an unpaired surrogate.
The v1 stream carries no floating-point values at all — no Infinity,
-Infinity, or NaN, and no fractional number. Every quantity is an integer, a
boolean, a string, or an object/array of those. A strict consumer should
configure its JSON parser to reject the non-finite tokens and to reject
duplicate object keys; a well-formed record never produces either.
1.1 The header line
The first line of every stream is the header:
{"event":"stream","version":1,"generator":"mtest <version>"}
version is the integer stream version (frozen at 1). generator is
"mtest " followed by the mtest version string, JSON-escaped. The header is the
only record whose event is stream; every subsequent record is one session
event.
2. The events
Every record after the header opens with "event":"<name>" in snake_case and
then mirrors the landed event's payload fields 1:1 under their own names,
with a single naming exception (the *_us duration rule, §3). Closed-vocabulary
values (outcome, parse_disposition, attribution_disposition) serialize as
their frozen lowercase string tokens; counts, indices, and the termination
discriminants serialize as bare integers; booleans as true/false.
The event names, in the order they can appear (§7):
session_started, warning, precompile_failed, file_started,
attempt_finished, file_finished, crash_attribution, collection_known,
internal_error, test_reported, session_finished.
The model's progress kind is not on this list and never appears on the
wire: it is ephemeral and console-only, rendered live to a TTY counter and
never serialized. The stream reporter drops it before serialization, so a
consumer never sees a progress record and never a blank line in its place.
2.1 session_started
| field | type | notes |
|---|---|---|
root |
string | invocation root |
toolchain |
string | resolved compiler path |
selected_count |
int | files selected to run |
excluded_count |
int | files removed by --exclude |
shard_label |
string | e.g. "2/5", or "" when unsharded |
sharded_out_count |
int | files handed to other shards |
workers |
int | resolved worker count; 1 for a sequential run |
config_file |
string | lexically normalized root-relative path when inside the invocation root, normalized absolute path when outside it, or "" when none |
2.2 warning
| field | type | notes |
|---|---|---|
warning_kind |
string | e.g. stale-exclusion, skipped-symlink, skipped-nonregular, compile-kill-residual, lf-stale, lf-empty, cache-off, cache-publish, cache-clear, cache-rebuild |
warning_pattern |
string | the offending pattern / detail |
warning_kind is an open vocabulary. Consumers must accept unknown strings as
valid warnings.
2.3 precompile_failed
| field | type | notes |
|---|---|---|
step |
string | the precompile source |
compiler_output |
string | head+tail bounded (§4) |
compiler_output_omitted_bytes |
int | omission metadata for the above |
casualty_count |
int | authoritative dependent-file count |
casualties |
array<string> | bounded list (§4) of dependent files |
casualties_omitted |
int | entries elided from casualties |
casualties_omitted_bytes |
int | bytes elided from the kept entries (§4) |
ending_known |
bool | whether term_* names a real ending |
term_kind |
int | termination discriminant (§6) |
term_value |
int | termination value (§6) |
escalated |
bool | SIGKILL escalation at the deadline |
timeout_us |
int | the enforced compile deadline (§3) |
attempts_used |
int | attempts the retry budget spent |
2.4 file_started
| field | type | notes |
|---|---|---|
path |
string | root-relative file path |
2.5 attempt_finished
One per non-final crash-class retry attempt (a TRY line on the console).
| field | type | notes |
|---|---|---|
path |
string | root-relative file path |
step |
string | build, run, or precompile |
attempt_index |
int | 1-based attempt number |
attempts_planned |
int | total planned attempts |
term_kind |
int | this attempt's termination discriminant (§6) |
term_value |
int | this attempt's termination value (§6) |
term_final_kind |
int | the FINAL disposition discriminant (§6) |
term_final_value |
int | the FINAL disposition value (§6) |
escalated |
bool | SIGKILL escalation |
retry_eligible |
bool | whether the failure class is retried |
classification |
string | the retry-class label |
duration_us |
int | attempt wall time (§3) |
captured_stdout |
string | bounded at capture; no omission field |
captured_stderr |
string | bounded at capture; no omission field |
stdout_truncated |
bool | capture-level overflow flag |
stderr_truncated |
bool | capture-level overflow flag |
attempt_argv |
array<string> | bounded list (§4) |
attempt_argv_omitted |
int | entries elided from attempt_argv |
attempt_argv_omitted_bytes |
int | bytes elided from the kept entries (§4) |
2.6 file_finished
The file's verdict.
| field | type | notes |
|---|---|---|
path |
string | root-relative file path |
outcome |
outcome token | §5 |
duration_us |
int | run wall time (§3) |
build_argv |
array<string> | bounded list (§4) |
build_argv_omitted |
int | entries elided from build_argv |
build_argv_omitted_bytes |
int | bytes elided from the kept entries (§4) |
build_duration_us |
int | build wall time (§3) |
captured_stdout |
string | head+tail bounded (§4) |
stdout_capture_bytes |
int | retained stdout bytes (pre-escape) |
stdout_stream_omitted_bytes |
int | bytes elided from the inlined window |
captured_stderr |
string | head+tail bounded (§4) |
stderr_capture_bytes |
int | retained stderr bytes (pre-escape) |
stderr_stream_omitted_bytes |
int | bytes elided from the inlined window |
stdout_truncated |
bool | capture-level overflow flag (authoritative) |
stderr_truncated |
bool | capture-level overflow flag (authoritative) |
signal_number |
int | terminating signal for a CRASH (else 0) |
exit_status |
int | exit code for a FAIL (else 0) |
timeout_us |
int | the enforced deadline for a TIMEOUT (§3) |
exclusion_pattern |
string | the pattern for an EXCLUDED file |
parse_disposition |
disposition token | §5 |
passed_tests |
int | per-test tally |
failed_tests |
int | per-test tally |
skipped_tests |
int | per-test tally |
deselected_tests |
int | per-test tally |
attempts_used |
int | attempts spent |
flaky |
bool | passed only after a crash-class retry |
slow |
bool | a step crossed the SLOW threshold |
escalated |
bool | SIGKILL escalation on a TIMEOUT |
serial |
bool | ran on the sequential path rather than a worker |
2.7 crash_attribution
Secondary diagnostic evidence for one crashed file; never a verdict.
| field | type | notes |
|---|---|---|
path |
string | root-relative file path |
attribution_disposition |
disposition token | §5 |
culprit_test |
string | the named culprit, when attributed |
isolation_reruns |
int | isolation reruns performed |
attribution_us |
int | attribution wall time (§3) |
2.8 collection_known
| field | type | notes |
|---|---|---|
selected_test_total |
int | selected tests |
deselected_test_total |
int | deselected tests |
2.9 internal_error
| field | type | notes |
|---|---|---|
step |
string | build, run, or precompile |
program |
string | the program mtest tried to spawn |
errno |
int | spawn errno, or 0 for a machinery failure |
2.10 test_reported
One per retrospective per-test row of a parsed report.
| field | type | notes |
|---|---|---|
path |
string | the file's root-relative path |
name |
string | the test name |
outcome |
outcome token | §5 |
detail |
string | head+tail bounded (§4) assertion detail |
detail_omitted_bytes |
int | bytes elided from detail |
timing |
string | the raw timing string |
2.11 session_finished
The single terminal record (§8).
| field | type | notes |
|---|---|---|
summary |
object | 13 outcome-token keys → int (see below) |
wall_time_us |
int | session wall time (§3) |
exit_code |
int | the exit code resolved at session end; the process exit is authoritative and may be escalated afterward (§9) |
test_counts |
object | {passed,failed,skipped,deselected} → int |
flaky_files |
int | files that passed only after a retry |
built_files |
int | files admitted at a first-attempt compile, compile failures included; retries, probes and precompile steps never count |
cached_files |
int | files admitted from a build-cache hit |
built_files + cached_files equals the run's first-attempt compile admissions,
gates included. cached_files alone is 0 when the cache served nothing — a run
with the cache off, and every cold store; the compiles those runs admit are
counted in built_files like any other. Both are 0 only for a run that
compiled nothing at all.
summary is a stable, fully-enumerated object of the 13 outcome tokens in
discriminant order:
{"pass":N,"fail":N,"skip":N,"crash":N,"timeout":N,"compile_error":N,
"compile_timeout":N,"malformed_suite":N,"precompile_error":N,"flaky":N,
"deselected":N,"excluded":N,"not_run":N}
3. Durations: the *_seconds → *_us rule (the SOLE naming exception)
Every duration the model carries as *_seconds is emitted as an
integer-microsecond *_us field — the one place a serialized field name
differs from its model name — because the v1 stream carries no floats. The
conversion clamps a negative (or NaN) input to 0, rounds half away from
zero, and saturates at 2**63 - 1.
The *_us fields, exhaustively:
| field | kinds |
|---|---|
duration_us |
file_finished, attempt_finished |
build_duration_us |
file_finished |
wall_time_us |
session_finished |
attribution_us |
crash_attribution |
timeout_us |
precompile_failed, file_finished |
timeout_us is derived from an integer-second configured deadline (exact
× 1_000_000), not from a measured elapsed time; the other four are measured
wall times.
4. Bounds, omission metadata, and the worst-case line
Every variable-length field is individually bounded at serialization, so no single field — and therefore no single line — can grow without limit. There is no unbounded field anywhere in the stream.
| bounded field | bound | retained bytes (pre-escape) |
|---|---|---|
captured_stdout / captured_stderr (file_finished) |
64 KiB head + 64 KiB tail | ≤ 131 075 each |
compiler_output, detail |
64 KiB head + 64 KiB tail | ≤ 131 075 |
build_argv / attempt_argv / casualties |
256 entries × 4 KiB head+tail | ≤ 1 049 344 |
runner strings (path, toolchain, patterns, names, timing, …) |
3 KiB head + 1 KiB tail window | ≤ 4 099 |
captured_stdout / captured_stderr (attempt_finished) |
whole (clamped at capture) | ≤ 131 072 each |
A head+tail window keeps the first 64 KiB and the last 64 KiB with a visible
elision marker (…) between them.
Derivable omission metadata. A bounded field rides beside metadata a consumer can use to detect and quantify elision — never an invented "original total":
*_omitted_bytes(compiler_output_omitted_bytes,detail_omitted_bytes,stdout_stream_omitted_bytes,stderr_stream_omitted_bytes) — bytes elided from an inlined text/stream window.*_capture_bytes(stdout_capture_bytes,stderr_capture_bytes) — the bytes actually retained by capture (pre-escape).*_omitted(build_argv_omitted,attempt_argv_omitted,casualties_omitted) — entries elided from a bounded list.*_omitted_byteson a bounded LIST (build_argv_omitted_bytes,attempt_argv_omitted_bytes,casualties_omitted_bytes) — total bytes elided from the KEPT entries by the per-element 4 KiB bound, so per-element truncation of a user-supplied value (a longbuild_argventry) is quantifiable, not just the count of whole entries dropped. A capped element itself is kept as a head+tail window with the visible elision marker (…) between, never a silent cut; the scalar runner strings use the same visible marker (a formality cap that carries no separate count, since their truncation is self-evident in the value).- the capture-level truncation booleans (
stdout_truncated,stderr_truncated) — the authoritative statement that capture itself overflowed, distinct from the serializer's own window elision.
The real worst-case line. Because each captured stream is bounded by a
64 KiB head and a 64 KiB tail, the worst-case single line is bounded but is
not under 1 MiB. A file_finished whose two captured streams are entirely
C0 control bytes escapes each retained 128 KiB window roughly six-fold (each
control byte becomes a \u00XX escape), to ≈ 0.77 MiB per stream — ≈ 1.5 MiB
from the two streams together — and a maximally-populated build_argv
(256 × 4 KiB, likewise escapable) adds more on top. The guarantee is that every
field is individually bounded, not that a pathological line stays under any
particular round number. Realistic lines are a few KiB.
5. Token vocabularies
outcome — one of:
pass, fail, skip, crash, timeout, compile_error, compile_timeout,
malformed_suite, precompile_error, flaky, deselected, excluded,
not_run.
parse_disposition — one of:
parsed, no_report, ambiguous, drift, capture_overflow.
attribution_disposition — one of:
attributed, no_reproduction, probe_failed, run_cap, time_budget.
These tokens are frozen at v1.
6. The termination discriminants (term_*)
term_kind, term_value, term_final_kind, and term_final_value are plain
integers, not tokens: the model carries a child's termination as a decomposed
integer pair, and the stream mirrors it so the record stays self-describing.
term_kind (and term_final_kind) vocabulary:
| value | meaning | term_value (and term_final_value) carries |
|---|---|---|
0 |
EXITED | the process exit code |
1 |
SIGNALED | the terminating signal number |
2 |
TIMED_OUT | a deadline mtest enforced (value carries no code) |
3 |
SPAWN_FAILED | the errno from a failed spawn |
On attempt_finished, the term_* pair is this attempt's raw termination and
the term_final_* pair is the disposition after any SIGKILL escalation.
7. Ordering
Informally, a stream reads as a timeline: the header, then the run begins, files are built and run in scheduling order, and the session ends. Formally, these split invariants are frozen:
Per session:
1. the stream header is line 1;
2. session_started is the first event;
3. every precompile attempt/warning and any precompile_failed precede any
per-file event;
4. crash_attribution records (the bounded post-pass) follow every per-file
verdict;
5. session_finished is last (§8).
Per file:
6. a file's test_reported rows are contiguous and immediately precede that
file's file_finished;
7. a file's attempt_finished records appear in strictly increasing
attempt_index order and all precede that file's file_finished.
The stream is otherwise the session's emission order.
8. Terminal semantics
The session dispatches exactly one session_finished event in every
scenario — a normal finish, an interrupt, or a fatal abort — carrying the final
resolved exit_code. The stream therefore carries zero or one parseable
terminal record:
- one on any stream that survived to finalization;
- zero (or a torn final fragment) on a stream whose destination died before the terminal was written.
That absence is the truncation signal, never a defect. A consumer that
reaches EOF without a session_finished record — or with a trailing fragment
that does not parse — knows the run was cut short (interrupted, killed, or its
destination went away), and should treat the run as incomplete rather than
assume success.
An interrupt (SIGINT/SIGTERM) during a run still ends the stream with the
terminal record and exit_code 2.
9. Writing, truncation, and dead destinations
Each line is written to the destination through a write_all loop that
advances on a partial write and retries EINTR; the writer never assumes one
system call drains the buffer. A record is only committed when its terminating
\n is written, so a stream that is cut mid-write leaves complete lines plus at
most one torn (unterminated) final fragment — never a corrupt interior line.
SIGPIPE is ignored for the run's duration, so a write to a broken
destination pipe returns EPIPE instead of killing mtest. A latched write
failure on the stream — a --json - consumer that closed early (mtest --json -
| head), a full or unwritable file — is a fatal abort: mtest stops
scheduling, best-effort finalizes the other artifacts, and resolves exit 3.
It never dies at 141, and it never runs to completion writing into a void.
When the stream is an owned file destination (--json PATH, not -), the
session_finished record carries the exit_code resolved at session end, then
that file is closed. A destination that defers its error to close(2) — an
ENOSPC/EIO a network filesystem reports only at close — is detected after
the terminal record is already committed, and escalates the process exit to
3 (it never lowers a resolved 2/3). So the committed record can read
"exit_code": 0 while the process exits 3: the process exit status is
authoritative, and a consumer that also cares about durability should treat a
nonzero process exit as overriding a 0 in the record. On --json - there is
no owned close, so this divergence cannot arise.
10. Determinism: the comparison projection
Two runs of the same inputs produce streams that are equal under a closed projection — the projection a determinism check should compare, ignoring the fields that legitimately vary run to run.
Included (deterministic; compare these):
- every
outcome,parse_disposition, andattribution_dispositiontoken; - the per-test set from
test_reported(path,name,outcome); - the counts and totals (
passed_tests/failed_tests/skipped_tests/deselected_tests,test_counts, thesummaryobject,selected_count/excluded_count/sharded_out_count,collection_known); - the boolean flags (
flaky,slow,escalated,retry_eligible,stdout_truncated,stderr_truncated,ending_known); - the casualty list (
casualties) andcasualty_count; attempts_used,attempt_index,attempts_planned,flaky_files;- the configured
timeout_us(derived from a config integer); - the final
exit_code.
Excluded (vary run to run; do NOT compare):
- every measured duration: the
*_usfieldsduration_us,build_duration_us,wall_time_us, andattribution_us, andtest_reported.timing— the per-test wall clock the suite itself reported, carried verbatim as the decimal-seconds string it was parsed from and so the one measured duration that is not spelled*_us; - every byte-payload field and its omission metadata:
captured_stdout,captured_stderr,compiler_output,detail, together withstdout_capture_bytes,stderr_capture_bytes,stdout_stream_omitted_bytes,stderr_stream_omitted_bytes,compiler_output_omitted_bytes,detail_omitted_bytes,build_argv/attempt_argv/casualtiesbyte content and their*_omittedcounts; - the
generatorstring (carries the version label); - the build-cache split
built_files/cached_files— their SUM is stable for identical inputs (it is the first-attempt compile admission count), but how it divides depends on what the store already held when the run started.
The excluded set is exactly the run-to-run-variable surface; everything else is byte-stable for identical inputs.
11. Versioning
The stream is versioned by the single integer on the header line, and only
there. Version 1 freezes: the NDJSON framing, the header shape, every event
name, every field name and its meaning, the token vocabularies (§5), and the
termination discriminants (§6).
Growth within a major version is additive only: a later v1 stream may add
new fields to existing records and new event kinds. A consumer MUST ignore
unknown fields and unknown event kinds — that tolerance is the compatibility
contract, and a conforming consumer never rejects a record merely for carrying a
field or an event value it does not recognize. Any removal or
meaning-change of a frozen field, or a framing change, bumps the header
version.
12. A worked consumer skeleton
The following demonstrates the required discipline: strict where the format is frozen, tolerant of unknown fields and kinds.
import json
def _reject_non_finite(tok):
raise ValueError(f"non-finite token forbidden in the v1 stream: {tok}")
def _reject_dupes(pairs):
out = {}
for k, v in pairs:
if k in out:
raise ValueError(f"duplicate key: {k}")
out[k] = v
return out
def consume(fileobj):
records, terminal, torn = [], None, False
header_seen = False
pending = None # a line not yet known to be newline-terminated
for raw in fileobj: # iterating yields lines WITH their trailing "\n"
if not raw.endswith("\n"):
torn = True # a final fragment with no newline: truncation
break
line = raw[:-1]
rec = json.loads(line, parse_constant=_reject_non_finite,
object_pairs_hook=_reject_dupes)
if not header_seen:
assert rec.get("event") == "stream" and rec.get("version") == 1
header_seen = True
continue
kind = rec.get("event")
if kind == "session_finished":
assert terminal is None # exactly one terminal
terminal = rec
elif kind == "file_finished":
handle_verdict(rec) # KNOWN fields only
# ANY other 'kind' — including one this consumer has never heard of —
# is silently ignored: that is the forward-compatibility contract.
if terminal is None:
report_truncated_run(torn) # absence == truncation signal
return records, terminal
The consumer reads unknown kinds without failing, treats a missing terminal as a truncated run, and rejects the two things the format forbids (non-finite tokens, duplicate keys). That is the whole contract.