TriggerEngine::CheckSample returned early in every state but ARMED, so an
edge arriving while a capture was being collected or handed out was
dropped, and the automatic rearm then waited for a FRESH edge. The engine
was therefore blind from its own trigger point until the capture had been
harvested — a post-window — and for the holdoff on top of that.
On a sparse pulse train that rounds the capture spacing up to a whole
pulse period: at the default 1 s window the blind stretch is 1 s, so a
1 Hz train was caught at 0.5 Hz and a wider window lost whole multiples.
The comparator now keeps running through COLLECTING and TRIGGERED and
remembers the first edge at or past trigTime + max(postSec, holdoffSec).
The holdoff guards against re-triggering on the ringing of the same
event and is measured from the trigger point, so it overlaps the
post-window rather than adding to it. Rearm() fires on the remembered
edge; it also keeps the tracked level, so the first sample after it has
a real predecessor instead of being spent seeding one.
Arm() stays the operator's arm and discards the held edge — they asked
for the next event, not one already been and gone — and SetConfig() and
Disarm() drop it too, since it was never judged against the new window.
This is the same defect and the same remedy already validated in the Go
hub (wshub/trigger.go, trigger_sporadic_test.go); the C++ hub had been
left with the original semantics.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reported as samples sporadically carrying a previous packet's timestamp:
holes on one side of the stream and collisions on the other, in both the
Go and the MARTe2 receiver. That it appeared in both is what located it
-- the shared cause is upstream of either client. Four independent
defects, all of which end in a packet's values being placed at a time
that is not theirs.
Reassembly slot exhaustion (the "Reassembly slots full; evicting oldest"
flood). Chunk size was learnt only from fragment 0, so an out-of-order
burst destroyed a packet whose bytes had all arrived and left the slot
occupied until the 2 s GC. Slots were keyed on the counter alone, but
DATA and CONFIG number independently, so equal counters merged the two
streams. The 32-byte received-mask covered 256 of the 512 fragments the
client accepts, so a duplicate above 255 was counted as new and the
packet was delivered with a hole of stale bytes in it. And one datagram
was read per Execute(), which cannot drain a fast producer. Fixed with a
pendingTail deferral, (counter, type) keying, a 64-byte mask, a
256-datagram drain, counter-age slot reclamation, and a 1 Hz aggregated
warning in place of the per-eviction flood.
UDPStreamer dropping whole Accumulate batches. EventSem::ResetWait is
Reset-then-Wait, so a Post() landing while the sender thread was inside
ServiceClients()/SendData() was destroyed by the next Reset. The batch
was then skipped with dataReady false, readyFill was never cleared, and
the following flush overwrote it: an entire run of RT cycles never
reached the wire. The record of pending work now lives in the buffers
rather than in the semaphore edge, which also removes up to
UDPS_DATA_WAIT_MS of latency; genuine backpressure overwrites are
counted and reported. Against the unfixed code the new test sees
2999/3000 batches never consumed.
Period inflation after loss. Accumulated scalars carry no SamplingRate,
so the receiver derives dt from the sender-clock gap -- but dividing it
by the previous packet's sample count is only right while nothing is
lost. One loss doubles the reported period, which spreads a batch a full
batch past its own end and into the range the next packet claims. That
is the hole and the collision, exactly. Inferring the cycle count from
the estimate's own period is not a way out: it has a stable fixed point
wherever gap/dt is an integer, so a real rate change locks it at the old
one for good (AccumDtGTest.FollowsSustainedRateChange).
The packet counter removes the ambiguity, so all three receivers now
order on it: a DATA packet that does not advance the counter is dropped
rather than delivered, because its values are older than data already
handed over. Ordering is on the signed difference so it survives the
uint32 wrap, and the sequence resets on reconnect, where the producer's
counter restarts independently of ours. The loss count that falls out of
the same delta feeds the period estimate as cycles = prevN * (1 + lost),
which reduces exactly to gap/prevN when nothing is lost and therefore
still tracks a genuine rate change. UDPSClient::AcceptDataCounter (C++),
udpsprotocol.SequenceGate (Go), decode_data (C).
The C client's existing gap counter was wrap-unsafe and let a stale
packet rewind last_counter, which made every subsequent gap wrong; it
uses the same code now. Docs/Protocol.md gains an Ordering DATA section
stating the requirement for any receiver, including ones outside this
repository.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Brings the Go hub and web SPA work developed on feature/udpscope onto
main, without the udpscope client itself.
The trigger engine could not capture a sporadic event: it armed on the
live tail only, so a burst shorter than one push window was already past
by the time the FSM looked for it. It now searches the ring history for
the crossing, which also makes a capture reproducible from the same data
rather than dependent on push timing (wshub/trigger.go, ringbuf.go,
history.go).
Adds CSV/JSON export of the visible window (wshub/export.go) and reworks
the SPA: per-signal axis controls, a readable trigger panel, and a fix
for the flicker caused by repainting on every push instead of on a frame
tick (static/app.js, index.html, style.css).
BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction,
which is otherwise only inferable from the three files that implement it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Eighteen TDD tasks covering the build scaffold, pane tree, time base, frame
decoding, trigger FSM, threading, UI, persistence and export, so the scope can
be built task-by-task with a reviewable deliverable at each step.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A bench scope that attaches straight to one UDPStreamer through the
standalone C client, so it can be dropped on a machine with no StreamHub,
no Go and no browser. Records the decisions that are easy to get wrong:
the time-base rules must follow UDPSourceSession rather than the C
library's arrival-time estimate, decimation must be min/max rather than
LTTB so glitches survive, and the ring must be sized past the trigger
window by an explicit harvest margin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consuming a UDPStreamer feed so far meant either linking MARTe2 (UDPSClient)
or writing Go (Common/Client/go/udpsprotocol). Common/Client/c fills the gap
for plain C/C++ integrators: two files depending on nothing but libc and BSD
sockets, covering the whole receive path — CONNECT, fragment reassembly,
CONFIG/DATA decoding with dequantisation, keepalives and silence-triggered
reconnect. No threads are spawned; udps_client_poll() does all the work and
runs every callback, so it drops into an existing event loop unsynchronised.
Verified against run_udp_producer.sh at 1 Msps: unicast (120 MiB, no loss) and
multicast with 12-fragment cycles (116k datagrams, no loss).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
UDPSClient::ConnectMulticast was calling the single-arg BasicUDPSocket::Join,
which forwards NULL as the local interface and lets the kernel bind to
INADDR_ANY. On a multi-homed host (or when the server sends on loopback via
Interface = "127.0.0.1") the client joins the wrong interface and silently
receives nothing.
Fix: read the optional Interface key inside the useMulticast block in
UDPSClient::Initialise; in ConnectMulticast call the two-arg
Join(group, interface) when Interface is set, and fall back to the one-arg
call otherwise to preserve the existing INADDR_ANY behaviour for configs that
omit it.
Forward the new optional Interface key through UDPStreamerClient (read from
DataSource config, written into the UDPSClient ConfigurationDatabase only
when non-empty). Extend the "Joined multicast group" log to report the
interface name or "default".
Regression test TestExecute_MulticastReceivesDataOnInterface: mock TCP
control listener + multicast UDP DATA socket with IP_MULTICAST_IF set to
127.0.0.1, verifying a uint32 value of 424242 reaches DataSource signal
memory. Confirmed FAILED without the Join fix and PASSED with it.
Suite: 133/133 (was 132/132 before this commit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UDPSServer parses Interface with inet_addr() to set IP_MULTICAST_IF, so
a name like "eth0" yields INADDR_NONE and Initialise fails. Every
example in UDPStreamer.md used "eth0", so anyone following the docs hit
that failure; the .cfg files in Test/ already used a dotted-quad.
Replace the examples with 192.168.1.10, state the dotted-quad
requirement in the parameter table and the Multicast section, and note
that receivers must join on the matching interface or they silently
receive nothing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Commit 3e0a481 made Interface mandatory for multicast in both
UDPStreamer::Initialise and UDPSServer::Initialise and updated
Docs/UDPStreamer.md, but left the GTest configs untouched. All four
multicast tests have failed since.
Add Interface to the five multicast configs. The field is parsed with
inet_addr(), so it takes a dotted-quad, not an interface name; 127.0.0.1
keeps the tests self-contained and off the LAN.
TestInitialise_MulticastMode_InvalidDataPort was passing for the wrong
reason: UDPStreamer.cpp rejected it on the missing Interface before the
DataPort == Port check could run. It now proves what its name claims.
TestExecute_MulticastConnectDataDisconnect additionally needed the
two-argument Join(): the single-argument form passes INADDR_ANY, so the
reader joined the default-route interface while the server sent on
loopback via IP_MULTICAST_IF, and the DATA datagram never arrived.
GTest: 132/132 (was 128/132), multicast subset stable over 3 runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These subagent handoff artifacts were committed before
.superpowers/sdd/.gitignore took effect. They are ephemeral
scratch, not project content.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per-signal data calibration (value = raw * scale + offset) with an optional
unit override, plus sources+calibration persistence in a single config file,
implemented identically in the Go hub and the C++ StreamHub.
- calibration.js: fix baseSignalName('[0]') parity with Go/C++ (>= 0 not > 0)
- calibration.test.js: add assertions for '[0]' edge case in two existing tests
- app.js: remove stale typeof guard around refreshVScaleMenu (always defined)
- app.js: call refreshTrigThresholdField on trig-signal change (both assignment sites)
- index.html: drop maxlength='16' on unit input; normaliseCal is the sole enforcer
- configcheck/main.go: delete dead nextOneOf function (no callers)
- hub_calibration_test.go: delete orphaned waitBroadcast comment (function never existed)
- calibration.go: correct arrayIndexSuffix comment to document known Go/C++ difference
- Docs/StreamHub-API.md: add calibration entry count and unit byte limits to §5 table
- spec: fix configReloaded missing path field, '16 chars'→'16 UTF-8 bytes', StreamString→char[], chain scenario→configcheck program, four→five new frames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1 (HandleReloadConfig data loss): move ClearCalibration inside
LoadSourcesFile so the table is wiped only after a successful fread.
Adds a clearCalibration bool parameter (default false); the reload path
passes true, the startup path passes false.
Finding 2 (JSON injection via unit/source/signal): add JsonEscape()
static helper (escapes \", \\, \n \r \t, and \u00XX for other control
chars). Applied at all three emission sites: BroadcastCalibration,
HandleSaveSources, and BroadcastSources (label). Teach JsonGetString to
unescape the same set on read, so values round-trip correctly.
Finding 3 (%.17g verbosity): add ShortFloat() static helper that tries
%.15g then %.16g then %.17g, stopping at the first precision whose
strtod() output compares equal to the original. Applied at both float
emission sites. 0.1 now prints as "0.1", not "0.10000000000000001".
Minor: fix two inaccurate comments in StreamHub.h — the CalibrationEntry
rationale (not a 133 MB / address-limit issue; the real reason is no
per-entry heap churn, STL-free, trivially copyable) and "chars" to "bytes"
for the unit cap.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Finding 1 (Critical): replace "16 chars" with "16 UTF-8 bytes" in the
setCalibration unit field description (StreamHub-API.md) and the Cal·Unit
toolbar row (WebUI.md); note that multi-byte characters consume more than
one byte and that truncation never splits a character.
- Finding 2 (Important): correct the claim that a sources broadcast after
reloadConfig is conditional on new sources being added — that is true only
of the Go hub. The C++ hub calls BroadcastSources() unconditionally on
success. Both the reloadConfig command description and the configReloaded
event description in StreamHub-API.md are updated; the Reload bullet in
WebUI.md is updated with a brief note. Clients must tolerate an unsolicited
sources frame after any reload.
- Finding 3 (Minor): the configSaved failure example used "no SourcesFile
configured", which matches neither hub. Corrected to the C++ form
"no sources file configured" and added a note that the exact error text
is not part of the protocol contract (Go uses "no sources-file configured").
Source evidence: calibration.go (maxUnitLen, len(), rune-repair loop),
StreamHub.cpp (kMaxUnitLen, byte strncpy, HandleReloadConfig unconditional
BroadcastSources, HandleSaveSources error string).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update Docs/StreamHub-API.md (new setCalibration/reloadConfig commands,
calibration/configSaved/configReloaded events, §4 config file format),
ARCHITECTURE.md §6 (updated command/event tables and Config File Format
subsection), Docs/WebUI.md (Cal row in V-Scale Toolbar, Sources & Config
sidebar section). Also corrects the spec's Validation sentence to match
the shipped cal-invalid border behaviour instead of silent field revert.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames the "Add Source" section to "Sources & Config" and replaces the
fire-and-forget "Save list" button with Save and Reload, plus a one-line
status area that renders the hub's configSaved/configReloaded ack.
onConfigAck replaces the no-op stub Task 7 left behind. It branches on the
frame type because configSaved carries a path and configReloaded does not,
and surfaces the hub's error text on failure rather than failing silently.
The status is kept outside the DOM because buildSidebar() recreates this
section on every sources broadcast.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: revert the comma/quote strip added in 3cb998c from
normaliseCal() in calibration.js. The strip broke byte-identical parity
with Go CalConfig.Normalise and C++ StreamHub::SetCalibrationEntry, both
of which only trim whitespace and cap at 16 UTF-8 bytes. Delete the
companion test that asserted the now-removed behaviour (suite returns to
18 tests).
Finding 2: fix the actual CSV-safety problem at the point of use in
exportAllCSV() in app.js. Header cells (time column and signal columns)
are now RFC 4180-quoted: wrapped in double quotes with any embedded
double quote doubled. This safely handles units or signal names that
contain commas or quotes without touching normaliseCal.
Finding 3: update two stale comments in app.js that called the trigger
threshold or rawFromNorm result 'raw' — Task 9 moved trig.threshold into
calibrated units throughout, so the comments now say 'calibrated'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refreshVScaleMenu() was unconditionally removing the cal-invalid class
from all three calibration inputs, which silently cleared the red-border
error indicator on a field the user was editing whenever a hub
calibration broadcast arrived. Apply the same focused-element guard
already used for .value writes so a rejected value's error styling
persists until the user corrects it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the Cal row (Scale / Offset / Unit / Reset) to #vscale-menu, its
CSS, and the refreshVScaleMenu() / commitCal() logic that wires it to
calTable and the hub via setCalibrationWS.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TextEncoder always emits well-formed UTF-8, so truncation can strand at
most a lead byte plus three continuations — one repair pass covers it.
The C++ hub needs a loop because its input is raw bytes off the wire.
Also drops a dead variable from the byte-boundary test.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
normaliseCal was using String.length/.slice() (UTF-16 code units), so
multi-byte characters like °, Ω, µ could slip through oversized. Now uses
TextEncoder to slice at 16 bytes, then repairs any incomplete trailing
UTF-8 sequence by walking back over continuation bytes to find the lead
byte and dropping the incomplete rune — exactly mirroring Go's
utf8.DecodeLastRuneInString loop and the C++ walk-back in
StreamHub::SetCalibrationEntry. Adds 4 new test cases covering the
non-ASCII/boundary scenarios, and corrects the canonical test command in
the task-6 report to 'cd Client/udpstreamer && node --test'.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements Calib JS module (calibration.js) with affine transform primitives,
CalTable, and normaliseCal matching Go CalConfig.Normalise semantics; 14 node
--test cases all pass. Loads before app.js in index.html.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix round 1 review findings:
1. Frame-matching strategy: add protocolFrameTypes set; next() now fails immediately
on an out-of-order protocol frame instead of silently discarding it. Ambient
frames (data, stats, triggerState, monotonicState) are logged and skipped.
nextSkipping() accepts explicitly-listed protocol frames that legitimately differ
in order across hubs (connect-time "sources" before "calibration" in C++).
2. BroadcastSources documented exception: the extra "sources" frame C++ emits after
reload is accepted and logged as [KNOWN DIFFERENCE] with full rationale; the
report recommendation to remove it has been retracted (it is required for correct
client-side source-list updates after reload-with-new-sources).
3. Sort-order constraint exercised: three calibration entries submitted in
reverse-sort order (src2/Beta, src1/Zeta, src1/Alpha); each broadcast and the
saved config file are asserted to be sorted by source then signal.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The single-pass repair left invalid bytes when the candidate lead byte
had class 0 (illegal 0xF8-0xFF bytes, or a bare continuation byte
reached after the 3-byte backward-scan cap). Convert to a loop with a
`cut` flag mirroring Go's loop: each iteration either makes no cut
(exits) or strictly reduces ulen by >= 1 byte (terminates in <= 16
iterations). Also treat expected==0 as a cut target, matching Go's
behaviour of stripping any byte that decodes as an invalid one-byte
sequence.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous walk-back in SetCalibrationEntry was unconditional, corrupting
short valid units ending in multi-byte characters (e.g. Omega, mu, degree).
Also failed to drop an orphaned lead byte left after stripping continuation
bytes. Restructured to use a 256-byte staging buffer so truncation can be
detected, then repair runs only in the truncation branch. Algorithm now
matches Go CalConfig.Normalise() exactly: scan back over continuation bytes
(up to 3), find the lead byte, derive expected sequence length, cut if
incomplete. Covers all cases: orphaned continuation, orphaned lead, cut on
lead byte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: Add TrimInPlace helper; apply trim→strip-[i]→empty-check order
in SetCalibrationEntry (matching Go Normalise()), so whitespace-padded
source/signal from WS clients normalise identically to config-file loads.
Finding 2: Trim unit before truncating to kMaxUnitLen, then walk back
continuation bytes (0x80-0xBF) to avoid leaving a partial UTF-8 rune,
matching Go's utf8.DecodeLastRuneInString loop.
Finding 3: BroadcastCalibration and HandleSaveSources now emit entries
sorted by source then signal (insertion sort over an index array, no STL),
producing byte-identical calibration frames and config files to the Go hub.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add CalibrationEntry (heap-allocated array[256], char[] fields to stay within
the 133 MB struct's canonical address limit) plus calibrationMutex_ and
numCalibration_.
- Implement SetCalibrationEntry/ClearCalibration, BroadcastCalibration,
BroadcastConfigAck, HandleSetCalibration, HandleReloadConfig.
- Wire setCalibration and reloadConfig into OnWSCommand dispatch.
- Broadcast calibration to each newly connected client after triggerState.
- Fix JSON round-trip bug: replace JsonGetString/JsonGetBool helpers with a
shared whitespace-tolerant JsonFindValue (tolerates "key" : "value" as
written by HandleSaveSources); add JsonIsFinite (no <cmath>).
- Extend LoadSourcesFile(bool skipActive) to also parse calibration blocks;
SourceIsActive checks live sessions before starting a duplicate.
- Extend HandleSaveSources to persist calibration blocks; emit configSaved ack.
- Reload semantics: calibration replaced wholesale, sources added only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire five new WebSocket frames into hub.go: setCalibration (client→hub),
calibration (hub→client broadcast), reloadConfig (client→hub), configSaved and
configReloaded (hub→client acks with ok/path/error). Extend hubCmd with cal
field, add buildCalibrationMsg and buildConfigAckMsg builders, send calibration
on client connect, and run Save/Reload on separate goroutines to avoid blocking
the Run() select loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Finding 1: strip trailing [digits] array-element suffix from Signal so one
calibration entry covers an entire array signal, matching the C++ strchr
truncation and the JS equivalent.
Finding 2: after the 16-byte Unit truncation, drop any trailing partial UTF-8
rune so json.Marshal never emits replacement characters; keeps byte limit in
sync with C++ strncpy(u, unit, kMaxUnitLen).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Design for a per-signal affine calibration (scale/offset/unit) applied
client-side and stored server-side alongside the source list, so signals in
raw units can be read in engineering units and the setup survives a reload.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Task 7 added --skip-coverage/--skip-stress/--skip-datasources/
--skip-recorder/--skip-debug/--skip-tcplogger but the AGENTS.md table row
was never updated to mention them; it also still listed a --stress flag
that has never existed. Sync with the script's actual --help output.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The final whole-branch review found runDebugScript only checked
mc.IsConnected() after sending FORCE/TRACE/BREAK/UNFORCE -- a liveness
check that would PASS even if DebugService silently no-op'd every command
(e.g. a signal-name/wire-format bug), undercutting the design's stated
rationale for this scenario ("catching wire-format/serialization bugs the
in-process suite cannot"). This mirrors the tautology already fixed for
the tcplogger scenario in an earlier task, but had not been applied here.
Added waitForAck(), which polls the sink's recorded "text_line" events for
DebugServiceBase::HandleCommand's real "OK <TOKEN> <count>\n" reply and
requires count > 0 -- HandleCommand prints this for every one of
FORCE/UNFORCE/TRACE/BREAK regardless of enable/disable direction, and
count is always "number of signals actually matched", so count==0 means
the signal path was never resolved. Verified with a real negative control
(temporarily pointing all commands at a nonexistent signal name): the
scenario now correctly FAILs, then reverted and reconfirmed PASS against
the real signal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The final whole-branch review (post Task 10) found two real cross-task
integration bugs:
- run_e2e.sh's coverage-instrumented scenario re-run only rebound OUT_DIR
in its subshell, not WORK. proc_perf.py/plots.py write perf_*.json and
wave_*.png into WORK, so every --cpp-coverage run (the default) silently
clobbered the primary pass's perf/waveform data with the instrumented
re-run's numbers before report_build.py read them -- defeating the
"uncontaminated performance metrics" goal of the coverage-double-run
design. Fixed by rebinding WORK the same way OUT_DIR already was.
- report_build.py's build_e2e() iterated all results["scenarios"] with no
kind filter, so the direct/recorder/debug/tcplogger scenarios (already
covered by their own dedicated report sections since Task 8) also leaked
into the chain-only Scenarios/Performance sections and headline e2e
pass/fail count as degenerate rows. Fixed by filtering to kind=="chain".
Verified end-to-end with a full ./run_e2e.sh run: coverage pass now writes
to /tmp/chain_e2e/coverage_pass/ (confirmed via log), and report_data.json's
e2e section now reports 51 (chain-only) scenarios instead of all 56.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The post-coverage restore step ran `make clean` (which also wipes
Test/GTest, Test/Integration and Test/Components/*) but only rebuilt
`core apps`, leaving MainGTest.ex/IntegrationTests.ex deleted after
every --cpp-coverage run instead of restored to their plain (non-gcov)
form.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>