feat(cli,server): MAN-122 — log a startup banner and periodic status line - #136
Open
catalyst-cloud-connector[bot] wants to merge 1 commit into
Open
feat(cli,server): MAN-122 — log a startup banner and periodic status line#136catalyst-cloud-connector[bot] wants to merge 1 commit into
catalyst-cloud-connector[bot] wants to merge 1 commit into
Conversation
catalyst-cloud-connector
Bot
force-pushed
the
MAN-122
branch
from
September 7, 2026 19:24
044156f to
0a67725
Compare
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
Before this change,
manta listen --server-configsaid nothing on startup and nothing whilerunning — the first log line ever emitted was a per-connection message, and only once some
client happened to touch a listener. An operator watching
stderr(or a journald unit) had noway to tell the daemon came up correctly, or that it was still alive and decoding, short of
scraping
/metricsby hand. This closes MAN-122's two scenarios:INFOline, emitted as line 1 of the daemon's log, naming version,active source, sample rate, dial frequency, station callsign, and the three real bound
addresses (telnet/JSON/metrics).
INFOline per configured interval (default 60 s),naming active track count, spots/min, spots total, per-protocol + total connected client
count, and uplink connection state.
Both go to
stderrthrough thetracingmachinery MAN-59 already set up, so--json's stdoutstream (the determinism contract) is untouched.
Sample output (ANSI stripped):
What changed
manta-server::statusmodule (crates/manta-server/src/status.rs) — the pure,synchronous formatting core:
format_startup_banner,format_status_line,spots_per_min(a windowed derivative, not a lifetime average — answers "is it decoding now"), and
spawn_status_line, the timer task itself.Metrics(spots_total,active_tracks, and the threeper-protocol client-gauge readers) —
Metricswas already the daemon's one shared,synchronously-readable handle on live state, but the only way to read most of it back out was
string-parsing
render_prometheus_text().start_spot_server(crates/manta-cli/src/main.rs), emitted afterall three
TcpListener::bindcalls succeed but before any listener task is spawned — so a bindfailure never produces a false "ready" line, and no per-connection line can race ahead of it on
a multi-thread runtime. It reports
local_addr(), not the configured port, so a*_port = 0(ephemeral) config still names the real bound address.
status_interval_secs: Option<u64>config key (
ServerConfig, backward-compatible default via#[serde(default)];0disablesthe task entirely). It's raced against the shutdown watch channel so it can't emit a line into
the middle of shutdown drain.
manta_active_tracksis no longer a permanent, documented0. It was frozen becausemanta_engine::listen()never exposed a live track-count hook. Rather than ship a status linewhose headline field silently lies on a healthy, actively-decoding node, this derives a real
count from the
DecoderEventstream the CLI'son_eventclosure already observes — aHashSet<u32>of open track ids, keyed on any track-scoped event for insert andTrackClosedfor remove, with no
manta_engineAPI change.ARCHITECTURE.md§8 is corrected to match.docs/DECISIONS/2026-09-07-man122-operator-liveness-logging.md(new) records the designdecisions below with their rationale.
Design decisions
key=value, single line, no structuredtracingfields —grep manta status:on a plainlog yields the whole record, matching MAN-59's existing style.
uplink=noneis distinct fromuplink=disconnected— an operator with no[[rbn_uplink]]configured must not read a permanent "disconnected" as a fault. The distinction is computed from
the count of enabled uplink configs, not inferred from the connected-count metric alone.
Verified a dry-run uplink still reads
connected(the dry-run suppression check is downstream ofwhere the gauge is set).
status line whose absence is meaningful (the daemon stopped logging) is more useful than one
that goes quiet when nothing changes.
months on a Pi;
status_interval_secsmakes 30 s (or 0, for silence) a one-line config change.manta statussubcommand, no/healthz, no CPU-percent field, no stdout change in textmode, no non-daemon (
listenwithout--server-config) banner — all explicitly out of scope;see the decision record for why each belongs to a different ticket (MAN-44, MAN-128) or isn't
backed by any existing accounting in the workspace.
Two real bugs fixed during implementation, beyond the original plan
watch::Senderside hasdropped (
changed()returnsErr), instead of busy-spinning a core forever — a realcorrectness bug the plan's own sketch would have shipped, and one that matters on this
project's single-core Pi4 budget.
CharDecoded,WordBoundary,SpeedUpdate,TrackMeta) for insert, notTrackMetaalone as originally planned — verifiedagainst
TrackManager'shas_emittedinvariant that every event-emitting track id isguaranteed exactly one eventual
TrackClosed, so the narrowerTrackMeta-only rule would haveundercounted short-lived tracks, and the shipped rule cannot leak.
Testing
Both ticket scenarios were reproduced live against the real
mantabinary in the implementationcontainer (not just asserted by unit test): the banner is confirmed as line 1 of
stderr, namingevery field the Then clause requires, with real (non-zero, non-configured) ephemeral ports; the
status line was confirmed rate-limited to one line per configured interval, and
tracks=wasconfirmed genuinely live (moving 105→108→105 run to run against a synthetic CW replay) rather than
the previously-frozen
0.Two new test files:
crates/manta-cli/tests/startup_banner.rs— end-to-end through the real binary: banner-is-line-1,real-port assertion, and a periodic-status-line-appears-during-replay case.
crates/manta-server/tests/status_line_acceptance.rs— the timer task really emits, really readsMetrics, really rate-limits, using a small customMakeWriterto capturetracingoutput.Plus unit tests for the two new formatters, the rate-arithmetic edge cases (zero window), the five
new
Metricsgetters, and the track-set bookkeeping (double-insert, unknown-close, non-lifecycleevents are all no-ops).
Full validation (
validate-plan) ran the workspace's real CI-equivalent gate per-crate (thiscontainer's disk can't hold every debug test binary from a single
cargo test --workspace):cargo fmt --all --checkandcargo clippy --workspace --all-targets -- -D warningsboth clean;475 tests passed, 0 failed, 9 pre-existing ignored across all 9 crates, including the
determinism regression test (
json_output_is_valid_and_deterministic_across_three_runs), confirmingstdout in
--jsonmode is untouched. A reward-hacking scan over the diff found no suppressions,no weakened assertions, and confined every added
.unwrap()/.expect()to test code.Not exercised in this environment
No SDR hardware, no reachable KiwiSDR, and no live RBN uplink endpoint were available in the
implementation or validation containers, so the following are read-verified against source but not
run end-to-end:
source=/dial_freq_hz=against a real KiwiSDR/SoapySDR/HPSDR device (the codepath,
IqSource::center_freq_hz(), is shared and already exercised by other tests);uplink=flipping from
connectedtodisconnectedagainst a live target (the underlying gauge transitionsare covered by
uplink_acceptance.rsagainst a mock); and a long-running (24 h) soak to bound-checkthe open-track
HashSetagainst leaks (source-level analysis shows it cannot leak — every event anopen track can emit sets
has_emitted, and everyTrackClosedpath gates on it — but a live soakremains the honest test). None of these are new gaps introduced by this change; they're the same
hardware-dependent gaps CLAUDE.md already tracks against M2/M4 acceptance.
Follow-up (non-blocking, per this repo's review-convergence policy)
Four informational findings from validation, none correctness or security, recorded for a
follow-up ticket rather than fixed inline:
crates/manta-cli/tests/startup_banner.rs's periodic-status-line test derives its timingmargin from unpaced file-replay speed (240 s fixture, 1 s interval) — measured margin varies
2.8x–6.2x across containers in this fleet already, so a sufficiently fast CI runner could flip
it red. Should be rewritten to not depend on replay speed.
HashSetpredates the"any track-scoped event" fix above and should be corrected to reflect that it now runs on the
CharDecodedhot path (cost is still negligible against per-event DSP work, but the writtenestimate is stale).
spawn_status_line's returnedJoinHandleis discarded at its only production call site (it'sa detached background task, matching the existing reaper-task idiom, and shutdown is covered by
the
select!arm regardless) — the return value exists only for tests today.status.rs'suptime_smeasures time since the status task started, not the process —indistinguishable today since the spawn happens microseconds after the daemon's binds, but
would silently drift if the task were ever spawned lazily.
Scope notes
except that
manta_active_tracksnow reports real numbers — a correction of previouslydocumented-broken behavior, not a contract change.
status_interval_secsis optional; existing configs get the 60 sdefault unchanged.
decision record so it isn't a surprise against a journald quota.