Repository navigation
A project logger writes the capture to file while it is connected - #470
Merged
Merged
Conversation
This was referenced Sep 6, 2026
Owner
Author
This was referenced Sep 6, 2026
thebhef
force-pushed
the
task137-export-dialog
branch
from
September 6, 2026 23:48
bdd4948 to
fd63c35
Compare
thebhef
force-pushed
the
task137-loggers
branch
from
September 6, 2026 23:48
3087613 to
0de4dfc
Compare
thebhef
force-pushed
the
task137-export-dialog
branch
from
September 7, 2026 05:41
fd63c35 to
7a3de01
Compare
thebhef
force-pushed
the
task137-loggers
branch
from
September 7, 2026 05:41
0de4dfc to
7f63d93
Compare
This was referenced Sep 7, 2026
thebhef
force-pushed
the
task137-loggers
branch
from
September 7, 2026 19:46
7f63d93 to
e3c1300
Compare
This was referenced Sep 7, 2026
thebhef
force-pushed
the
task137-loggers
branch
from
September 9, 2026 17:14
e3c1300 to
f2c39cc
Compare
thebhef
force-pushed
the
task137-loggers
branch
from
September 10, 2026 17:28
f2c39cc to
94c8759
Compare
thebhef
force-pushed
the
task137-export-dialog
branch
from
September 10, 2026 17:28
71a3df8 to
503fd2b
Compare
This was referenced Sep 10, 2026
thebhef
marked this pull request as ready for review
September 23, 2026 14:47
This was referenced Sep 23, 2026
thebhef
force-pushed
the
task137-loggers
branch
from
September 23, 2026 15:43
80e3c3b to
f7a8ba7
Compare
thebhef
force-pushed
the
task137-export-dialog
branch
from
September 23, 2026 15:43
e9c7d83 to
e8a4f50
Compare
This was referenced Sep 23, 2026
…491) Nothing on the wire said which protocol revision either side spoke. That was survivable while the GUI shipped in the same bundle as the server it talked to; it stopped being survivable once the python client module and its CLI existed and third-party clients became the point. A mismatch presented as `UNIMPLEMENTED`, `connect_flow.rs` had no arm for it, and the retry loop dialled an incompatible server forever — so it looked exactly like a server that keeps flapping. The rule (owner ruling, ADR 0059) is the protobuf package-major convention. `cannet.v1` is the major and it is already in every gRPC method path, so both peers state it on every request; inside a major only additive changes land, and a breaking change is a new package served beside the old one for a deprecation window. Rejected: a version integer with exact match (ADR 0011's pattern — right for a project file, wrong for a wire, because it forces every client to upgrade the day a breaking change ships) and a negotiated min/current range. ADR 0051 §6's "one monotonic Extension API version carried by cannet.proto" is amended to be this package major, so tasks 145 and 69 share one number. `cannet.proto` states the rule in its header — above `syntax`, because protoc folds a declaration's leading comments into every language's generated docstrings and the first placement put forty lines of policy into `CannetServerStub.__doc__` — and `Envelope` reserves the tag-9 hole so nothing can pick it up later. `proto/cannet_info.proto` is a second file declaring `package cannet;`, deliberately unversioned and frozen, carrying one unary `ServerInfo` that answers with the packages served, the build version and the instance name. Outside every major on purpose: a server that has dropped `cannet.v1` still has to be able to tell a `cannet.v1` client so. `cannet_wire::PROTOCOL_PACKAGE` is the one string both sides compare. Every server mode answers it — the proxy, `debug replay`, `debug vbus`, and `serve_virtual_bus_ephemeral` — as does the python sidecar, whose stubs are regenerated from both protos. `cannet-client` calls it inside `ConnectConfig::client()`, the single choke point `list_interfaces`, `watch_interfaces` and `connect_and_subscribe` already shared, and refuses with `ConnectionError::IncompatibleProtocol`. `UNIMPLEMENTED` on any RPC becomes `ConnectionError::Unimplemented`; `classify` sends both to `Outcome::Fatal`, so neither retries. Both read "serves cannet.v2; this client speaks cannet.v1". `ServerInfo` is unauthenticated, so a client that cannot speak to the server at all is told that rather than that its token is wrong. That forced a real change: the token gate was a server-wide `Server::layer`, and a tonic interceptor is handed a `Request<()>` whose URI has already been dropped, so it cannot tell which service a call is for. The gate moved onto the services (`auth::gated`), and `tests/auth.rs` is now what holds the line — it asserts each gated RPC refuses an absent and a wrong token, and that `ServerInfo` answers with neither. mDNS gains `proto=`, the comma-separated packages served; `ver=` stays the build string it always was. Advisory — absent for a hand-added server and for `--no-mdns` — so the Servers panel greys out a row it cannot speak to before dialling it, and `ServerInfo` on the connection is the gate. `run_proxy`'s advertisement block moved into `advertise()` to stay under the 100-line clippy limit. CI gains two jobs. `wire-breaking` runs `buf breaking` (pinned 1.47.2, CI-only, no codegen role) against the last release tag — `GIT_LFS_SKIP_SMUDGE` is set because buf clones this repository and has no LFS endpoint for the example captures it does not need. `wire-gencode` runs `scripts/regen-proto-gencode.sh`, which drives every package's own `scripts/regen_proto.sh` found under `servers/` and `libs/`, then fails on any diff; nothing checked that the committed stubs still matched the proto before. `regen_proto.sh` gained `newline=""` on its import-rewrite step, without which a Windows regeneration rewrites every line of the gencode and that job can never be green. Tests, written against a fake server that serves only `cannet.v2` while serving the real `cannet.v1` service, so the refusal cannot be an accident of a missing RPC: `crates/cannet-client/tests/protocol_gate.rs` covers the sentence, all three entry points, a server with no `ServerInfo` at all, a server serving both majors (accepted — that is the deprecation window), and an ordinary server. Every existing test server in the workspace had to mount `ServerInfo`; without it they fail with `Unimplemented("")`, which is how the gate was falsified before the new tests were written. Two inherited breakages fixed in passing rather than reported and left: `apps/gui/src-tauri/src/interfaces.rs` was not rustfmt-clean (7d18a42 — CI runs no `cargo fmt --check`, only the pre-commit hook, which `--no-verify` skips), and three files carried CRLF against LF blobs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#466) `clients/cannet-python-client` is a uv-managed sibling of the sidecar that registers `CannetBus(can.BusABC)` under python-can's `can.interface` entry-point group, so `can.Bus(interface="cannet", server=..., channel=...)` opens a remote bus with no cannet-specific import and no credential pasted into application code. Full BusABC citizenship: `_recv_internal` (so `recv`, iteration and `Listener`/`Notifier` work), `send`, `shutdown`, `state`, `channel_info`. `server=` is resolved against the GUI's own trust store — `servers.json` in the platform config dir under `dev.cannet.app` — whose location and schema are now documented as a read contract, in the package README and in `trust.py`. The library never writes it: accepting a server means comparing a fingerprint against what the server printed, and only a person can do that. The plan it derives is the GUI's (ADR 0041): loopback is plaintext whatever is stored, a pin never degrades to plaintext, a plaintext target carries no token, and an unknown non-loopback server raises rather than inventing an answer. Pinning is by certificate rather than by fingerprint, because Python's gRPC exposes no verifier hook: the library fetches the server's certificate, checks its SHA-256 against the stored pin, and hands that exact certificate to gRPC as the channel's sole root — equivalent in what it accepts. A second handshake, trusting the fetched certificate as its own root, reads back the SAN list so the target-name override names something the certificate actually carries. Reviewers should note this path has no end-to-end coverage: both hardware-free debug servers terminate no TLS, so the fingerprint form, the pin comparison and the name choice are unit-tested and the handshake is not. Session semantics are `cannet-client`'s, so a hardware server sees the same client whichever language is talking to it. `ConfigureBus` precedes the `Subscribe` it configures. A bare `virtual:` id is a factory and waits for its `InterfaceAllocated` (ADR 0021). The three per-frame codes are tallied on `bus.rejections` and the stream goes on; everything else, including a code this build does not recognise, ends the session and is raised. One consequence worth reading twice: the wire does not acknowledge an ordinary `Subscribe`, so an unknown interface surfaces on the first `recv` rather than out of the constructor — waiting on a reply a server has no reason to send would trade a working session for a stall. Everything both ends of the wire have to agree on is a package of its own, `libs/cannet-python-wire`: the checked-in `_proto` gencode and its `regen_proto.sh`, the `Frame`/`FrameKind` pair, and the four mappers — `message_to_frame` / `frame_to_message` either side of a python-can `Message`, `frame_to_proto` / `proto_to_frame` either side of the generated type. The sidecar and the client both take it as a path dependency, so there is one encoding of the wire in this repository and nothing for a second copy to drift from. The cut is at the seam and not past it: the hardware driver, its adapters and the gRPC service stay in the sidecar, and `driver.py` re-exports `Frame`/`FrameKind` because they remain part of the contract an alternative-driver author reads. A client that never speaks to a sidecar does not depend on one. `frame_to_message` leaves timestamp and direction unset — it is the transmit direction, where neither means anything — so the receive path supplies both. Tests are hardware-free throughout: the units cover the trust-store contract, the fingerprint form, the envelope order and the batch routing; the integration half opens real buses against `cannet-server debug vbus` and `debug replay` on explicit loopback, and skips when the binary is not built. A `python-client` CI job builds the server and runs them for real, and four pre-commit hooks mirror the sidecar's. The wire package carries the tests of the code in it — the kind ladder, the proto round-trip, the timestamp fallbacks — and gets the same four hooks and its own `python-wire` lane; the sidecar's and the client's mypy/pytest hooks watch its paths too, because a change there can break either. All three Python packages hold ruff below 0.16. Adding a package beside the sidecar at 0.16 would have put two ruffs in the tree that do not agree about the same code: ruff 0.16 *changes* its default rule selection rather than only widening it, dropping E4xx and adding `I`/`UP`/`S`/`PL`/`RUF`. Under 0.16 the sidecar reports 213 findings, among them 50 deliberate `# noqa: BLE001` / `E402` suppressions that `RUF100` deletes and five `S110`/`S112` best-effort `except: pass` shutdown paths whose only remedy is to start logging. Moving every package to 0.16 is that change, not this one. Also drops 17 inherited `(task 129)` references from `apps/gui/src/` comments (from #450). `CLAUDE.md` forbids naming a task in source; every one was a bare parenthetical carrying nothing else, so it goes. The client asks `ServerInfo` before it opens a session (ADR 0059), on the channel it has just dialled and before any credential goes out, and raises `IncompatibleProtocol` — "serves cannet.v2; this client speaks cannet.v1" — when the server's package list does not hold ours. A server that does not implement `ServerInfo` at all is refused the same way: it predates the rule, so nothing it answers can be read as agreement about the wire. `cannet_python_wire` grows `PROTOCOL_PACKAGE`, read off the gencode's own descriptor rather than written out, so the sidecar and the client compare against the package their stubs were actually generated from; the sidecar's `info.py` reads it from there instead of carrying its own copy. The wire package pins `grpcio-tools` to 1.80.x, and that pin is load-bearing rather than tidiness: every generated stub records the toolchain that emitted it and refuses to import under an older runtime, and the consumers lock grpcio 1.80.0 / protobuf 6.33.6. The lock as first resolved here reached grpcio-tools 1.84 while the committed gencode came from 1.80 — so the stubs were not reproducible from the lock, and regenerating under it produced `_pb2_grpc.py` demanding grpcio >= 1.81.1 and `_pb2.py` carrying protobuf 7.x gencode, both of which import-error in the sidecar and the client. Nothing caught that before, because nothing regenerated; the gencode-drift CI job does now. `technology-inventory.md` records the pin and the rule it comes from beside the existing grpcio entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#467) Phase 2 of task 136: the SNTP clock probe and server detection the core-bus phase left as gaps. cannet_python_client/clock.py ports crates/cannet-client/src/clock.rs's RFC 4330 offset/delay math and its SessionClock/OffsetSlew state machines verbatim in behaviour (minus the u64/i128 overflow dance, which Python's arbitrary-precision integers make unnecessary). Session drives it from a dedicated thread rather than folding it into the existing receive-pump thread: Python has no equivalent of tokio::select! over a stream and a timer together, and a thread with blocking waits gets the same two guarantees more directly — a session is usable at the speed of its subscribe, and a peer that never answers a round costs nothing but an Unsupported reading once its 2s deadline passes, never a stall. close() wakes that thread on the same event the round waits on, so shutdown never pays the deadline either. Every delivered frame's timestamp is corrected before it reaches recv(). Detection reuses the trust store the same way the bus constructor does: CannetBus._detect_available_configs() (the BusABC hook behind can.detect_available_configs()) dials ListInterfaces on every trusted server with a short timeout and returns one config per interface offered; an unreachable or untrusted server contributes nothing rather than failing the scan. CI gained a step that regenerates the checked-in _proto stubs from cannet.proto and diffs the tree, so the gencode shared between the sidecar and this client can't drift silently. Tests: 27 new unit tests for the clock arithmetic (a port of clock.rs's own suite), an integration test proving a real debug server's probe gets measured, a fake in-process gRPC peer proving an unanswered probe degrades to raw timestamps without blocking session open or close, and a detection suite covering reachable/unreachable/untrusted servers plus the end-to-end can.detect_available_configs() path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The python client did full ADR 0041 auth on the wire but only ever *read* the trust store, so a machine with no GUI on it could not onboard against a server at all short of hand-editing servers.json. This adds `cannet-client`, a console script on the client package with three subcommands — `list`, `connect`, `forget` — that is the Servers panel's workflow on a terminal, writing the same per-machine `servers.json`. Accept a server either way and every client on the machine inherits the decision; ADR 0041 and ADR 0032 are amended to say the store now has a second writer. The decisions live in the library, not in the command (ADR 0003 applied to a terminal rather than a WebView): `browse` is a browse-only mDNS stack over `zeroconf` — it never registers a service — `servers` holds the list merge and what a name on the command line means, `connect` is the four-path state machine, and `trust` grew the write half of the store. `cli` parses arguments, puts the flow's questions to the person and renders what comes back; it decides nothing, which is why the tests for the rules need no terminal and the tests for the terminal need no rules. `connect` mirrors `connect_flow.rs` path for path: loopback is plaintext unasked, a pinned server is dialled pinned, first contact prints the observed `SHA256:…` for comparison against the server's startup banner and asks for the banner's token, and an endpoint that never answers with a certificate is reached in the clear only after an explicit yes. A certificate that is not the pinned one is refused with both fingerprints, stores nothing and sends nothing; a *pinned* server that is merely down is not offered in the clear at all, since that would make "the server is down" a route to dropping its protection. Whatever it stored, it then proves with one authenticated `ListInterfaces` and prints the `can.Bus(...)` call for what it found. A name resolves without touching the network wherever it can: the store answers first, an address is taken as typed, and only a bare name nothing is stored for is looked for among the advertising servers — by instance name or by the machine's own — with two answers refused rather than guessed at. Worth a reviewer's eye: the store writes are additive. The GUI owns the schema and may grow fields, so a write reads the raw document and puts back every key it did not touch, on the entry being edited and on every other, and replaces the file by temp sibling + rename the way the GUI's own writer does. Accepting an identity clears an earlier unprotected choice; accepting an unprotected connection drops any pin and token, because a credential never rides an unencrypted channel. The default suite stays off the network and off multicast: the browse, the certificate probe and the verifying RPC are injected, and the one end-to-end case runs against `cannet-server debug vbus` on explicit loopback like the rest of the integration half. `zeroconf` is recorded as adopted in the technology inventory, browse-only and distinct from the Rust crate of the same name rejected in task 43. `list` gains a PROTOCOL column: what each server says it speaks, from the `proto=` TXT key (ADR 0059 — the wire's version is its protobuf package). A server serving a major this client does not is rendered "cannet.v2 (not spoken here)", because it otherwise reads as a reachable server that refuses every connect; a server that advertised nothing reads `-`, which is "did not say" and not "serves nothing", and is dialled normally. `connect`'s verifying `ListInterfaces` now asks `ServerInfo` first like every other connection this package makes, and translates the refusal into `Refused` carrying the sentence both sides are named in. The in-process fake in `test_session_clock.py` had to mount `ServerInfo` too — a fake without it is refused before the test's own subject is reached — so `conftest.py` grows `add_server_info`, which also answers with a package list a test can choose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 1 of task 137's export/logger dialog: the shared machinery both
the export dialog (phase 2) and project loggers (phase 3) resolve
their name/folder templates through, plus the machine-scoped state
that pre-fills the next export.
export_template.rs resolves {project}/{logger}/{start}/{now} tokens.
A bare token is ISO 8601 basic with a timezone offset (chrono's own
%z), and an explicit {token:<fmt>} passes straight through to
chrono's strftime formatter with no subset, per the owner's ruling —
validated first against Item::Error so a bad specifier reports a
polished message instead of hitting chrono's to_string panic. {start}
on an unanchored capture resolves as {now}, and the result carries
that fact for a future preview to announce. Folder templates root a
relative result at the project directory, and error when the project
has none the user chose (read as !ProjectDir::is_auto_located(), the
closest fit for "project-directory mode" — no ruling defined the term
precisely, so this is flagged in the status log for the owner).
export_state.rs persists the last-used folder, format, and name
template at user scope (export.json under app_config_dir), following
the servers.json precedent; default template {project}-{start}.
Both are wired behind Tauri commands (preview_export_template,
get_export_state, set_export_state) sized for a live preview and for
phase 2 to read/write directly. No GUI in this phase. chrono becomes
a direct cannet-gui dependency (clock feature, for {now}'s wall
clock), matching plans/technology-inventory.md's existing entry for
this extension.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in the background (#469) Export stopped being "open the OS picker and block the GUI until the file is on disk". It now opens a dialog that settles the two things a picker cannot express — what the file is called, and which slice of the capture goes into it — and then hands the write to a host thread that reports progress and can be stopped. The **name** is the template machinery from the previous change, given a surface: an editable template with the resolved name beside it as a label. The preview is the host's answer (`preview_export_template`), not a second implementation of the tokens in JS, so an invalid token or strftime format is rejected with the host's own message and Export… goes disabled. The template the export was made with is remembered, alongside the folder and the format. The **format is still chosen in the picker**, which is where people expect it. The sticky last-used format reaches it as filter *order*, because the order a save dialog is handed its filters is the only handle there is on which one it opens with. The **range** defaults to both bounds unset: the whole capture, up to the live edge when the write finishes. A timeline over the capture's extent carries the bounds, with the capture's own events as clickable ticks and presets for the whole capture, the trailing 1/5/30 minutes, and the window a plot is showing. Each bound is also a text field taking `[Nd ]HH:MM[:SS]` or bare seconds from the start; a bound sitting on an event reads back as that event. A capture with no wall-clock anchor takes and shows seconds-from-start only — there are no instants to name (ADR 0024). The **day prefix** is what lets a clock name an instant in a capture that runs longer than a day: `3d 12:30:00` is half past noon on the capture's fourth day, counted in local calendar days from the day it started, so a DST transition inside the span does not shift every later day's index. A bound past the first midnight also *renders* with its prefix — without one, two instants three days apart read as the same string, and committing what the field showed silently moved the bound three days. A bare clock keeps its short meaning (the capture's own day, or the next one when it is earlier than the start); an explicit day never rolls, so one landing before the start is refused rather than pushed forward. The bound fields are deliberately *not* the shared `Combobox`. That control is a filtered select: its text box is an fzf filter and Enter prefers the matching option to what was typed, so typing a time selected the event whose rendered time happened to fuzzy-match it. Here the typed text is the primary input, so the field keeps the anatomy and inverts the precedence. Host-side, `save_capture` returns as soon as the write starts. `ExportRun` carries the three things that are about *how* a capture is written rather than about its format — the range predicate, a paced progress sink, and a cooperative cancel flag — and both writers take one. The range bounds every content kind alike (frames, notes, and MDF's file-backed signal samples) so an exported slice is one consistent capture whose file starts where the slice does. `cancel_export` flips the flag, the writer stops at its next frame, and the partial file is removed: neither writer uses a temp sibling, so a cancelled export otherwise leaves a truncated capture wearing the name the user chose. That is the opposite of a cancelled *import*, which keeps what it read, and both doc comments now say why. Worth a reviewer's attention: `ExportRun`'s progress sink is a `Box<dyn Fn>` rather than the `AppHandle` it looks like it should be. An `AppHandle` field would be part of the struct's drop glue, and the writers' tests construct an `ExportRun` — which linked Tauri's whole window graph into the lib test binary, whose loader then failed on `comctl32!TaskDialogIndirect` (side-by-side ComCtl32 v6, which a test binary has no manifest for). The test binary would not start at all. The sink keeps the emitting closure reachable only from the command that builds it. Also here: the capture's extent as a host command rather than something the dialog derives from rows it fetched, the plot's current x window published for the one preset that needs it, and the status-bar chip that reports the write and carries its Cancel. A **fresh install's first export** opens the picker too. The host omits `folder` from `get_export_state` when nothing has been exported yet — serde skips a `None` rather than sending `null` — so the frontend's check is `== null`, not `=== null`, and `ExportStateRecord` spells the field the way the wire does (`folder?: string | null`). With no remembered folder the picker is seeded with just the file name and opens wherever the OS last left it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Export names one file and writes a slice of what has already been
captured. A logger is the standing counterpart: a project element with
its own panel, pointed at a templated folder and file, that keeps
writing BLF to disk while it is switched on. Both fields resolve through
the same host command the export dialog's preview uses, so the tokens
and their strftime formats stay the model's; `{logger}` is the fifth,
available only inside one, and a logger's `{now}` is the moment logging
started rather than the moment a field was read.
A template's separators are the running OS's. `resolve` renders every
result in `MAIN_SEPARATOR` whichever way the template was typed, so the
default folder `logs/{logger}` is one subdirectory on Windows and on
macOS alike, and a project written on either opens correctly on the
other. Keeping that policy in the model is what leaves the panel a
pass-through — the preview appends the format's extension and nothing
else — and the run path a plain join of two already-native halves. The
price, stated on the helper: a literal backslash cannot appear *in* a
name on Unix, because a template separator is a separator everywhere.
Logging runs exactly while the logger is enabled and something is
connected. That rule lives in one place: `connection_state::emit` — the
single write path both `set_and_emit` and `remove_and_emit` funnel
through — reconciles the loggers, so "the state changed" and "the
loggers followed it" cannot drift apart. The decision itself is a pure
function over (configs, running, connected), tested without an app, a
filesystem, or a connection. Unlike the RBS Run flag (ADR 0028), the
enabled flag is saved with the project: logging writes locally and puts
nothing on a bus, so opening a project can resume one without
transmitting.
The writer does not sit on the ingest path. `run_pump` is the obvious
hook and the wrong one: it would put a probe and a frame clone on the
hot path of every session, logger or not. Instead a writer thread
follows the capture model's own index — remember how far you have
written, ask the trace store for what has been appended since, write
that. Nothing is added to a session with no logger running, every frame
source is covered without naming one, and the render-tier runs show no
change in frame rate or mean lag with a logger writing throughout. What
it gives up is a guarantee against eviction outrunning the writer, so
`settle_cursor` detects exactly that and says so on the system log
rather than leaving a silent gap.
Reaching the size cap closes the file and opens the next, with `-002`,
`-003`… on the last path segment before its extension; a run that starts
where a file already sits takes the next suffix the same way. One rule,
`first_free_part`, applied at the start and at every roll. The cap is
read from `BlfCaptureWriter::bytes_on_disk`, new here — the header plus
every flushed container, counted rather than seeked — because
`FinishedCapture::byte_size` is only correct after `finish` consumes the
writer, which is no use to a file deciding whether to keep going.
Worth a reviewer's attention: stopping a logger **waits** for its writer
thread, and the exit path stops every logger after `disconnect_on_exit`.
The first live run left a capture whose header said `file_size = 0` —
the process walked away while the thread was still asleep between polls,
so `finish` never ran and the file was readable only through the
reader's recovery path. A condvar-backed stop signal wakes the thread
instead of polling it out, which is what makes the wait cheap enough to
put on the disconnect path.
**A logger writes BLF, and the panel carries no control saying so.** A
two-option select whose second option is permanently disabled asks a
question that has one answer, and reads as a broken feature rather than
an absent one. The element and the project file keep their `format`
field — the host's `LoggerConfig` never had one, but the preview's
extension reads it and a project should record what was written — so
the control can come back the day live MDF logging exists.
The folder's file gridview is the next phase; the panel stops after
folder, file, preview and the size cap, and the writing status belongs
in that list rather than in a status line here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thebhef
force-pushed
the
task137-export-dialog
branch
from
September 23, 2026 16:46
e8a4f50 to
daf9c99
Compare
thebhef
force-pushed
the
task137-loggers
branch
from
September 23, 2026 16:46
f7a8ba7 to
25481f0
Compare
This was referenced Sep 23, 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.

Export names one file and writes a slice of what has already been
captured. A logger is the standing counterpart: a project element with
its own panel, pointed at a templated folder and file, that keeps
writing BLF to disk while it is switched on. Both fields resolve through
the same host command the export dialog's preview uses, so the tokens
and their strftime formats stay the model's;
{logger}is the fifth,available only inside one, and a logger's
{now}is the moment loggingstarted rather than the moment a field was read.
Logging runs exactly while the logger is enabled and something is
connected. That rule lives in one place:
connection_state::emit— thesingle write path both
set_and_emitandremove_and_emitfunnelthrough — reconciles the loggers, so "the state changed" and "the
loggers followed it" cannot drift apart. The decision itself is a pure
function over (configs, running, connected), tested without an app, a
filesystem, or a connection. Unlike the RBS Run flag (ADR 0028), the
enabled flag is saved with the project: logging writes locally and puts
nothing on a bus, so opening a project can resume one without
transmitting.
The writer does not sit on the ingest path.
run_pumpis the obvioushook and the wrong one: it would put a probe and a frame clone on the
hot path of every session, logger or not. Instead a writer thread
follows the capture model's own index — remember how far you have
written, ask the trace store for what has been appended since, write
that. Nothing is added to a session with no logger running, every frame
source is covered without naming one, and the render-tier runs show no
change in frame rate or mean lag with a logger writing throughout. What
it gives up is a guarantee against eviction outrunning the writer, so
settle_cursordetects exactly that and says so on the system lograther than leaving a silent gap.
Reaching the size cap closes the file and opens the next, with
-002,-003… on the last path segment before its extension; a run that startswhere a file already sits takes the next suffix the same way. One rule,
first_free_part, applied at the start and at every roll. The cap isread from
BlfCaptureWriter::bytes_on_disk, new here — the header plusevery flushed container, counted rather than seeked — because
FinishedCapture::byte_sizeis only correct afterfinishconsumes thewriter, which is no use to a file deciding whether to keep going.
Worth a reviewer's attention: stopping a logger waits for its writer
thread, and the exit path stops every logger after
disconnect_on_exit.The first live run left a capture whose header said
file_size = 0—the process walked away while the thread was still asleep between polls,
so
finishnever ran and the file was readable only through thereader's recovery path. A condvar-backed stop signal wakes the thread
instead of polling it out, which is what makes the wait cheap enough to
put on the disconnect path.
The folder's file gridview is the next phase; the panel stops after
folder, file, preview and format, and the writing status belongs in that
list rather than in a status line here.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com