Preserve E2E run evidence - #784
Draft
chrisgleissner wants to merge 15 commits into
Draft
Conversation
Buffer the boot log before syslog is configured, flush it from a failing assert, and report the send failure and overflow counts over REST.
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An E2E run currently leaves a console log and nothing else. When a suite fails
overnight, that log is all a reader has: no screen, no device log, no record of
what the device was doing at the time, and no way to re-run the single check
that failed without reading the suite's source.
This branch gives a run a directory and a reader.
run-tests -o DIRkeeps the whole run: every check, suite, health sweep anddevice request as JSONL, each suite's console log, the screens the suites
read, and the state captured when a check failed.
python3 tools/e2e_report.py DIRwritesDIR/index.mdwith no device attached: a status line you can grep, then every failing check
with its screen, its log tail, and the command that runs it again.
--syslogadds the devices' own log to the same directory.--recordadds a video of the harness's screen beside the device's, with thedevice's audio, subtitled with the check that was running.
The same branch makes the Ultimate II+L and the C64 Ultimate first-class runner
targets alongside the Ultimate 64, because a report is only worth having if the
gate covers more than one machine.
Five firmware files change. All five are in the device-log path, and the
section below explains each one and why the change has to be in the firmware
rather than in the harness.
This work and the machine-code monitor work were developed on one branch and
have now been separated. The monitor changes are in #785, which is based on
this branch and should be merged after it.
What a run keeps
-o DIRwritesindex.mdat the top and one directory per target under it.Inside a target's directory:
run.jsonl<label>-<suite>.jsonl<label>-<suite>.logscreens.jsonlcapture/.pngand.txtpairsyslog.txt--syslogvideo.mp4,video.srt--recordA suite run is identified as
target/label/suite/attemptand a check is thatplus its index, and a suite run's file names are that key with the target
dropped and
/written-. The record shapes are documented intests/lib/README.md. The full specification the implementation was writtenagainst is
tests/e2e/doc/observability-spec.md.One rule runs through all of it: nothing the observability layer does may
change a verdict or an exit status. An output directory that cannot be created
is reported once and the run continues. A log file that cannot be written is
found when the collector starts rather than on the first datagram. An interval
that a component could not observe is recorded as a gap with a start and an
end, or with no end when it was still open, and shown on the timeline beside
the suite that was running at the time.
The report
index.mdis Markdown and nothing else, so GitHub renders it,lessreads it and
grepsearches it. It opens with a fixed 15-line preamble and aone-line verdict, then gives each failing check the screen it was looking at,
the tail of its log, the facts the run already knew about it, and the exact
command that reproduces it. A coverage section says what the run did not do:
what was planned but absent, and what skipped and why.
A killed run is rendered as a killed run rather than as a pass with missing
rows.
The device's own log
--syslogstarts a collector on the host, points every target's log at it, andchecks at both ends of the run that each device is still configured to send it.
The device setting is boot-time state rather than run configuration, so the
runner reports a device that is not configured rather than reconfiguring it
mid-run.
The report shows the device log as a slice around each failure, which is where
it earns its place: a suite that fails because the firmware asserted has the
assertion text and the task list sitting next to the failing check.
Firmware changes, and why they belong in the firmware
Five files under
software/change, and they are all one piece of work: makingthe device's log usable as evidence. The harness cannot recover a log the
device never sent, so each of these is a change only the firmware can make.
network/syslog.{cc,h}The boot log had nowhere to go.
custom_outbytewas pointed at the syslogsink only after
InitFunction::executeAll()returned. The product versionbanner, the FPGA capabilities line and every init function's output therefore
reached the hardware UART and nothing else. A test runner has no UART. The
buffer is now opened before that output starts and the destination is decided
afterwards, so those lines are held and then forwarded.
A device with no syslog server configured now allocates the 16 KB buffer during
init and frees it again at the end of init, where before it never allocated it.
That transient is the cost of capturing the boot log, and
close_bufferiswhat keeps the steady state unchanged. This matters most on the U2, whose heap
is the tightest.
A caller that is about to halt could not get its message out.
flush()sends whatever the forwarding task has not sent yet, from the calling task. It
sends blocks rather than lines because there is no time left to throttle, and
the collector splits a block back into lines. It refuses to run on the lwIP
thread, where a socket call would wait on the thread it is running on.
The buffer is now touched by two tasks, so the locking had to follow.
charout's bounds check and its store moved inside the safe section togetherwith the cursor, because
close_buffercan free the buffer from another taskand a check made outside the section would be made against a pointer that can
be freed before the store lands.
linestartposbecame a member so that a flushand the forwarding task rewind the buffer and that cursor together. The
forwarding loop now computes its span and checks it before calling
memchr,because a negative count passed as a
size_twould read four billion bytes.Two counters were added, for datagrams the stack refused and for times the
buffer filled before it drained. Neither can be reported through the log itself
without risking a loop, so both are read over REST.
The destructor also changes from
delete buftodelete[] buf. The buffer isallocated with
new char[], so the previous form was undefined behaviour.system/assert.cvAssertCalledentered the critical section, then printed the assertion andthe task list, then spun forever. Inside that critical section the syslog task
never runs again, so the one message worth having never left the machine. It
now prints, flushes, and then enters the critical section.
The cost is real and is stated in the code: printing outside the critical
section lets another task interleave characters into the same output, and the
task list is a snapshot taken with the scheduler still running. A message that
arrives interleaved is still worth more than one that never arrives.
syslog_flushis declared weak becauseassert.cis linked into applicationsthat have no syslog at all, and a null pointer is the correct answer there.
application/ultimate/ultimate.ccDefines the
syslog_flushthatassert.ccalls, opens the log buffer beforethe boot output starts, and closes it again when no destination turns out to be
configured.
api/routes.cc/v1/inforeportssyslog_failed_sendsandsyslog_overflows. A devicelogging to an address where nothing is listening is harmless to a run and
completely silent, so without these two numbers a lossy link and a quiet device
look identical from the host. The health sweep reads them, and the report shows
them.
Recording
--recordis off by default: it costs the device two streams and the LAN theirbandwidth for the length of the run. When it is on, the recorder writes the
harness's screen beside the device's video, with the device's audio, at 10
frames a second and lossless by default because the material is 40-column text
that a lossy encode blurs. Each frame carries a burned-in timecode, and the
report tells a reader the exact timecode to seek to for a given check. One
chapter per suite run, and an SRT track naming the check.
ffmpegwithlibx264rgbis the only new external dependency, and only forthis flag.
Three machines, one gate
A target is a host, or
cartridge@computerfor a cartridge under test in thecomputer that supplies its C64 keyboard and video. The Ultimate II+L in a C64
Ultimate is written
u2@c64u.tests/lib/targets.pyanswers where every surface of a device is: REST, FTP,Telnet, keyboard injection, video, audio and logs.
tests/lib/machine.pyidentifies the machine once from
/v1/infoand answers what it is and whichfirmware fixes its line does not have yet. A check that cannot run on a machine
therefore skips with the reason rather than failing, and
--assume-fix=NAMEruns it anyway, which is how a backport is confirmed.
Targets that do not share a physical machine run at the same time, each in its
own child process, with every output line naming the target it came from.
Targets that do share one take turns.
The suites
Every suite now reports through
tests/lib/report.py, so harness lines andsuite lines follow one set of rules and the JSONL records have one shape. The
suites were also adapted to the C64 Ultimate, whose menu differs from the
Ultimate 64's: a launcher in front of the file browser, a different task menu
key, and browser letters that mean navigation rather than search.
Concurrent targets no longer collide over FTP ports.
cfg-partial-effectuateis a new manual suite, split out of the CFG coverage because it needs an
operator decision.
Command line
-j/--jsonl-dirbecomes-o/--output-dir, with no alias. The directory holdsevery artifact a run keeps, not only JSONL, and keeping the old name would have
described a third of what it does.
./run-tests --helpis regrouped by what a reader is looking for rather thanby the order the options were added, and gained an examples section and an exit
status table.
--color auto|always|neveris one implementation shared by everyprogram here;
autoincludes GitHub Actions, which renders the escapes withoutbeing a terminal.
CI
.github/workflows/build.ymlgains one step:make observability_test. Itneeds no device and no network beyond loopback, it takes under 30 seconds, and
it runs before the firmware builds so that a broken report generator is
reported now rather than after an hour of building.
.github/workflows/e2e.ymlis new and is the hardware gate. It is a separateworkflow rather than a job in
build.ymlbecause it runs on differenthardware, takes 15 to 30 minutes, must not run twice concurrently against one
set of devices, and should be distinguishable from a red build at a glance. It
runs on a schedule rather than per push, because the devices are physical and
shared.
It will not run at all until a self-hosted runner carries the
e2elabel onthe device LAN. That is an operator decision about a machine rather than a
repository change, and until somebody makes it the workflow is valid and never
triggers.
tests/e2e/doc/self-hosted-runner.mdis what that person needs: whatthe machine has to provide, how firmware under test gets onto the devices by
JTAG or through the device's own updater, and what a run leaves behind.
Verification
Device-free, and run by CI on every push:
make observability_test: 124 cases against a loopback device double, greenin under 30 seconds. It covers the record shapes, the report's byte-for-byte
output against a fixture, reproducibility, the gap and coverage rules, and
the failure paths of every collector.
tests/lib/runner_policy_test.py: 64 checks on the health, recovery andretry policy.
tests/lib/check_transport_usage.py: 62 files checked for suites growingtheir own transport code.
On hardware, with firmware built from this branch and deployed to both devices
under test:
RESULT: OK targets=3 suites=69 ok=69 fail=0 warn=0 skip=0 recoveries=0 exit=0,with 1699 checks recorded.
frames_padded,frames_shed,frames_reordered,packets_malformedandmenu_failedare 0 on all three, and the video timingwas detected rather than assumed:
ntscon the Ultimate 64,palon the C64Ultimate and on the U2+L inside it.
The artifacts of that run were then checked against the run's own records,
using the recorder's arithmetic and the firmware character set rather than a
description of either:
timecode rendered from
glyphs.Canvasand compared pixel by pixel. Alltwelve match, so a screenshot of any frame seeks back to itself.
kind=checkrecord theyname, and each cue start was recomputed from
capture.started,capture.lead_inand the check's own interval. The worst disagreement is1 ms, which is the rounding to the
.srtmillisecond field.their suite record puts them at. In every case zero pixels differ across the
201376-pixel picture area; the only differences are in the borders the
stamp, the pane labels and the progress bar are drawn into, which a still
deliberately omits.
kind=capturerecord at 10 frames a second, and its geometry matches what that record
claims.
1078 file paths it names exist. The two that do not are naming examples in
its own "How to read this" section.
The observability layer was also audited against earlier recorded runs rather
than only through its own tests. The output directory's records, console logs,
screens, captures and device log were cross-checked against each other and
against the console. The video's duration, frame count, chapters and subtitles
were cross-checked against the check records. The burned-in timecode was
compared pixel for pixel against what the report tells a reader to seek to, and
every still was reproduced pixel for pixel by the video frame at its moment.
That audit found seven defects. Each is fixed here with a test that fails
against the previous behaviour:
card, so a screenshot did not seek back to itself.
record contradicting the first. A suite of 39 checks reported 43.
suite looks like to the report.
that caused the failure was in the screen spool and nowhere in the report.
syslog-unmapped.txtwas left unexplained.gate's
E2E_SYSLOG_OWNEDand its own scripted runs then started nocollector. This one appears only when several targets are driven at once,
which is the case the concurrent run exists to cover.
Recording health on hardware:
frames_shed0,frames_padded0,packets_dropped0,packets_malformed0,frames_reordered0, an audiotrack carrying signal rather than silence, and the video timing detected rather
than assumed (
ntscon the Ultimate 64,palon the U2+L in its C64Ultimate).
Turning the observability features on does not change a verdict: the monitor
suite on
u64reaches the same result with and without--recordand--syslog.Known limitations
The E2E workflow has never run. It needs a self-hosted runner carrying the
e2elabel on the device LAN. Everything the workflow calls was run by handfor this branch.
A full sweep loses recorder frames to the suites.
frames_lostacross a23-suite sweep was 6 on c64u, 4927 on u2@c64u and 14187 on u64, against 34610,
64767 and 55409 frames completed. Several suites take the video stream for
their own captures, and the recorder receives nothing while they hold it. The
capture record carries this as a re-arm count, so a reader can see that the run
competed for the stream. The file itself stays complete: every recording's
duration equals its frame count at 10 frames a second, with one chapter per
suite run.
Three findings in the report and the sidecar are known and not addressed
here. The
## Screenssection labels each still with its kind but not withits
mm:ssoffset into the recording, which OBS-3.23 asks for. Therunner-policysuite drives its policy fixtures through the same reportingmodule, so their synthetic suite records land in the run's JSONL and a fully
green run shows six
incompleterows in the verdict table and six entriesunder
## Failing checks. And about a fifth of the subtitle cues have zeroduration, because the check they name measured under a millisecond; they are
greppable, which is what the sidecar is for, but a player shows nothing for
them.
The C64 Ultimate stops answering after sustained driving, and only mains
power brings it back. This happened twice during this gate: once its UI task
blocked in a modal that RUN/STOP would not release while every listener still
answered, and once it stopped answering REST altogether. Both followed hours of
continuous driving, and
machine:rebootreturns 200 and clears neither. Therunner handles it correctly, abandoning the run rather than failing every
remaining suite for the same reason.
That machine also answers about ten times slower than the Ultimate 64. The
health sweep's
machine:readmemof$D012takes a median of 271, 274 and276ms across three complete
u2@c64usweeps, against 26ms on the Ultimate 64.