justerm_core/event.rs
1//! Consumer event surface (#12): point-in-time notifications the engine
2//! accumulates while parsing, for the consumer to drain.
3//!
4//! Pull, not push — the engine queues events during `feed` and the consumer
5//! takes them with `drain_events`, mirroring the rest of the pull cadence
6//! (`damage` / `frame` / `reset_damage`). No callback is injected across the
7//! boundary, so the engine stays decoupled from the consumer's event loop
8//! (unlike alacritty's `EventListener`, whose push model would couple them).
9//!
10//! OSC 8 hyperlinks are deliberately absent — a hyperlink is per-cell state
11//! (which cells are links), not a point-in-time event, so it is modelled like
12//! graphemes in its own slice (#26), not here.
13
14use crate::serialize::{MarkerId, MarkerKind};
15
16/// Which byte ended an OSC sequence — and therefore which one ends its reply.
17///
18/// The engine relays this rather than choosing: a query event carries the
19/// terminator the request arrived with, and the consumer hands it back to the
20/// matching `report_*`. Under ADR-0017 the parse-time fact is a *mechanism* only
21/// the engine can observe, while *which* terminator to send is policy — and a
22/// consumer cannot exercise a policy on a fact it was never given, which is what
23/// #836 measured: `bell_terminated` was discarded at the parser boundary before
24/// any event was queued.
25///
26/// **The spec settles the direction, not just the reference tally.**
27/// `ctlseqs.txt:2020` — *"XTerm accepts either BEL or ST for terminating OSC
28/// sequences, and when returning information, uses the same terminator used in a
29/// query. While the latter is preferred, the former is supported for legacy
30/// applications."* Under ADR-0004 that outranks every implementation, this one
31/// included. **On the colour path** all three implementations that echo carry the
32/// terminator *outward with the request* rather than remembering it: alacritty
33/// binds it into the reply formatter it sends its consumer
34/// (`alacritty_terminal/src/term/mod.rs:1678`), ghostty makes it a field on the
35/// parsed command (`src/terminal/osc.zig:87`, written at
36/// `src/termio/stream_handler.zig:1497`), and xterm threads it as a parameter
37/// (`misc.c:3567`, emitted at `:3593`). xterm.js is the one that always sends ST
38/// (`CoreBrowserTerminal.ts:239`) — which is what this engine used to do.
39///
40/// **The colour qualifier is load-bearing, because `OSC 52` has a counterexample.**
41/// xterm *does* store the terminator for the clipboard: `int base64_final;` on the
42/// screen (`ptyx.h:2637`), written when the request is parsed (`misc.c:3389`) and
43/// read a file away when the paste finally arrives (`button.c:2218`), because its
44/// X selection retrieval is asynchronous. That is shape (b) — the one this engine
45/// weighed and did not take — on the exact sequence `report_clipboard` answers.
46///
47/// It is recorded here because it **strengthens** the choice rather than undoing
48/// it. xterm stores precisely because the reply is detached from the request, and
49/// its store is a *single scalar*, so two overlapping paste requests would collide
50/// exactly as one stored terminator does here. Detachment is the condition
51/// [`crate::Engine::drain_events`] creates for every family at once, not just one —
52/// so carrying answers it where storing only postpones it.
53///
54/// **Carrying beats remembering for a reason that is this channel's.**
55/// [`crate::Engine::drain_events`] hands over a batch, so a consumer can hold two
56/// colour queries at once and answer them in either order; one remembered scalar
57/// could not say which exchange it belonged to. An occurrence's payload is
58/// detached from its instant by the queue — ADR-0029 D4 records the same shape
59/// for coordinates — so there is no re-ask and the fact must ride the event.
60///
61/// **Exhaustive on purpose (#843's rule).** The space is closed at exactly two,
62/// with a date for each (`ctlseqs.txt:2024-2028`), so there is no member a later
63/// slice may name and nothing for `#[non_exhaustive]` to preserve.
64///
65/// ⚠ **The closure rests on the input space, not on the spec alone (#847).** ECMA-48
66/// gives `ST` a third encoding — the 8-bit C1 `0x9C` — and it is absent here because
67/// [`crate::Engine::feed`] does not treat a lone `0x80..=0x9F` byte as a control at
68/// all, not because the spec stops at two. A reader who finds `0x9C` in `ctlseqs.txt`
69/// and concludes this enum is missing a member has the reasoning backwards: were that
70/// contract ever revisited, the member would follow, and it is the contract that is
71/// load-bearing.
72/// ghostty reaches the same shape independently — `src/terminal/osc.zig:252` is a
73/// two-member `{ st, bel }` with no trailing `_`, which is Zig's marker for an
74/// open enum and is used at 23 other sites in that tree. Convergence on both the
75/// partition and the closure is the non-arbitrariness signal.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
77pub enum Terminator {
78 /// `ESC \` (ST), the terminator ECMA-48 documents and xterm prefers.
79 ///
80 /// The default, and what an OSC ended by **any other byte that ends one**
81 /// resolves to. Those streams are real rather than theoretical: `vte` ends a
82 /// string on three byte classes — `BEL`, the cancel pair `CAN`/`SUB` (`0x18` /
83 /// `0x1a`), and a bare `ESC` opening the next sequence — and only the first is
84 /// reported as bell-terminated. So a cancelled query is still relayed, and
85 /// answered ST.
86 ///
87 /// **Read "any other byte that ends one" strictly: the 8-bit C1 `ST` (`0x9C`) is
88 /// not a fourth class (#847).** It does not end the string, so there is no event
89 /// to carry a terminator and nothing resolves to this variant — the OSC stays
90 /// open instead. See [`crate::Engine::feed`] for why that is a contract.
91 ///
92 /// That is the right answer, not a fallback: xterm hardcodes ST on exactly this
93 /// shape (`charproc.c:8964`, *"should be ST"*) and ghostty's `Terminator.init`
94 /// returns `.st` for a missing byte (`src/terminal/osc.zig:263`).
95 #[default]
96 St,
97 /// `BEL` (`0x07`), supported for legacy applications.
98 ///
99 /// Real applications still emit it: `nvim` 0.8.0 asks `ESC ] 11 ; ? BEL` in
100 /// this crate's own `cursor_color_nvim.raw` fixture. And the shell idiom
101 /// `printf '\e]11;?\a'; read -d $'\a'` reads *until* BEL, so an ST answer to
102 /// a BEL question blocks that `read` until it times out.
103 Bel,
104}
105
106impl Terminator {
107 /// The bytes that end a reply carrying this terminator.
108 pub(crate) fn bytes(self) -> &'static [u8] {
109 match self {
110 Terminator::St => b"\x1b\\",
111 Terminator::Bel => b"\x07",
112 }
113 }
114}
115
116/// Which selection an `OSC 52` clipboard request names (#828).
117///
118/// A *value*, never the protocol byte, so a consumer never parses the sequence —
119/// the same reason [`TermEvent::SetPaletteColor`] carries a `u8` index rather
120/// than the field it was written in.
121///
122/// **Three members, and `p` is kept apart from `s` deliberately.** The
123/// sequence's target field admits `c`, `p`, `q`, `s` and the eight cut buffers
124/// (`ctlseqs.txt:2156`); justerm models the three a consumer can act on and
125/// ignores the rest rather than folding them onto a neighbour, since mapping `q`
126/// onto a selection would be the engine inventing an equivalence the application
127/// did not ask for. ghostty folds every unrecognised kind onto the clipboard
128/// (`src/termio/stream_handler.zig:1013`); alacritty ignores them
129/// (`alacritty_terminal/src/term/mod.rs:1714`), and so does this. Read
130/// ghostty's from the `switch` and not from the comment four lines above it,
131/// which says *"we ignore the 'kind' field and always use the standard
132/// clipboard"* and is contradicted by the code under it — only the `else` arm
133/// goes to `.standard`.
134///
135/// **The first draft collapsed `p` and `s` into one member, and that was wrong
136/// on the wire.** alacritty collapses them
137/// (`alacritty_terminal/src/term/mod.rs:1713`) but replies with the byte the
138/// application sent (`:1744`), so the collapse never reaches a client. This
139/// engine hands the consumer a value and gets it back at `report_clipboard`, so
140/// a collapse here would answer `ESC ] 52 ; s ; ?` naming `p` — a selector the
141/// application never wrote, in the one field a client could pair a reply on. The
142/// spec lists the two separately (`ctlseqs.txt:2157`), xterm binds them to
143/// different atoms (`misc.c:3327`) and echoes the recognised list back
144/// (`misc.c:3384`), and ghostty keeps three locations apart in both directions
145/// (`src/Surface.zig:5954`, `src/terminal/c/terminal.zig:2942`). Splitting the
146/// member is what lets the value round-trip without the engine remembering
147/// anything.
148///
149/// **`#[non_exhaustive]` (#843).** The set is open by the paragraph above: `q` and
150/// the eight cut buffers are in the sequence and unmodelled here, so a later slice
151/// may name one. A consumer meeting a member it does not know can decline the
152/// request, which is already how it refuses any of them.
153///
154/// **ghostty reaches the same shape independently**, which #843 had recorded as
155/// impossible — its issue says Zig "has no such construct", and Zig does: a
156/// trailing `_` marks a non-exhaustive enum, used at 23 sites in that tree. The
157/// one that matters here is `src/terminal/clipboard.zig:2`, whose `Location` is
158/// `{ standard, selection, primary, _ }` — the same three members this type
159/// carries, and open for the same reason. Convergence on both the partition and
160/// the openness is the non-arbitrariness signal; it was invisible while the
161/// corpus was recorded as having no vote.
162#[non_exhaustive]
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub enum ClipboardTarget {
165 /// The system clipboard — the `c` field, **and the empty field**.
166 ///
167 /// The empty field is the common form in the wild rather than an edge case:
168 /// it is what `tmux` 3.2a was measured emitting for both an ordinary
169 /// copy-mode copy and `set-buffer -w` (#828). Reading it as "unrecognised"
170 /// would drop the only emission this project has observed.
171 Clipboard,
172 /// The primary selection — the `p` field. On a platform with no primary
173 /// selection a consumer may treat it as [`Clipboard`](Self::Clipboard); the
174 /// engine does not make that choice for it.
175 Primary,
176 /// The `s` field — *"the configurable primary/clipboard selection"*
177 /// (`ctlseqs.txt:2161`), which is to say: whichever of the two the user has
178 /// configured.
179 ///
180 /// **Relayed rather than resolved, and that is the boundary working.** The
181 /// thing that decides what `s` means is a setting: xterm resolves `SELECT`
182 /// through `DefaultSelection`, which is the `selectToClipboard` resource
183 /// (`button.c:2081`), and under ADR-0017 a setting is the consumer's. So the
184 /// application's choice is carried through unchanged and the consumer
185 /// resolves it against the configuration it owns.
186 ///
187 /// **A consumer with no such setting should treat this as
188 /// [`Primary`](Self::Primary), because that is what xterm-as-shipped does**
189 /// — `selectToClipboard` defaults to false. Worth stating rather than left
190 /// to taste, since the alternative reading sends the copy somewhere the
191 /// reference would not.
192 ///
193 /// And the setting is not purely out of reach: **DECSET 1041 sets the same
194 /// resource from the stream** (`ctlseqs.txt:1008`), so an engine that
195 /// tracked that mode could resolve `s` itself. justerm does not model 1041,
196 /// which is a *declined* capability rather than an impossible one — the
197 /// honest form of the claim, and the mode is unimplemented here like the
198 /// rest of the tail (#47).
199 Selection,
200}
201
202/// A consumer-facing event emitted while parsing the VT stream.
203///
204/// **`#[non_exhaustive]`, so a consumer must carry a `_` arm and a new variant
205/// never breaks one.** Decided 2026-09-02, by the maintainer, while #828 was
206/// adding two — and what decided it was neither this slice nor any consumer we
207/// can see.
208///
209/// **What decided it is `CLAUDE.md`'s own identity statement**: *"`justerm-core`
210/// is not penterm-only — it is a reusable, independent crate."* That sentence
211/// says there are consumers we cannot edit, which is precisely what this
212/// attribute defends; a crate whose identity were "internal, used by penterm"
213/// would want the opposite, because there a broken build is the compiler doing
214/// us a favour. So this follows from a call already made rather than from a
215/// preference, and it reverses only if that identity does.
216///
217/// Three measurements, so the next reader does not have to retake them:
218///
219/// - **crates.io reverse dependencies: zero** (the single row the API returns is
220/// this crate itself), across 248 downloads split over 11 versions — i.e. no
221/// external consumer exists *today*. That is why the identity statement had to
222/// decide it: there was nothing to observe.
223/// - **Cost in this workspace: zero.** `cargo test --workspace` (87 suites) and
224/// `clippy -D warnings` both stay green. A same-crate `match` may still be
225/// exhaustive, `justerm-wasm-decode` and `justerm-renderer` never name
226/// `TermEvent`, and `justerm-web`'s `events.ts` is a deliberately narrower
227/// union (title / bell / cwd).
228/// - **The window closes at `1.0.0`.** Adding this is free while the crate is
229/// `0.x` and is *itself* a breaking change afterwards, while an enum without
230/// it turns every future variant into a major bump. Conformance here is
231/// cumulative by design (#47 is a perpetual tail) and the two slices before
232/// this one added three variants and two, so that rate is measured rather than
233/// assumed.
234///
235/// **The argument that lost, recorded because it is a real cost.** An exhaustive
236/// match is a *feature* for a consumer: the compiler tells them a new event
237/// exists and makes them decide about it. penterm's `route_event` is the worked
238/// example — its `ColumnMode` and `ColorSchemeQuery` arms carry a comment
239/// explaining why each is dropped, written by someone the compiler had just
240/// informed. That signal is given up here, and it now has to come from release
241/// notes. It loses because this is a *notification* channel where ignoring an
242/// unknown event is documented as safe, so the guarantee belongs in prose rather
243/// than in the type — but a consumer who wanted the old behaviour is not
244/// imagining the loss.
245///
246/// (penterm was the evidence that an outside exhaustive matcher can exist — its
247/// five-variant `match` predates nine minor versions of this enum — and not the
248/// reason. It is being reimplemented, which is exactly why it could not be.)
249#[non_exhaustive]
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum TermEvent {
252 /// The window title is now this string.
253 ///
254 /// Read the tense carefully: since #823 this is **not** only "the
255 /// application set a title". Two paths emit it — `OSC 0`/`OSC 2`, and an
256 /// XTWINOPS title *pop* (`CSI 23 t`) restoring what an earlier `CSI 22 t`
257 /// saved. A consumer that treats it as "the title is now this" is correct
258 /// for both; one that treats it as "the application just chose this" is
259 /// wrong for the second, which is why there is no separate pop event.
260 ///
261 /// A pop can legitimately restore the **empty** string — every application
262 /// measured pushes at startup, before setting a title of its own — and that
263 /// means "go back to whatever you would show by default", not "show a blank
264 /// title".
265 Title(String),
266 /// The terminal bell rang (BEL, `0x07`).
267 Bell,
268 /// The working directory was reported (OSC 7), e.g. `file://host/path`.
269 Cwd(String),
270 /// The app requested 80/132-column mode (DECCOLM `?3`). justerm is
271 /// dimension-free, so this is a *request* — the consumer may honor it by
272 /// calling `resize(cols, rows)`, or ignore it. `cols` is 80 or 132 (#82).
273 ColumnMode { cols: usize },
274 /// The app queried the light/dark color scheme (DSR `CSI ? 996 n`). justerm
275 /// is theme-agnostic, so the consumer (which knows the scheme) answers by
276 /// calling `Engine::report_color_scheme` (#85).
277 ColorSchemeQuery,
278 /// The app set ANSI palette entry `index` to `spec` (OSC 4). One event per
279 /// `index ; spec` pair in the sequence. The cell still references
280 /// `Indexed(index)` — only the consumer's `palette[index]` changes, so the
281 /// engine stays theme-agnostic (#122).
282 SetPaletteColor { index: u8, spec: String },
283 /// The app set the default foreground colour (OSC 10). Raw spec, forwarded
284 /// for the consumer to apply — theme-agnostic, like [`SetBackground`](Self::SetBackground) (#122).
285 SetForeground(String),
286 /// The app set the default background colour (OSC 11). The engine is
287 /// theme-agnostic, so it forwards the raw spec string (`rgb:…`/`#…`) for the
288 /// consumer to parse and apply to its palette — it never holds hex (#122).
289 SetBackground(String),
290 /// The app reset palette entries to the theme default (OSC 104). `None` =
291 /// the whole table (no argument); `Some(index)` = one entry, one event per
292 /// index given. The consumer restores its palette (#122).
293 ResetPaletteColor(Option<u8>),
294 /// The app queried ANSI palette entry `index` (OSC 4 with `?` for that pair);
295 /// the consumer answers with `report_palette_color` (#122).
296 QueryPaletteColor {
297 index: u8,
298 /// The terminator `report_palette_color` must answer with (#836).
299 terminator: Terminator,
300 },
301 /// The app set the cursor colour (OSC 12, #832). The third slot of the same
302 /// dynamic-colour sequence `SetForeground` and `SetBackground` ride, and
303 /// theme-agnostic for the same reason: the raw spec is forwarded and the
304 /// consumer — which owns the palette *and* the cursor's contrast guard —
305 /// applies it.
306 SetCursorColor(String),
307 /// The app queried the cursor colour (OSC 12 with `?`); the consumer answers
308 /// with `report_cursor_color` (#832).
309 QueryCursorColor {
310 /// The terminator `report_cursor_color` must answer with (#836).
311 terminator: Terminator,
312 },
313 /// The app reset the cursor colour to the theme default (OSC 112, #832). The
314 /// third member of the 110/111/112 reset family, and the one real
315 /// applications emit most: `nvim` sends it on startup, on every alt-screen
316 /// transition and on exit.
317 ResetCursorColor,
318 /// The app reset the default foreground to the theme default (OSC 110, #122).
319 ResetForeground,
320 /// The app reset the default background to the theme default (OSC 111, #122).
321 ResetBackground,
322 /// The app queried the default foreground colour (OSC 10 with `?`); the
323 /// consumer answers with `report_foreground` (#122).
324 QueryForeground {
325 /// The terminator `report_foreground` must answer with (#836).
326 terminator: Terminator,
327 },
328 /// The app queried the default background colour (OSC 11 with `?`). The
329 /// theme-agnostic engine relays it; the consumer answers with
330 /// `report_background` (#122), mirroring `ColorSchemeQuery`.
331 QueryBackground {
332 /// The terminator `report_background` must answer with (#836).
333 terminator: Terminator,
334 },
335 /// The app asked for `text` to be put on `target` (`OSC 52` with a payload,
336 /// #828). The engine has already base64-decoded it, and holds no clipboard
337 /// of its own.
338 ///
339 /// **This is a request, not a fact.** Whether the copy happens is the
340 /// consumer's: it owns the platform clipboard, any permission model and any
341 /// prompt, and a consumer that drops this event has refused the copy. The
342 /// engine carries no allow/deny knob, which is where it parts company with
343 /// alacritty — alacritty gates the same sequence behind a four-state config
344 /// (`alacritty_terminal/src/term/mod.rs:1706`) because alacritty *is* the
345 /// consumer. Under ADR-0017 that gate lives one layer out.
346 ///
347 /// **An empty `text` means "clear it".** `ESC ] 52 ; c ; ESC \` carries a
348 /// payload that decodes to nothing, and both the spec and xterm end that
349 /// exchange with an empty selection — the spec because `Pd` *"becomes the
350 /// new selection"* whatever it is (`ctlseqs.txt:2166`), xterm because it
351 /// clears the buffer before appending anything (`misc.c:3410`). Note this is
352 /// **not** the spec's *"neither a base64 string nor `?`"* clause at
353 /// `ctlseqs.txt:2174`, which the engine diverges from: an empty payload is a
354 /// perfectly well-formed encoding of no bytes, so it never reaches that
355 /// sentence. Citing `:2174` here, as an earlier draft did, would have the
356 /// same line standing as authority followed and as authority departed from.
357 /// ghostty pins the same input under a test named *"clear clipboard"*
358 /// (`src/terminal/osc/parsers/clipboard_operation.zig:93`). It needs no rule
359 /// of its own here, which is the argument for having none: an empty payload
360 /// *is* a well-formed encoding of no bytes, so it reaches the consumer
361 /// through the ordinary path.
362 ClipboardStore {
363 target: ClipboardTarget,
364 text: String,
365 },
366 /// The app asked what is on `target` (`OSC 52` with a `?` payload, #828).
367 /// The consumer answers by calling `report_clipboard`, which encodes the
368 /// reply — or declines, which is how a clipboard *read* is refused
369 /// independently of a write.
370 ///
371 /// The engine cannot answer this itself and deliberately holds nothing that
372 /// would let it: a query is answered from the consumer's clipboard or not at
373 /// all, so there is no engine state here for a hostile application to read
374 /// back. Same `Query…` + `report_…` shape as `OSC 4`/`10`/`11`/`12`.
375 QueryClipboard {
376 target: ClipboardTarget,
377 /// The terminator `report_clipboard` must answer with (#836).
378 terminator: Terminator,
379 },
380 /// A decoration marker's line left the buffer — evicted past the scrollback
381 /// cap, or scrolled out of an in-screen region (#118). The handle is now
382 /// dead; the consumer drops the decoration bound to it. This is the
383 /// frame-mode equivalent of xterm's `IMarker.onDispose` — disposal is a
384 /// point-in-time fact (a marker absent from a frame may merely be scrolled
385 /// off-screen), so it rides the event queue, not the frame overlay.
386 MarkerDisposed(MarkerId),
387 /// A marker was created (#490) — by `add_marker`, or by the *stream* through an
388 /// OSC 133 command mark, which the consumer never called for.
389 ///
390 /// The mirror of [`TermEvent::MarkerDisposed`], and it exists for the same reason
391 /// ADR-0020 R1 gives: an appearance is an occurrence, not state, so it rides this
392 /// queue rather than a frame field. Without it a consumer that pulled a marker
393 /// index (`Engine::marker_index`) has no way to learn of a marker born after its
394 /// pull — the population would only ever shrink.
395 ///
396 /// `line` is absolute at the moment of creation, and `evicted_total` / `epoch` are the
397 /// instant it is absolute at — the same triple [`crate::MarkerIndex`] carries, because
398 /// this event is that pull's incremental mirror. The consumer appends the entry with
399 /// the basis it arrived on and rebases it exactly like a pulled one.
400 ///
401 /// **The two are one fact and neither is usable alone (#737).** A single `feed` can
402 /// create a marker and then evict, so by the end of the batch the buffer's origin has
403 /// moved out from under the line this event already carries. `Frame::evicted_total` is
404 /// the basis at the *end* of that batch, so reading `line` against it misplaces the
405 /// marker by however much the batch evicted after the birth — measured at three lines,
406 /// with the event line, both frame bases, the epoch and `Frame::marker_count` all
407 /// identical to the batch that evicted *first* and needs no adjustment at all.
408 ///
409 /// **And a basis dates only a uniform move (#741).** Eviction shifts every marker by
410 /// the same amount, which is what one scalar can say; a reflow or a region rotate
411 /// moves them *individually*, which is what `epoch` is for. A birth still queued when
412 /// the epoch moves describes a buffer that no longer exists, and carrying only the
413 /// basis leaves that indistinguishable from a birth in the current generation —
414 /// measured, a mark at absolute 3 reflowed to 5 with the basis unmoved at 0.
415 ///
416 /// Deliberately not an epoch bump: a bump costs a whole re-pull, and creation is
417 /// `O(1)` information.
418 MarkerCreated {
419 id: MarkerId,
420 line: u32,
421 kind: MarkerKind,
422 /// Lines evicted since RIS at the moment of creation — the basis `line` is
423 /// absolute at. Carried rather than inferred so that placement does not depend on
424 /// whether the consumer drains this queue before or after it reads the frame,
425 /// which nothing in the API specifies.
426 ///
427 /// It is the same quantity `Frame::evicted_total` reports, so a consumer whose
428 /// transport crosses a language boundary owes it the same treatment: the wasm
429 /// frame getter hands its `u64` over as an `f64` deliberately (exact to 2^53),
430 /// because a `BigInt` on one side of a subtraction and a `number` on the other is
431 /// a `TypeError`, not a rounding question.
432 evicted_total: u64,
433 /// The marker generation this line belongs to — [`crate::MarkerIndex::epoch`] at
434 /// the moment of creation (#741). Two lines dated with different epochs are
435 /// answers about different buffers and nothing rebases one onto the other, so a
436 /// consumer adopts this entry only into the generation it names and lets the
437 /// re-pull that the bump already forces supply it otherwise.
438 ///
439 /// Compare it for **equality**, never for order: the counter is
440 /// `wrapping_add`, so `<` is meaningless across a wrap while `==` is exact.
441 epoch: u32,
442 },
443}