fixed event/live transition

This commit is contained in:
Martino Ferrari committed 2026-10-13 23:37:49 +02:00
1 parent bb99be5889
commit 881b52748e
9 files changed
+314 -2

No files matched your search

@@ -59,6 +59,17 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting.
- **It refuses again while `replaying` is set: a file view is not the source's session.** Same
argument, different cause — this view configured nothing about the stream. A plain replay leaves
the rows where the user put them, so its save would merely be pointless; a **group** rebinds every
slot to its own renamed channels and the save wrote `signals = ["square (A)", …]` into the file.
Nothing noticed until the way back: *Go live* re-applies the session, those names match no live
signal, so nothing was enabled and **the live view came back with no channels at all** — on the
window the poisoned file carried, which is also how it swallowed the window restore below it. The
reading is `[events] live restored (after a group)`: the symptom was a stuck `pending=1` beside
`channels=0`, because the restore waits for a channel list that was never going to arrive. The
explicit *Save as* is deliberately **not** guarded: that one is a user's own answer to "save this
view", and its dialog reports the outcome.
## A configuration is a named session, and a source may hold several
@@ -182,6 +182,53 @@ deliberately **not** one of the conditions: a stored event being examined is a f
trigger, and tying the pane to the trigger would close the list under the click that opened an event.
The recording switch lives in the trigger panel, not in the pane it empties.
## Opening a file is a detour, so *Go live* is a restore
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes
the event's own span because that is what *framing* an event means, and the trigger goes off because
a file has no edge to wait for (leaving it on kept a frozen capture on screen *instead* of the file,
so the click looked like it did nothing at all). None of those reasons survive the way back. What
the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up"
is the complaint this answers.
- **`snapshot_live_state()` on the way in, `restore_live_state()` on the way out.** The snapshot is
taken by both ways into a file view -- `replay_event()` and `build_event_group()` -- *before* the
acquisition is stopped, and is a **no-op while `replaying`**: a second event opened from the first,
or a group opened from a replay, would otherwise snapshot a file's settings as "live", which are
the very values the restore exists to undo. The group takes it after its argument checks, because a
group that is refused leaves the live view alone.
- **The trigger channel is stored by *name*.** Same reason `TriggerEngine::configure()` takes one: the
index is a position in a channel list that a file view rebuilds, and a group **renames** its
channels outright (`square (A)`). Putting an index back would select a different signal.
- **`enabled` and `armed` are deliberately not restored from the snapshot.** They belong to the
toolbar toggle, which owns an atomic mirror of `enabled` (`set_trigger_enabled()`) that a plain
field write under the lock would leave stale. The restore writes the rest of the `TriggerConfig`
and then moves the *toggle*, whose handler states both.
- **The restore runs from the tick, not from `go_live_again()`, and *after* the deferred layout
block.** Two writers would otherwise beat it:
- `apply_layout()` ends in `apply_time_window(st.window_s)` -- the window the **session file**
holds, not the one the live view had. `go_live_again()` clears `layout_loaded`, so that block
fires on the next tick; a restore done inside `go_live_again()` is overwritten by the saved value
a frame later. Being the next statement after it in `tick()` is the fix.
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
channels, silently settles on the first signal *and* re-defaults the level -- which is why
`trigger_level_touched` is restored with the configuration.
- **It waits for the stream's own channel list, gated on `generation()` having moved.** The restored
name has to be resolved against the live channels, and those do not exist until the first CONFIG of
the restarted client lands on the producer thread. `App::live_restore_generation` is the generation
read when *Go live* was pressed; `live_restore_pending` stays set until it differs **and**
`channels()` is non-empty. A `pending=1` that never clears is therefore a reading about the
*stream*, not about the restore -- see `invariants-session-and-export.md` for the save that made it
stick at `channels=0`.
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
handler, so the ones with nothing to put back are left alone. Both writes are inside
`updating_panels`.
- **`pinned` is not a pass/fail reading on the way back.** `apply_time_window()` drops the file's pin
and re-arms the live follow, but if the restored trigger was *on* it can latch again within the
frame or two before the probe reads -- and a held capture is pinned, correctly. That is what
`held=` and `mode=` are beside it for.
## A comparison is two plots of one model
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
+20
View File
@@ -992,6 +992,7 @@ configuration that carries settings, is the defect this exists for — a saved h
[events] activated row0: replaying=1 pane=1 pane_w=240 pane_box=228 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] replayed: channels=3 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] live restored (after replay): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=10/5
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
@@ -1000,6 +1001,7 @@ configuration that carries settings, is the defect this exists for — a saved h
[events] compare sync: y_a=-3.500 y_b=-3.500 mirrored=1
[events] accumulate: kind=2 rows=1 channels=8 samples0=32964 selected=2 traces0=8 window=2.000
[events] group left: replaying=0 kind=0 waveform=0
[events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=22/16
```
`thumb_size` is the built size of the row's picture: `kEventThumbW` wide, and as tall as the *stored*
@@ -1016,6 +1018,21 @@ header — `480` wide and `44 * signals` tall — and `row_drawn=1` says the row
The probe therefore needs a cache a **previous** run populated: it fires ~4.5 s in, before this run's
first latch.
`[events] live restored (…)` is the way *back*, read once per way into a file view — after a plain
replay and after a group — and four of its fields are a **pair**: the value now, and the value the
live view held before the file view opened over it. The probe captures that expectation itself
(`capture_live_expectation()`, `App::dbg_live_*`) rather than asking the restore path for it: a
reading taken from the mechanism under test asserts nothing. The window is set to 0.5 s just before
the event is opened precisely so the pair can disagree — with the event's own span there would be
nothing to tell "restored" from "untouched".
Both lines are read a *later* step, not in the handler that pressed *Live*: the restore waits for the
restarted client's first CONFIG, so it is performed by a tick ~900 ms on. `pending=0` is what says it
happened; `gen=a/b` is the generation now against the one *Go live* read. A **stuck `pending=1`** is
not a reading about the restore but about the stream behind it — the generation never moved, or the
channel list came back empty, which is how a session file poisoned by a save taken during a group
produced `pending=1 channels=0` and a live view with nothing on it.
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `n`, `stem`, `seq` | one entry per latch, named by its time and the run's counter | two events in one second overwriting each other, or a cache that is never written |
@@ -1032,6 +1049,9 @@ first latch.
| `axis`, `marker`, `pane_box` (in `activated row0`) | `[-pre, +post]` of the event just opened, `marker=0.000`, and a `pane_box` close to `pane_w` | an opened event framed on something other than its own pin; a trigger line left **parked** (a large negative) rather than on the edge, which is a file opened without its marker; or a pane whose *rendered* width is 0 while the divider says 240 -- the position is what was asked for, `pane_box` is what the pane actually got, and a divider at 240 with a zero-width pane is still no pane |
| `rows_kept`, `selected` (in `activated row0`) | `rows_kept=1` and `selected=N/N` -- the counts either side of the activation equal | the list **rebuilt** by the activation. It is the row *object* that is compared, not its index: a rebuilt list has a row 0 too, and only identity tells a list that was left alone from one that was torn down under the user's scroll position and their comparison selection. `mark_events_on_screen()` must rewrite the titles in place |
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
| `window`, `trigger`, `channel`, `level` (in `live restored`) | each equal to its `(want …)` | the way back out of a file view discarding what the live view was set to. One wrong half names which writer won: a `window` that reads the **session file's** value is the deferred `apply_layout()` beating the restore; `trigger=0` is the toggle the file view switched off and nobody switched back; an empty or shifted `channel` is an index restored instead of a name, after a rebuild renumbered (or a group renamed) the channels; and a `level` that moved on its own is `rebuild_trigger_signals()` re-defaulting it because `trigger_level_touched` was not restored with the configuration |
| `pending`, `gen` (in `live restored`) | `pending=0`, and the two generations different | the restore still waiting. It is gated on the stream's first CONFIG, so `pending=1` says the channel list the stored trigger name needs never arrived -- with `channels=0` behind it, a session file that enabled nothing |
| `pinned`, `held`, `mode` (in `live restored`) | `pinned=0`, **or** `pinned=1` with `held=1` | a file's pin surviving the way back, which is a live view that cannot be navigated. `apply_time_window()` drops it; the second reading is the legitimate case -- a restored trigger that was *on* can latch again before the probe reads, and a held capture is pinned, correctly |
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
| `tick` | 1 in live mode, **0** once a file is on screen | the signal list still offering "acquire this signal" for samples that are already stored |
| `window` (in `activated row0`) | the **event's** span -- the probe sets 0.5 s just before the click, so reading the event's span says opening it framed it | a replay that leaves the previous window alone, or one that restates it every frame instead of once |