libxtc 0.4.0
Async concurrency for C: Tokio + Seastar + BEAM, in one library
Loading...
Searching...
No Matches
xtc_tail.h
1/*-
2 * Copyright (c) 2026, The XTC Project
3 * Use of this source code is governed by the ISC License.
4 *
5 * src/inc/xtc_tail.h
6 * A runtime microscope: cheap, high-volume recording of what the
7 * scheduler and runtime are actually doing -- "tail -f a system" --
8 * so hard async bugs (lost/late wakeups, long polls, mailbox backups,
9 * scheduler imbalance) become obvious after the fact. Inspired by
10 * dial9 ("a microscope for Tokio").
11 *
12 * Unlike xtc_stats (aggregate counters) and xtc_trace (the causal
13 * message trace), xtc_tail records every INDIVIDUAL runtime event
14 * tied to a precise instant, so a degraded window can be diffed
15 * against a normal one. It is OFF by default and one branch when
16 * disabled -- observability must not tax production.
17 *
18 * Phase 1 (this): the SCHED source (proc spawn / exit / wake / run)
19 * over a bounded per-process ring, an in-process read callback, and a
20 * versioned binary dump to an fd. The IO/OS sources, on-disk spill
21 * with rotation, and the offline viewer are staged follow-ons (see
22 * the roadmap); the record format and API are designed to accept them
23 * without a break.
24 */
25
26#ifndef XTC_TAIL_H
27#define XTC_TAIL_H
28
29#include "xtc_export.h"
30
31#include <stddef.h>
32#include <stdint.h>
33
34#include "xtc.h"
35#include "xtc_proc.h"
36
37/* Event sources, an enable mask (dial9-style: pay only for what you turn
38 * on). Phase 1 implements SCHED; the others are reserved so the mask is
39 * stable across the staged build-out. */
40#define XTC_TAIL_SCHED (1u << 0) /* proc spawn/exit/wake/run */
41#define XTC_TAIL_MSG (1u << 1) /* reserved: send/recv/mailbox depth */
42#define XTC_TAIL_IO (1u << 2) /* reserved: fd reg/del/completion */
43#define XTC_TAIL_OS (1u << 3) /* reserved: per-loop CPU/RSS sampling */
44#define XTC_TAIL_ALL (XTC_TAIL_SCHED | XTC_TAIL_MSG | XTC_TAIL_IO | XTC_TAIL_OS)
45
46/* Event kinds recorded by the SCHED source. */
47enum xtc_tail_kind {
48 XTC_TAIL_SPAWN = 0, /* a proc was spawned */
49 XTC_TAIL_EXIT = 1, /* a proc exited (detail = reason) */
50 XTC_TAIL_WAKE = 2, /* an I/O completion was DISPATCHED to a task
51 * (the waker side). pid.loop_id is the
52 * DISPATCHING loop; local_id/gen are 0
53 * because dispatch has a task, not a proc.
54 * detail = the xtc_task_t * as an integer,
55 * which is the join key: match it against
56 * the `task` column of xtc-procs, or against
57 * the XTC_TAIL_PARK_TASK event the parking
58 * fiber emits just before its PARK.
59 *
60 * NOTE: XTC_TAIL_PARK's detail is the aio OP,
61 * NOT a task pointer. An earlier version of
62 * this comment said otherwise and sent a
63 * consumer chasing a join that could not be
64 * performed; PARK_TASK exists because of it.
65 *
66 * This is the event that separates "the wake
67 * was never generated" from "the wake was
68 * generated and the task still never ran":
69 * a WAKE for task T with no following RUN
70 * for T's pid means dispatch DID run and the
71 * loss is after it. */
72 XTC_TAIL_RUN = 3, /* a proc began running after a wake
73 * (detail = wake-to-run latency, ns) */
74 XTC_TAIL_PARK = 4, /* a proc parked (blocked on recv/timer/fd) */
75 /* MSG source: */
76 XTC_TAIL_SEND = 5, /* pid sent a message (detail = payload bytes) */
77 XTC_TAIL_RECV = 6, /* pid received a message (detail = bytes) */
78 XTC_TAIL_MBOX_HWM = 7, /* pid's mailbox depth reached a new high-water
79 * (detail = the new peak depth) */
80 /* Loop liveness (SCHED source). pid.loop_id identifies the loop;
81 * local_id/gen are 0 because a LOOP is not a proc. detail = the
82 * number of events the poll dispatched.
83 *
84 * This exists to answer one question a park/run timeline cannot:
85 * when a fiber's park has no matching RUN, did its loop KEEP WORKING
86 * (so the loop is alive and that fiber specifically was skipped) or
87 * did the loop stop entirely? Those have different causes and
88 * different fixes, and without a per-loop event the only way to tell
89 * them apart is to infer from the absence of other pids' events --
90 * which cannot distinguish "loop dead" from "we stopped recording",
91 * and a consumer hit exactly that ambiguity.
92 *
93 * Emitted after each completed xtc_io_poll on the loop's own ring, so
94 * a loop that is still polling produces a steady stream even when it
95 * dispatches nothing (detail = 0). */
96 XTC_TAIL_LOOP_POLL = 8,
97 /*
98 * The identity of the task a proc is parked as. pid is the PARKING
99 * proc; detail is its xtc_task_t * as an integer.
100 *
101 * This exists purely to make XTC_TAIL_WAKE joinable. WAKE is emitted
102 * from completion dispatch, which holds a task and no pid (there is no
103 * task-to-proc back pointer), so it carries the task pointer. PARK
104 * carries the pid and, for an aio park, the OP in detail -- which a
105 * consumer relies on to tell an fdatasync park from a read park.
106 * Overwriting that op with the task pointer would trade one key for
107 * the other; emitting this alongside gives both.
108 *
109 * Emitted immediately before the PARK it describes, from the same
110 * fiber, so the pairing is unambiguous:
111 *
112 * PARK_TASK pid=25.1.1 detail=<task*>
113 * PARK pid=25.1.1 detail=3 (XTC_AIO_FDATASYNC)
114 * ...
115 * WAKE pid=<loop> detail=<task*> <- joins on detail
116 * RUN pid=25.1.1 detail=<ns>
117 *
118 * A PARK_TASK whose task pointer never appears in a later WAKE means
119 * the completion never reached dispatch. One that does appear, with
120 * no following RUN for that pid, means dispatch ran and the loss is
121 * downstream of it. Those are different bugs.
122 */
123 XTC_TAIL_PARK_TASK = 9,
124 /*
125 * A CQE was REAPED from a ring and its life ended here. pid.loop_id
126 * is the REAPING loop; local_id/gen are 0 (the reaper holds a ring,
127 * not a proc). detail is the tag the CQE resolved to -- the same
128 * xtc_task_t * that PARK_TASK and WAKE carry -- or 0 when it resolved
129 * to no tag at all.
130 *
131 * This closes the LAST gap in the chain. PARK_TASK/WAKE/RUN can prove
132 * a completion never reached dispatch, but not WHY: the CQE might never
133 * have been posted, or been posted and consumed by the reaper without
134 * ever being handed on. Those are different subsystems. A REAP event
135 * for task T with no WAKE for T means the reaper consumed T's
136 * completion and dropped it; no REAP at all means the kernel never
137 * posted it (or we never looked).
138 *
139 * Emitted at every point where a CQE is consumed, INCLUDING the paths
140 * that deliberately discard one, because a deliberate discard is
141 * indistinguishable from a bug in a timeline that cannot see it:
142 *
143 * detail = <task*> the completion was stored for dispatch
144 * detail = 0 consumed and NOT handed on. Expected for the
145 * wakeup-pipe CQE and for a poll_remove cancel
146 * (user_data NULL by design), and the signature of
147 * a completion dropped because its registration was
148 * already torn down.
149 *
150 * Rides XTC_TAIL_SCHED like the other kinds. It is the highest-volume
151 * event here -- one per CQE -- so on a busy ring it WILL dominate the
152 * buffer; check xtc_tail_dropped() before believing any absence, and
153 * prefer a short capture window. (The LOOP_POLL lesson: an event that
154 * crowds out the data it explains is worse than none.)
155 */
156 XTC_TAIL_REAP = 10,
157 /*
158 * An SQE was handed to the kernel. pid.loop_id is the SUBMITTING loop;
159 * local_id/gen are 0. detail is the task pointer the submission is on
160 * behalf of -- the same key PARK_TASK, REAP and WAKE carry -- or 0 when
161 * the submission is not tied to a parked task.
162 *
163 * This separates the two halves of "no REAP", which REAP alone cannot:
164 * a completion that never came back may never have been ASKED FOR.
165 *
166 * SUBMIT then REAP normal.
167 * SUBMIT, never REAPed the kernel took the request and no
168 * completion came back -- look at the ring,
169 * the wait, or the request itself.
170 * no SUBMIT at all we never asked. The fiber parked for a
171 * completion that was never queued, which no
172 * amount of polling can deliver.
173 */
174 XTC_TAIL_SUBMIT = 11,
175 /*
176 * A submission did NOT reach the kernel. pid.loop_id is the submitting
177 * loop; detail is the negated errno, or 0 if there was none.
178 *
179 * This is the one kind here whose mere PRESENCE is a fault -- every
180 * other needs a join to mean anything. If one appears, a fiber is
181 * parked on a request the kernel never accepted, and no reap-side or
182 * dispatch-side investigation can explain it.
183 *
184 * Reachable today: io_uring_submit returns the number of SQEs consumed
185 * or a negative errno, and every submit site in the uring backend
186 * discarded that value, so a partial or failed submit left a fiber
187 * parked forever with nothing in flight, silently.
188 */
189 XTC_TAIL_SUBMIT_FAIL = 12
190};
191
192/*
193 * XTC_TAIL_SUBMIT_FAIL detail encoding. Below this value the detail is a
194 * plain errno (the submit was refused); at or above it, subtract the base to
195 * get the number of SQEs left UNSUBMITTED by a short submit, which has no
196 * errno of its own. Two failure shapes, one field, no ambiguity: a short
197 * submit returns a POSITIVE count, so an errno-only report would call it
198 * success while an SQE never reached the kernel.
199 */
200#define XTC_TAIL_SHORT_SUBMIT_BASE 4096
201
202/* One recorded event. Fixed layout; the binary dump writes it verbatim
203 * behind a versioned header, so a reader across the wire/disk decodes it
204 * without guessing. */
205typedef struct xtc_tail_rec {
206 uint64_t ts_ns; /* monotonic timestamp (ns) */
207 uint32_t source; /* which XTC_TAIL_* source produced it */
208 uint32_t kind; /* enum xtc_tail_kind */
209 xtc_pid_t pid; /* the proc the event concerns */
210 uint64_t detail; /* kind-specific (EXIT reason / RUN latency ns) */
212
213/* Visit callback for xtc_tail_read: return 0 to continue, nonzero stops. */
214typedef int (*xtc_tail_fn)(const xtc_tail_rec_t *rec, void *user);
215
216/*
217 * PUBLIC: unsigned xtc_tail_enable __P((unsigned));
218 * PUBLIC: void xtc_tail_disable __P((void));
219 * PUBLIC: int xtc_tail_reset __P((void));
220 * PUBLIC: int xtc_tail_read __P((xtc_tail_fn, void *));
221 * PUBLIC: int xtc_tail_dump __P((int));
222 * PUBLIC: size_t xtc_tail_count __P((void));
223 * PUBLIC: uint64_t xtc_tail_dropped __P((void));
224 */
225
226/* Enable the named sources (a bitwise-OR of XTC_TAIL_*). Returns the
227 * previously enabled mask. Enabling is idempotent; call with the full
228 * mask you want each time (it replaces, not ORs). */
229XTC_API unsigned xtc_tail_enable(unsigned source_mask);
230
231/* Disable all recording (equivalent to xtc_tail_enable(0)). */
232XTC_API void xtc_tail_disable(void);
233
234/* Drop all buffered records. Returns XTC_OK. */
235XTC_API int xtc_tail_reset(void);
236
237/* Visit every buffered record oldest-first (a stable snapshot). */
238XTC_API int xtc_tail_read(xtc_tail_fn cb, void *user);
239
240/* Write the buffered records to `fd` as a versioned binary trace:
241 * a small header (magic, version, record count, record size) followed
242 * by the records verbatim. A separate offline tool renders it. */
243XTC_API int xtc_tail_dump(int fd);
244
245/* Number of records currently buffered. */
246XTC_API size_t xtc_tail_count(void);
247
248/*
249 * How many records have been OVERWRITTEN (evicted) because the ring
250 * wrapped -- total emitted minus what is still buffered.
251 *
252 * Check this before drawing any conclusion from the ABSENCE of an event.
253 * With a non-zero dropped count, "pid X has no events" and "loop L never
254 * polled" are unfalsifiable: the events may simply have been evicted. A
255 * consumer reported two verdicts that were exactly this artifact -- 23 of
256 * 33 loops appeared to have stopped polling when their events had merely
257 * been overwritten -- so this accessor exists to make a real zero
258 * distinguishable from an evicted one.
259 *
260 * Conclusions drawn from events that are PRESENT stay valid regardless.
261 */
262XTC_API uint64_t xtc_tail_dropped(void);
263
264/* The internal hook-point primitives __xtc_tail_emit / __xtc_tail_on
265 * are library-internal (the __ prefix) and live in "tail_int.h", not in
266 * this installed public header. Consumers use the public xtc_tail_*
267 * API (enable + read/dump/count) below. */
268
269/* On-disk binary dump header (also used by the offline reader).
270 *
271 * Format v2 is COMPACT and PORTABLE (dial9-style): all header fields are
272 * written as explicit little-endian bytes (no struct memcpy, so it is
273 * byte-identical across endianness and padding), and each event is
274 * varint/delta encoded rather than a fixed 32-byte record:
275 *
276 * header: magic[4]="XTCL" version(LE u32)=2 flags(LE u32)
277 * count(LE u32) base_ts_ns(LE u64)
278 * flags bit0 = 1 -> little-endian canonical stream (always set today)
279 * per event (oldest first):
280 * kind : 1 byte
281 * source : 1 byte
282 * ts_delta: LEB128 varint, ns since the previous event (base for the
283 * first) -- monotonic timestamps make this 1-2 bytes
284 * loop_id : LEB128 varint (pid.loop_id)
285 * local_id: LEB128 varint (pid.local_id)
286 * gen : LEB128 varint (pid.gen)
287 * detail : LEB128 varint (EXIT reason / RUN latency ns)
288 *
289 * Typical ~6-12 bytes/event vs 32 for the raw struct, and portable.
290 * xtc_tail_read (in-process) still hands back the fixed xtc_tail_rec_t. */
291#define XTC_TAIL_MAGIC 0x5854434Cu /* "XTCL" */
292#define XTC_TAIL_VERSION 2u
293#define XTC_TAIL_FLAG_LE 1u /* little-endian canonical stream */
294typedef struct xtc_tail_hdr {
295 uint32_t magic; /* XTC_TAIL_MAGIC */
296 uint32_t version; /* XTC_TAIL_VERSION */
297 uint32_t flags; /* XTC_TAIL_FLAG_* (endianness marker) */
298 uint32_t count; /* number of events that follow */
299 uint64_t base_ts_ns; /* timestamp of the first event (deltas from here) */
301
302#endif /* XTC_TAIL_H */