blob: 999443694fe789b60e9918cf398b501bea7d9da3 [file]
#!/usr/bin/env python3
#
# SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors
#
# SPDX-License-Identifier: Apache-2.0
"""
Zephyr CTF trace viewer.
Reads a Common Trace Format (CTF) binary stream produced by Zephyr's tracing
subsystem (for example the ``tracing.bin`` dumped by
``samples/subsys/tracing`` when built with the CTF format and the semihosting
backend) and renders it graphically on the console - a lightweight,
terminal-only take on tools such as SEGGER SystemView or Eclipse Trace Compass.
The viewer focuses on visualization. Most of the screen is a Gantt-style time
graph with one lane per thread (plus lanes for the idle thread and for ISRs);
coloured bars show which thread is running over time and the transitions
between them are the context switches. A movable time cursor (the playhead)
selects a point in the trace, with a small info strip beneath the chart that
can be toggled off to give the graph the whole screen. A metrics panel fills
the area below the lanes with a CPU-busy gauge, context-switch / event rates
and per-thread stacked utilization bars computed over the visible window.
The trace can be replayed: ``space`` starts/stops autoplay, which advances the
playhead in real time and scrolls the chart to follow it, and ``[`` / ``]``
adjust the playback speed. Pressing ``v`` switches to a raw CTF log view: a
scrollable table of every decoded event (timestamp, id, name and fields), with
the event nearest the playhead highlighted.
Unlike ``babeltrace2`` this script needs no external dependencies: it decodes
the packed little-endian CTF records itself. The per-event field layout is read
from the TSDL ``metadata`` file that ships next to the tracing subsystem
(``subsys/tracing/ctf/tsdl/metadata``) so the decoder automatically tracks any
events added there; a built-in fallback table covers the core scheduling events
so the time graph still works even when the metadata file cannot be found.
Usage::
# interactive viewer (a terminal/TTY is required)
./trace_viewer.py build/tracing.bin
# follow a trace live while the application is still writing it
./trace_viewer.py -f build/tracing.bin
# non-interactive ASCII timeline, e.g. to pipe or redirect
./trace_viewer.py build/tracing.bin --text
# point at a specific metadata file
./trace_viewer.py build/tracing.bin --metadata path/to/metadata
"""
import argparse
import os
import re
import struct
import sys
def safe_open(path, mode, **kwargs):
"""Open a command-line file path after canonicalising and validating it.
The viewer only ever reads pre-existing files named on the command line.
Resolving symlinks with ``realpath`` and confirming the target is a regular
file keeps a stray CLI argument (a directory, device node, or dangling
symlink) from being opened, and yields a clear error instead of a deep
traceback. Centralising every read here keeps the trusted/untrusted
boundary in one obvious place.
"""
real = os.path.realpath(path)
if not os.path.isfile(real):
raise SystemExit(f"error: {path!r} is not a readable file")
return open(real, mode, **kwargs) # noqa: SIM115
# Record framing, identical for every event:
# uint64_t timestamp (ns) <- present when CONFIG_TRACING_CTF_TIMESTAMP=y
# uint16_t id
# <packed, byte-aligned event specific fields>
HDR = struct.Struct("<QH")
HDR_NO_TS = struct.Struct("<H")
# Map a TSDL integer typedef to a struct format character and byte size.
TYPES = {
"int8_t": ("b", 1),
"uint8_t": ("B", 1),
"uint16_t": ("H", 2),
"uint32_t": ("I", 4),
"int32_t": ("i", 4),
"uint64_t": ("Q", 8),
}
# Events that drive the running-thread timeline / lane layout.
THREAD_SWITCHED_IN = 0x11
THREAD_SWITCHED_OUT = 0x10
ISR_ENTER = 0x1B
ISR_EXIT = 0x1C
ISR_EXIT_TO_SCHEDULER = 0x1D
IDLE = 0x1E
# Events carrying thread identity we want in the thread table.
THREAD_INFO = 0x19
THREAD_CREATE = 0x13
THREAD_NAME_SET = 0x1A
THREAD_PRIO_SET = 0x12 # k_thread_priority_set bracket (carries prio)
THREAD_SCHED_PRIO_SET = 0xE9 # scheduler priority change, incl. inheritance
# Minimal fallback layout for the scheduling events, used only when the TSDL
# metadata file cannot be located. Each entry is (name, [(field, type), ...]).
FALLBACK_EVENTS = {
0x10: ("thread_switched_out", [("thread_id", "uint32_t"), ("name", "str20")]),
0x11: ("thread_switched_in", [("thread_id", "uint32_t"), ("name", "str20")]),
0x12: (
"thread_priority_set",
[("thread_id", "uint32_t"), ("name", "str20"), ("prio", "int8_t")],
),
0x13: ("thread_create", [("thread_id", "uint32_t"), ("name", "str20")]),
0x14: ("thread_abort", [("thread_id", "uint32_t"), ("name", "str20")]),
0x19: (
"thread_info",
[
("thread_id", "uint32_t"),
("name", "str20"),
("stack_base", "uint32_t"),
("stack_size", "uint32_t"),
],
),
0x1A: ("thread_name_set", [("thread_id", "uint32_t"), ("name", "str20")]),
0x1B: ("isr_enter", []),
0x1C: ("isr_exit", []),
0x1D: ("isr_exit_to_scheduler", []),
0x1E: ("idle", []),
0x7F: ("thread_sleep_enter", [("timeout", "uint32_t")]),
0x80: ("thread_sleep_exit", [("timeout", "uint32_t"), ("ret", "int32_t")]),
}
class EventDef:
"""Decoded layout for one CTF event id."""
__slots__ = ("eid", "name", "fields", "size")
def __init__(self, eid, name, fields):
self.eid = eid
self.name = name
# fields: list of (field_name, kind) where kind is a struct char for
# scalars or ("str", n) for a fixed length string. Every field is a
# fixed size, so the whole record body has a known length (self.size),
# which the streaming reader uses to spot incomplete trailing records.
self.fields = []
self.size = 0
for fname, ftype in fields:
if ftype == "str20":
ftype = ("str", 20)
if isinstance(ftype, tuple) and ftype[0] == "str":
self.fields.append((fname, ftype))
self.size += ftype[1]
else:
ch, _ = TYPES[ftype]
self.fields.append((fname, ch))
self.size += struct.calcsize(ch)
def decode(self, buf, off):
"""Decode fields from buf starting at off; return (dict, new_off)."""
out = {}
for fname, kind in self.fields:
if isinstance(kind, tuple) and kind[0] == "str":
n = kind[1]
raw = buf[off : off + n]
off += n
out[fname] = raw.split(b"\x00", 1)[0].decode("ascii", "replace")
else:
sz = struct.calcsize(kind)
(val,) = struct.unpack_from("<" + kind, buf, off)
off += sz
out[fname] = val
return out, off
def parse_metadata(path):
"""Parse the TSDL metadata file into {id: EventDef}."""
with safe_open(path, "r", errors="replace") as f:
text = f.read()
defs = {}
# Match each "event { ... };" block. The body can contain a nested
# "struct { ... }", so balance braces manually rather than with a
# non-greedy regex (which would stop at the inner closing brace).
for kw in re.finditer(r"\bevent\s*\{", text):
start = kw.end()
depth = 1
i = start
while i < len(text) and depth:
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
i += 1
body = text[start : i - 1]
name_m = re.search(r"name\s*=\s*([A-Za-z0-9_]+)\s*;", body)
id_m = re.search(r"id\s*=\s*(0[xX][0-9a-fA-F]+|\d+)\s*;", body)
if not name_m or not id_m:
continue
eid = int(id_m.group(1), 0)
name = name_m.group(1)
fields = []
struct_m = re.search(r"fields\s*:=\s*struct\s*\{(.*?)\}\s*;", body, re.DOTALL)
if struct_m:
for fm in re.finditer(
r"([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[(\d+)\])?\s*;",
struct_m.group(1),
):
ftype, fname, arr = fm.group(1), fm.group(2), fm.group(3)
if ftype == "ctf_bounded_string_t":
fields.append((fname, ("str", int(arr) if arr else 1)))
elif ftype in TYPES:
fields.append((fname, ftype))
# Unknown types are skipped; they would desync decoding, but the
# Zephyr metadata only uses the integer aliases and strings.
defs[eid] = EventDef(eid, name, fields)
return defs
def build_event_defs(metadata_path):
if metadata_path and os.path.exists(metadata_path):
try:
defs = parse_metadata(metadata_path)
if defs:
return defs, metadata_path
except Exception as exc: # pragma: no cover - defensive
sys.stderr.write(f"warning: could not parse {metadata_path}: {exc}\n")
# Fallback: scheduling events only.
defs = {eid: EventDef(eid, name, flds) for eid, (name, flds) in FALLBACK_EVENTS.items()}
return defs, None
def find_metadata(binary_path, override):
if override:
return override
rel = os.path.join("subsys", "tracing", "ctf", "tsdl", "metadata")
candidates = []
# $ZEPHYR_BASE, if set.
zbase = os.environ.get("ZEPHYR_BASE")
if zbase:
candidates.append(os.path.join(zbase, rel))
# Climb parents from this script (scripts/tracing/) looking for the source
# tree metadata, so the viewer works from anywhere in the tree.
d = os.path.dirname(os.path.abspath(__file__))
for _ in range(6):
candidates.append(os.path.join(d, rel))
d = os.path.dirname(d)
# Alongside the trace itself (the layout babeltrace2 expects).
candidates.append(os.path.join(os.path.dirname(os.path.abspath(binary_path)), "metadata"))
candidates.append("metadata")
for c in candidates:
if os.path.exists(c):
return c
return None
class Event:
__slots__ = ("ts", "eid", "name", "fields")
def __init__(self, ts, eid, name, fields):
self.ts = ts
self.eid = eid
self.name = name
self.fields = fields
class Trace:
"""Parsed trace: ordered events plus reconstructed running timeline."""
def __init__(self):
self.events = []
self.threads = {} # tid -> {name, prio, stack_base, stack_size}
self.segments = [] # (start_ts, end_ts, tid) running thread spans
self.isr_spans = [] # (start_ts, end_ts) ISR active spans
self.states = {} # tid -> [(start, end, state, reason), ...]
self.state_starts = {} # tid -> [start, ...] (for bisect)
self.t0 = 0
self.t1 = 0
def thread(self, tid):
return self.threads.setdefault(
tid, {"name": "", "prio": None, "stack_base": None, "stack_size": None}
)
class TraceReader:
"""Incrementally decode a CTF stream into a Trace.
Because every record has a known fixed length, the reader can be fed bytes
in arbitrary chunks: complete records are decoded and any partial trailing
record is kept until the rest arrives. This is what lets the viewer follow
a tracing.bin file that the application is still writing.
All derived structures (running segments, ISR spans and the per-thread
state timeline) are maintained incrementally. The currently-open state of
each thread is published as a provisional segment extending to the latest
timestamp so the live edge renders; it is dropped and re-derived on the
next feed.
"""
def __init__(self, defs, has_ts=True):
self.defs = defs
self.has_ts = has_ts
self.hdr = HDR if has_ts else HDR_NO_TS
self.tr = Trace()
self._buf = b"" # undecoded bytes (possibly a partial record)
self._fake_ts = 0
self._desync = False
# Some platforms back the CTF timestamp with a free-running cycle
# counter that wraps, giving a sawtooth instead of a monotonic clock.
# Unwrap it: every time the raw value jumps backwards, add the previous
# raw value as an offset so the timeline stays monotonic. For a trace
# whose timestamps are already monotonic this is a no-op.
self._prev_raw = None
self._ts_off = 0
# running-thread / ISR incremental state
self._cur_tid = None
self._seg_start = None
self._isr_depth = 0
self._isr_start = None
# per-thread state-machine incremental state
self._st_cur = {} # tid -> [state, since, reason]
self._st_hint = {} # tid -> (state, reason)
self._running = None
self._provisional = [] # tids whose open state is appended for render
# --- incremental state-machine helpers --------------------------------
def _st_close(self, tid, ts):
st = self._st_cur.get(tid)
if st is not None and ts > st[1]:
self.tr.states.setdefault(tid, []).append((st[1], ts, st[0], st[2]))
self.tr.state_starts.setdefault(tid, []).append(st[1])
def _st_set(self, tid, ts, state, reason=""):
self._st_close(tid, ts)
self._st_cur[tid] = [state, ts, reason]
def _drop_provisional(self):
for tid in self._provisional:
segs = self.tr.states.get(tid)
if segs:
segs.pop()
self.tr.state_starts[tid].pop()
self._provisional = []
def _add_provisional(self):
last = self.tr.t1
for tid, st in self._st_cur.items():
state, since, reason = st
if last > since and state != ST_DEAD:
self.tr.states.setdefault(tid, []).append((since, last, state, reason))
self.tr.state_starts.setdefault(tid, []).append(since)
self._provisional.append(tid)
def _state_machine(self, ts, nm, fields, tid):
if nm == "thread_switched_in":
self._running = tid
if tid is not None:
self._st_set(tid, ts, ST_RUN)
self._st_hint.pop(tid, None)
elif nm == "thread_switched_out":
t = tid if tid is not None else self._running
if t is not None and self._st_cur.get(t, [None])[0] == ST_RUN:
h = self._st_hint.pop(t, None)
if h:
self._st_set(t, ts, h[0], h[1])
else:
self._st_set(t, ts, ST_RDY) # preempted, still runnable
self._running = None
elif nm in _SLEEP_ENTERS:
if self._running is not None:
to = fields.get("timeout", fields.get("ms", fields.get("us", "")))
self._st_hint[self._running] = (ST_SLP, f"sleep {to}")
elif nm.endswith("_blocking"):
if self._running is not None:
self._st_hint[self._running] = (ST_BLK, _block_reason_nm(nm, fields))
elif nm in _PEND_EVENTS:
if tid is not None:
h = self._st_hint.get(tid)
self._st_set(tid, ts, ST_BLK, h[1] if h else "blocked")
elif nm in _READY_EVENTS:
if tid is not None:
self._st_set(tid, ts, ST_RDY)
self._st_hint.pop(tid, None)
elif nm in _SUSPEND_EVENTS:
if tid is not None:
self._st_set(tid, ts, ST_SUS)
elif nm in _ABORT_EVENTS:
if tid is not None:
self._st_set(tid, ts, ST_DEAD)
elif nm == "thread_create":
if tid is not None and tid not in self._st_cur:
self._st_set(tid, ts, ST_RDY)
def _consume(self, ts, eid, name, fields):
tr = self.tr
tr.events.append(Event(ts, eid, name, fields))
if len(tr.events) == 1:
tr.t0 = ts
tr.t1 = ts
tid = fields.get("thread_id")
if tid is not None:
t = tr.thread(tid)
nm = fields.get("name")
if nm:
t["name"] = nm
if eid in (THREAD_PRIO_SET, THREAD_SCHED_PRIO_SET) and "prio" in fields:
t["prio"] = fields["prio"]
if eid == THREAD_INFO:
t["stack_base"] = fields.get("stack_base")
t["stack_size"] = fields.get("stack_size")
# Running-thread timeline from context switches.
if eid == THREAD_SWITCHED_IN:
if self._cur_tid is not None and self._seg_start is not None:
tr.segments.append((self._seg_start, ts, self._cur_tid))
self._cur_tid = tid
self._seg_start = ts
elif eid == THREAD_SWITCHED_OUT:
if self._cur_tid is not None and self._seg_start is not None:
tr.segments.append((self._seg_start, ts, self._cur_tid))
self._cur_tid = None
self._seg_start = None
# ISR overlay spans (nested enters collapse to one active span).
if eid == ISR_ENTER:
if self._isr_depth == 0:
self._isr_start = ts
self._isr_depth += 1
elif eid in (ISR_EXIT, ISR_EXIT_TO_SCHEDULER):
if self._isr_depth > 0:
self._isr_depth -= 1
if self._isr_depth == 0 and self._isr_start is not None:
tr.isr_spans.append((self._isr_start, ts))
self._isr_start = None
self._state_machine(ts, name, fields, tid)
def _decode_buf(self):
data = self._buf
off = 0
n = len(data)
hsz = self.hdr.size
new = 0
while off + hsz <= n:
if self.has_ts:
ts, eid = self.hdr.unpack_from(data, off)
else:
(eid,) = self.hdr.unpack_from(data, off)
ts = None
edef = self.defs.get(eid)
if edef is None:
# Unknown id: the record length is unknown, so we cannot safely
# skip it. Stop and keep the bytes; flag the desync for the UI.
self._desync = True
break
rec = hsz + edef.size
if off + rec > n:
break # incomplete trailing record; wait for more
if ts is None:
ts = self._fake_ts
self._fake_ts += 1
else:
if self._prev_raw is not None and ts < self._prev_raw:
self._ts_off += self._prev_raw # counter wrapped
self._prev_raw = ts
ts += self._ts_off
fields, _ = edef.decode(data, off + hsz)
off += rec
self._consume(ts, eid, edef.name, fields)
new += 1
self._buf = data[off:]
return new
def feed(self, data):
"""Decode as many complete records as `data` (appended) allows.
Returns the number of new events decoded.
"""
if not data:
return 0
self._buf += data
self._drop_provisional()
new = self._decode_buf()
self._add_provisional()
return new
def parse_trace(path, defs, has_ts=True):
"""Read a complete trace file in one shot (non-live path)."""
reader = TraceReader(defs, has_ts)
with safe_open(path, "rb") as fh:
reader.feed(fh.read())
return reader.tr
# Thread state codes. The glyph ramp goes from solid (on CPU) to light (idle),
# so darker == closer to running; colour disambiguates further.
ST_RUN = "run" # executing on a CPU
ST_RDY = "rdy" # runnable, waiting for a CPU (preempted / just readied)
ST_BLK = "blk" # pending on a kernel object (see reason)
ST_SLP = "slp" # sleeping on a timeout (k_sleep and friends)
ST_SUS = "sus" # explicitly suspended
ST_DEAD = "dead" # aborted / not yet created
STATE_GLYPH = {ST_RUN: "█", ST_RDY: "▓", ST_BLK: "▒", ST_SLP: "░", ST_SUS: "—", ST_DEAD: " "}
STATE_NAME = {
ST_RUN: "run",
ST_RDY: "ready",
ST_BLK: "blocked",
ST_SLP: "sleep",
ST_SUS: "susp",
ST_DEAD: "dead",
}
# Precedence for choosing one state when a column's time bucket spans several;
# running wins ties so context switches stay visible.
STATE_PREC = {ST_RUN: 5, ST_BLK: 4, ST_RDY: 3, ST_SLP: 2, ST_SUS: 1, ST_DEAD: 0}
# curses colour pair per state (set up in run_curses).
STATE_PAIR = {ST_RUN: 1, ST_RDY: 2, ST_BLK: 3, ST_SLP: 4, ST_SUS: 5}
_SLEEP_ENTERS = {
"k_sleep_enter",
"thread_sleep_enter",
"thread_msleep_enter",
"thread_usleep_enter",
}
_READY_EVENTS = {
"thread_sched_ready",
"thread_ready",
"thread_wakeup",
"thread_sched_wakeup",
"thread_resume",
"thread_sched_resume",
}
_SUSPEND_EVENTS = {"thread_suspend", "thread_sched_suspend"}
_ABORT_EVENTS = {"thread_abort", "thread_sched_abort"}
_PEND_EVENTS = {"thread_sched_pend", "thread_pending"}
def _block_reason_nm(nm, f):
"""Best-effort 'why is this thread blocked' string for a blocking event."""
if nm.startswith("semaphore"):
return f"sem 0x{f.get('id', 0):x}"
if nm.startswith("mutex"):
return f"mutex 0x{f.get('id', 0):x}"
if nm.startswith("msgq"):
return f"msgq 0x{f.get('id', 0):x}"
if nm.startswith("condvar"):
return f"condvar 0x{f.get('id', 0):x}"
if nm.startswith("event_wait"):
return f"event 0x{f.get('event_id', 0):x}"
if nm.startswith("thread_join"):
return f"join 0x{f.get('thread_id', 0):x}"
if nm.startswith("mem_slab"):
return f"memslab 0x{f.get('id', 0):x}"
if nm.startswith("work"):
return "work"
return "blocked"
def state_at(tr, tid, ts):
"""Return (state, reason) of thread tid at time ts, or (None, '')."""
import bisect
starts = tr.state_starts.get(tid)
if not starts:
return None, ""
i = bisect.bisect_right(starts, ts) - 1
if i < 0:
return None, ""
s, e, state, reason = tr.states[tid][i]
if s <= ts < e or (i == len(starts) - 1 and ts >= s):
return state, reason
return None, ""
def thread_running_at(tr, ts):
"""Return the thread id running at time ts (single-CPU), or None."""
for tid in tr.threads:
st, _ = state_at(tr, tid, ts)
if st == ST_RUN:
return tid
return None
# --------------------------------------------------------------------------
# Rendering helpers shared by the text and curses front-ends.
# --------------------------------------------------------------------------
def lane_order(tr):
"""Return thread ids ordered for display (most-active first)."""
busy = {}
for s, e, tid in tr.segments:
busy[tid] = busy.get(tid, 0) + max(0, e - s)
# Threads that ran, busiest first; then any known-but-idle threads.
ran = sorted(busy, key=lambda t: -busy[t])
others = [t for t in tr.threads if t not in busy]
return ran + others
def thread_label(tr, tid):
name = tr.threads.get(tid, {}).get("name") or ""
if not name:
# Idle threads are often unnamed; flag the well-known idle id heuristic
# by name only, otherwise show the handle.
name = "(unnamed)"
return name
def fmt_time(ns):
"""Human friendly timestamp."""
if ns >= 1_000_000:
return f"{ns / 1_000_000:.3f}ms"
if ns >= 1_000:
return f"{ns / 1_000:.3f}us"
return f"{ns}ns"
def render_rows(tr, order, view0, view1, width):
"""Return {tid: list[str-cells]} bars plus an ISR row, for the time window.
Each cell is one of ' ' (idle), '#'/full block (running). We use shading to
indicate partial coverage of a column's time bucket.
"""
span = max(1, view1 - view0)
col_ns = span / width
shades = " ░▒▓█"
rows = {}
for tid in order:
rows[tid] = [0.0] * width
isr_row = [0.0] * width
def paint(acc, s, e):
if e <= view0 or s >= view1:
return
s = max(s, view0)
e = min(e, view1)
c0 = (s - view0) / col_ns
c1 = (e - view0) / col_ns
ic0 = int(c0)
ic1 = min(width - 1, int(c1))
for c in range(ic0, ic1 + 1):
cell0 = c
cell1 = c + 1
cov = min(cell1, c1) - max(cell0, c0)
if cov > 0:
acc[c] = min(1.0, acc[c] + cov)
for s, e, tid in tr.segments:
if tid in rows:
paint(rows[tid], s, e)
for s, e in tr.isr_spans:
paint(isr_row, s, e)
def to_chars(acc):
out = []
for v in acc:
if v <= 0.0:
out.append(" ")
else:
idx = min(4, 1 + int(v * 3.999))
out.append(shades[idx])
return out
char_rows = {tid: to_chars(acc) for tid, acc in rows.items()}
return char_rows, to_chars(isr_row)
def render_state_rows(tr, lanes, view0, view1, width):
"""Return {tid: [state-or-None per column]} for the visible window.
For each column the dominant state (by time covered, ties broken by
STATE_PREC) is chosen, so a lane reads as run/ready/blocked/sleep bars.
"""
import bisect
span = max(1, view1 - view0)
col_ns = span / width
out = {}
for tid in lanes:
cells = [None] * width
segs = tr.states.get(tid)
if segs:
starts = tr.state_starts[tid]
acc = {} # col -> {state: coverage}
i = max(0, bisect.bisect_right(starts, view0) - 1)
n = len(segs)
while i < n:
s, e, state, _reason = segs[i]
i += 1
if s >= view1:
break
if e <= view0 or state == ST_DEAD:
continue
cs = max(s, view0)
ce = min(e, view1)
c0 = (cs - view0) / col_ns
c1 = (ce - view0) / col_ns
for c in range(int(c0), min(width - 1, int(c1)) + 1):
cov = min(c + 1, c1) - max(c, c0)
if cov <= 0:
continue
d = acc.get(c)
if d is None:
d = {}
acc[c] = d
d[state] = d.get(state, 0) + cov
for c, d in acc.items():
best = None
bestv = -1.0
for st, v in d.items():
if v > bestv or (v == bestv and STATE_PREC[st] > STATE_PREC.get(best, -1)):
best, bestv = st, v
cells[c] = best
out[tid] = cells
return out
def window_stats(tr, view0, view1):
"""Per-thread time in each state over [view0, view1].
Returns ({tid: {state: ns}}, span_ns). Threads that exist in the window
have segments covering it, so their per-state times sum to the span.
"""
import bisect
span = max(1, view1 - view0)
per = {}
for tid, segs in tr.states.items():
starts = tr.state_starts[tid]
i = max(0, bisect.bisect_right(starts, view0) - 1)
n = len(segs)
acc = {}
while i < n:
s, e, st, _r = segs[i]
i += 1
if s >= view1:
break
if e <= view0:
continue
d = min(e, view1) - max(s, view0)
if d > 0:
acc[st] = acc.get(st, 0) + d
if acc:
per[tid] = acc
return per, span
# --------------------------------------------------------------------------
# Non-interactive ASCII output.
# --------------------------------------------------------------------------
def run_text(tr, width):
order = lane_order(tr)
label_w = max([len("THREAD")] + [len(thread_label(tr, t)) for t in order]) + 1
label_w = min(label_w, 18)
tl_w = max(20, width - label_w - 12)
print(
f"Zephyr CTF trace ({len(tr.events)} events, "
f"{len(tr.threads)} threads, "
f"{fmt_time(tr.t1 - tr.t0)} span)"
)
print(f"t0 = {fmt_time(tr.t0)} t1 = {fmt_time(tr.t1)}")
print()
state_rows = render_state_rows(tr, order, tr.t0, tr.t1, tl_w)
_, isr_row = render_rows(tr, [], tr.t0, tr.t1, tl_w)
def lbl(s):
s = s[: label_w - 1]
return s.ljust(label_w)
legend = " ".join(
f"{STATE_GLYPH[s]}={STATE_NAME[s]}" for s in (ST_RUN, ST_RDY, ST_BLK, ST_SLP, ST_SUS)
)
print("states: " + legend)
print()
axis = "".join("|" if i % 10 == 0 else "." for i in range(tl_w))
print(
" " * label_w
+ "0"
+ " " * (tl_w - len(fmt_time(tr.t1 - tr.t0)) - 1)
+ fmt_time(tr.t1 - tr.t0)
)
print(lbl("THREAD") + axis)
for tid in order:
cells = state_rows[tid]
if all(c is None for c in cells):
continue
line = "".join(STATE_GLYPH.get(c, " ") if c else " " for c in cells)
print(lbl(thread_label(tr, tid)) + line)
if any(c != " " for c in isr_row):
print(lbl("[ISR]") + "".join(isr_row))
print()
print("Threads")
print(f" {'handle':<12}{'name':<18}{'prio':>5} {'stack_base':<12}{'stack_sz':>9}")
for tid in order:
t = tr.threads[tid]
prio = "" if t["prio"] is None else str(t["prio"])
sb = "" if t["stack_base"] is None else f"0x{t['stack_base']:08x}"
ss = "" if t["stack_size"] is None else str(t["stack_size"])
print(f" 0x{tid:08x} {thread_label(tr, tid):<18}{prio:>5} {sb:<12}{ss:>9}")
# Event histogram by type.
print()
print("Event counts")
counts = {}
for ev in tr.events:
counts[ev.name] = counts.get(ev.name, 0) + 1
for name, c in sorted(counts.items(), key=lambda kv: -kv[1]):
print(f" {c:>7} {name}")
# --------------------------------------------------------------------------
# Interactive curses front-end.
# --------------------------------------------------------------------------
def _fmt_fields(ev):
"""One-line rendering of an event's decoded fields."""
parts = []
for k, v in ev.fields.items():
if isinstance(v, int) and (
k.endswith("id") or k.endswith("base") or k in ("port", "pin", "iface", "pkt")
):
parts.append(f"{k}=0x{v:x}")
else:
parts.append(f"{k}={v}")
return " ".join(parts)
def run_curses(stdscr, reader, fh=None):
import bisect
import curses
import time
tr = reader.tr
live = fh is not None
curses.curs_set(0)
stdscr.keypad(True)
use_color = curses.has_colors()
if use_color:
curses.start_color()
curses.use_default_colors()
# Lanes are coloured by thread *state* (not identity) so run / ready /
# blocked / sleep / suspended read at a glance.
curses.init_pair(1, curses.COLOR_GREEN, -1) # running
curses.init_pair(2, curses.COLOR_YELLOW, -1) # ready
curses.init_pair(3, curses.COLOR_RED, -1) # blocked
curses.init_pair(4, curses.COLOR_CYAN, -1) # sleeping
curses.init_pair(5, curses.COLOR_MAGENTA, -1) # suspended
curses.init_pair(20, curses.COLOR_BLACK, curses.COLOR_RED) # ISR
curses.init_pair(21, curses.COLOR_BLACK, curses.COLOR_CYAN) # header/cursor
curses.init_pair(22, curses.COLOR_WHITE, curses.COLOR_BLUE) # selected
curses.init_pair(23, curses.COLOR_BLACK, curses.COLOR_GREEN) # play
order = lane_order(tr)
ts_list = [ev.ts for ev in tr.events]
full_span = max(1, tr.t1 - tr.t0)
view0 = tr.t0
view1 = tr.t1 if tr.t1 > tr.t0 else tr.t0 + 1
cursor_ns = float(tr.t0)
sel_row = 0 # selected thread lane
show_events = True # event-density row
show_info = True # compact info panel under the chart
show_metrics = True # metrics panel in the gap below the lanes
mode = "gantt" # "gantt" or "log"
log_scroll = 0 # top event index shown in the log view
# Autoplay: cursor advances by `speed` trace-ns per real second. Default to
# replaying the whole trace in ~20 seconds.
playing = False
speed = max(1.0, full_span / 20.0)
last_tick = time.monotonic()
# Live follow: when reading a file that is still being written, keep the
# view pinned to the latest events. Panning/zooming detaches; 'f' re-pins.
live_follow = live
nthreads = len(tr.threads)
def clampview():
nonlocal view0, view1
span = view1 - view0
if span < 100:
span = 100
view1 = view0 + span
if view0 < tr.t0:
view0 = tr.t0
view1 = view0 + span
if view1 > tr.t1:
view1 = tr.t1
view0 = max(tr.t0, view1 - span)
def running_at(ts):
return thread_running_at(tr, ts)
def nearest_idx(ts):
if not ts_list:
return 0
i = bisect.bisect_left(ts_list, ts)
if i <= 0:
return 0
if i >= len(ts_list):
return len(ts_list) - 1
return i if (ts_list[i] - ts) < (ts - ts_list[i - 1]) else i - 1
def fmt_speed(sp):
return f"{fmt_time(int(sp))}/s"
while True:
# --- Poll the file for newly written events -----------------------
if live:
try:
chunk = fh.read()
except OSError:
chunk = b""
if reader.feed(chunk):
ts_list.extend(ev.ts for ev in tr.events[len(ts_list) :])
full_span = max(1, tr.t1 - tr.t0)
if len(tr.threads) != nthreads:
nthreads = len(tr.threads)
order = lane_order(tr)
if live_follow and tr.t1 > tr.t0:
span = max(100, view1 - view0)
view1 = tr.t1
view0 = max(tr.t0, view1 - span)
cursor_ns = float(tr.t1)
log_scroll = len(tr.events) # keep log view at the newest row
# --- Advance autoplay clock ---------------------------------------
now = time.monotonic()
dt = now - last_tick
last_tick = now
if playing:
cursor_ns += speed * dt
if cursor_ns >= tr.t1:
cursor_ns = float(tr.t1)
playing = False
# Scroll the window so the playhead stays visible (~70% across).
span = view1 - view0
if cursor_ns > view1 or cursor_ns < view0:
view0 = cursor_ns - span * 0.7
view1 = view0 + span
clampview()
stdscr.erase()
h, w = stdscr.getmaxyx()
if h < 10 or w < 50:
stdscr.addstr(0, 0, "Terminal too small (need >=50x10).")
stdscr.refresh()
stdscr.timeout(-1)
if stdscr.getch() in (ord("q"), 27):
return
continue
clampview()
span = view1 - view0
cur_i = nearest_idx(int(cursor_ns))
# --- Header (both modes) ------------------------------------------
if live:
play_tag = " LIVE>> " if live_follow else " LIVE "
elif playing:
play_tag = f" PLAY {fmt_speed(speed)} "
else:
play_tag = " PAUSED "
hdr = (
f" CTF {('LOG' if mode == 'log' else 'GANTT')} "
f"ev={len(tr.events)} thr={len(tr.threads)} "
f"t={fmt_time(int(cursor_ns) - tr.t0)}"
)
hattr = curses.color_pair(21) if use_color else curses.A_REVERSE
stdscr.addstr(0, 0, hdr[: w - 1].ljust(w - 1 - len(play_tag)), hattr)
hot = playing or (live and live_follow)
pattr = (
curses.color_pair(23)
if (hot and use_color)
else (curses.A_REVERSE if not use_color else curses.color_pair(22))
)
stdscr.addstr(0, max(0, w - 1 - len(play_tag)), play_tag[: w - 1], pattr)
if mode == "log":
_draw_log(stdscr, tr, h, w, cur_i, log_scroll, use_color, curses)
else:
ev_lo = bisect.bisect_left(ts_list, view0)
ev_hi = bisect.bisect_right(ts_list, view1)
sel_row = _draw_gantt(
stdscr,
tr,
h,
w,
view0,
view1,
span,
cursor_ns,
order,
sel_row,
show_events,
show_info,
show_metrics,
use_color,
curses,
running_at,
ev_lo,
ev_hi,
)
# Footer help.
latest = "[End/f]latest " if live else "[End]latest "
if mode == "log":
help_line = f"{latest}[space]play [up/dn]scroll [PgUp/Dn] [g]antt-view [q]uit"
else:
help_line = (
f"{latest}[space]play [<-/->]cursor [+/-]zoom "
"[up/dn]lane [a]ll [v]log [m]etrics [i]nfo [q]uit"
)
stdscr.addstr(h - 1, 0, help_line[: w - 1], hattr)
stdscr.refresh()
# --- Input --------------------------------------------------------
# Block when idle; poll periodically when live or auto-playing.
stdscr.timeout(33 if playing else (150 if live else -1))
k = stdscr.getch()
if k == -1:
continue
# End / f: jump to the latest state and re-sync with the newest events
# (and resume live-follow). Works in both the Gantt and log views.
if k in (ord("f"), curses.KEY_END):
jump_span = max(100, view1 - view0)
view1 = tr.t1
view0 = max(tr.t0, view1 - jump_span)
cursor_ns = float(tr.t1)
log_scroll = len(tr.events)
if live:
live_follow = True
continue
# Navigating away from the live edge detaches follow.
if live:
nav = (
{
curses.KEY_UP,
curses.KEY_DOWN,
curses.KEY_PPAGE,
curses.KEY_NPAGE,
curses.KEY_HOME,
}
if mode == "log"
else {
curses.KEY_LEFT,
curses.KEY_RIGHT,
ord(","),
ord("."),
ord("+"),
ord("="),
ord("-"),
ord("_"),
ord("a"),
curses.KEY_HOME,
curses.KEY_NPAGE,
curses.KEY_PPAGE,
}
)
if k in nav:
live_follow = False
step = max(1, span // 40)
pan = max(1, span // 4)
# Keys common to both views.
if k in (ord("q"), 27):
return
elif k == ord(" "):
playing = not playing
last_tick = time.monotonic()
elif k in (ord("]"), ord(">")):
speed = min(full_span * 4.0, speed * 1.5)
elif k in (ord("["), ord("<")):
speed = max(1.0, speed / 1.5)
elif k in (ord("v"), ord("g"), ord("\t")):
mode = "log" if mode == "gantt" else "gantt"
log_scroll = max(0, cur_i - (h // 2))
continue
if mode == "log":
page = max(1, h - 4)
if k == curses.KEY_UP:
log_scroll = max(0, log_scroll - 1)
elif k == curses.KEY_DOWN:
log_scroll = min(max(0, len(tr.events) - 1), log_scroll + 1)
elif k == curses.KEY_PPAGE:
log_scroll = max(0, log_scroll - page)
elif k == curses.KEY_NPAGE:
log_scroll = min(max(0, len(tr.events) - 1), log_scroll + page)
elif k == curses.KEY_HOME:
log_scroll = 0
continue
# Gantt-only keys.
if k == curses.KEY_LEFT:
cursor_ns = max(view0, cursor_ns - step)
elif k == curses.KEY_RIGHT:
cursor_ns = min(view1, cursor_ns + step)
elif k == ord(","):
view0 -= pan
view1 -= pan
clampview()
cursor_ns = max(view0, min(view1, cursor_ns))
elif k == ord("."):
view0 += pan
view1 += pan
clampview()
cursor_ns = max(view0, min(view1, cursor_ns))
elif k in (ord("+"), ord("=")):
new_span = max(100, span // 2)
view0 = cursor_ns - new_span // 2
view1 = view0 + new_span
clampview()
elif k in (ord("-"), ord("_")):
new_span = min(full_span, span * 2)
view0 = cursor_ns - new_span // 2
view1 = view0 + new_span
clampview()
elif k == ord("a"):
view0, view1 = tr.t0, tr.t1
elif k == curses.KEY_HOME:
cursor_ns = float(view0)
elif k == curses.KEY_UP:
sel_row = max(0, sel_row - 1)
elif k == curses.KEY_DOWN:
sel_row += 1
elif k == ord("e"):
show_events = not show_events
elif k == ord("i"):
show_info = not show_info
elif k == ord("m"):
show_metrics = not show_metrics
elif k == curses.KEY_NPAGE:
view0 += pan * 2
view1 += pan * 2
clampview()
elif k == curses.KEY_PPAGE:
view0 -= pan * 2
view1 -= pan * 2
clampview()
def _draw_gantt(
stdscr,
tr,
h,
w,
view0,
view1,
span,
cursor_ns,
order,
sel_row,
show_events,
show_info,
show_metrics,
use_color,
curses,
running_at,
ev_lo,
ev_hi,
):
"""Render the Gantt timeline; returns the clamped selected lane index."""
label_w = 14
tl_w = w - label_w - 1
def st_attr(state):
if use_color and state in STATE_PAIR:
a = curses.color_pair(STATE_PAIR[state])
return a | curses.A_BOLD if state == ST_RUN else a
return curses.A_BOLD if state == ST_RUN else curses.A_NORMAL
# Time-axis ruler with both end labels.
stdscr.addstr(1, 0, "t".ljust(label_w))
ruler = "".join("|" if i % 10 == 0 else "-" for i in range(tl_w))
stdscr.addstr(1, label_w, ruler[:tl_w])
left_lbl = fmt_time(view0 - tr.t0)
right_lbl = fmt_time(view1 - tr.t0)
stdscr.addstr(2, label_w, left_lbl[:tl_w])
if len(right_lbl) < tl_w:
stdscr.addstr(2, label_w + tl_w - len(right_lbl), right_lbl)
# The info panel at the bottom is intentionally small so the chart gets
# the bulk of the screen; toggle it off entirely with 'i'.
panel_h = 5 if show_info else 0
lanes_top = 3
overhead = lanes_top + panel_h + 1 # +1 footer
visible = [
t for t in order if any(tt == t and s <= view1 and e >= view0 for s, e, tt in tr.segments)
] or order
max_lanes = max(1, h - overhead - 1) # -1 for the events row
view_lanes = visible[:max_lanes]
sel_row = max(0, min(sel_row, len(view_lanes) - 1)) if view_lanes else 0
state_rows = render_state_rows(tr, view_lanes, view0, view1, tl_w)
_, isr_row = render_rows(tr, [], view0, view1, tl_w)
cursor_col = int((cursor_ns - view0) / max(1, span) * tl_w)
cursor_col = max(0, min(tl_w - 1, cursor_col))
row_y = lanes_top
for idx, tid in enumerate(view_lanes):
sel = idx == sel_row
lbl = thread_label(tr, tid)[: label_w - 1].ljust(label_w)
lblattr = (
curses.color_pair(22)
if (sel and use_color)
else (curses.A_BOLD if sel else curses.A_NORMAL)
)
stdscr.addstr(row_y, 0, lbl, lblattr)
# Draw each lane as runs of same-state glyphs, coloured per state.
cells = state_rows[tid]
c = 0
while c < tl_w:
st = cells[c]
run = c + 1
while run < tl_w and cells[run] == st:
run += 1
glyph = STATE_GLYPH.get(st, " ") if st else " "
if glyph != " ":
stdscr.addstr(row_y, label_w + c, glyph * (run - c), st_attr(st))
c = run
# Playhead marker.
st = cells[cursor_col] if cursor_col < len(cells) else None
mark = STATE_GLYPH.get(st, "|") if st else "|"
stdscr.addstr(
row_y,
label_w + cursor_col,
mark,
curses.color_pair(21) if use_color else curses.A_REVERSE,
)
row_y += 1
if any(c != " " for c in isr_row) and row_y < h - panel_h - 1:
stdscr.addstr(row_y, 0, "[ISR]".ljust(label_w), curses.A_BOLD)
iattr = curses.color_pair(20) if use_color else curses.A_REVERSE
stdscr.addstr(row_y, label_w, "".join(isr_row)[:tl_w], iattr)
row_y += 1
if show_events and row_y < h - panel_h - 1:
marks = [" "] * tl_w
bucket = {}
for ev in tr.events[ev_lo:ev_hi]:
if ev.eid in (
THREAD_SWITCHED_IN,
THREAD_SWITCHED_OUT,
ISR_ENTER,
ISR_EXIT,
ISR_EXIT_TO_SCHEDULER,
IDLE,
):
continue
c = int((ev.ts - view0) / max(1, span) * tl_w)
c = max(0, min(tl_w - 1, c))
bucket[c] = bucket.get(c, 0) + 1
dens = " .:+*#@"
for c, n in bucket.items():
marks[c] = dens[min(len(dens) - 1, n)]
marks[cursor_col] = "|" if marks[cursor_col] == " " else marks[cursor_col]
stdscr.addstr(row_y, 0, "events".ljust(label_w), curses.A_DIM)
stdscr.addstr(row_y, label_w, "".join(marks)[:tl_w])
row_y += 1
# --- Metrics panel (fills the gap above the info strip) ---------------
if show_metrics:
gap_bot = (h - panel_h) if show_info else (h - 1)
if gap_bot - row_y >= 3:
_draw_metrics(
stdscr,
tr,
row_y,
gap_bot,
w,
view0,
view1,
view_lanes,
ev_lo,
ev_hi,
curses,
st_attr,
)
# --- Compact info panel -----------------------------------------------
if show_info:
py = h - panel_h
stdscr.hline(py, 0, curses.ACS_HLINE, w)
py += 1
# Colour-coded state legend.
stdscr.addstr(py, 0, " states: ")
x = 9
for st in (ST_RUN, ST_RDY, ST_BLK, ST_SLP, ST_SUS):
seg = f"{STATE_GLYPH[st]} {STATE_NAME[st]} "
stdscr.addstr(py, x, seg, st_attr(st))
x += len(seg)
py += 1
# Running thread at the playhead.
cur_tid = running_at(int(cursor_ns))
runlbl = thread_label(tr, cur_tid) if cur_tid is not None else "(none)"
runhandle = f"0x{cur_tid:08x}" if cur_tid is not None else ""
stdscr.addstr(
py,
0,
(f" running: {runlbl} {runhandle}".ljust(w - 1))[: w - 1],
curses.color_pair(21) if use_color else curses.A_REVERSE,
)
py += 1
# Selected lane: its state and, when blocked, why.
sel_tid = view_lanes[sel_row] if view_lanes else None
if sel_tid is not None:
t = tr.threads[sel_tid]
prio = "-" if t["prio"] is None else str(t["prio"])
ss = "-" if t["stack_size"] is None else f"{t['stack_size']}B"
st, reason = state_at(tr, sel_tid, int(cursor_ns))
stext = STATE_NAME.get(st, "-")
if st == ST_BLK and reason:
stext = f"blocked on {reason}"
elif st == ST_SLP and reason:
stext = reason
det = (
f" lane: {thread_label(tr, sel_tid)} 0x{sel_tid:08x} "
f"prio={prio} stack={ss} -> {stext}"
)
stdscr.addstr(py, 0, det[: w - 1].ljust(w - 1), st_attr(st) if st else curses.A_BOLD)
return sel_row
def _draw_metrics(stdscr, tr, y0, y1, w, view0, view1, lanes, ev_lo, ev_hi, curses, st_attr):
"""Draw key metrics over the visible window in the empty area below the
lanes: a CPU-busy gauge, context-switch / event rates, and per-thread
stacked utilization bars (run/ready/blocked/sleep)."""
per, span = window_stats(tr, view0, view1)
idle_ids = {t for t in tr.threads if tr.threads[t].get("name") == "idle"}
run_total = sum(a.get(ST_RUN, 0) for a in per.values())
idle_run = sum(per.get(t, {}).get(ST_RUN, 0) for t in idle_ids)
busy = max(0.0, min(1.0, (run_total - idle_run) / span))
switches = sum(1 for ev in tr.events[ev_lo:ev_hi] if ev.eid == THREAD_SWITCHED_IN)
nev = ev_hi - ev_lo
secs = span / 1e9
def bar(y, x, width, parts, empty="░"):
cx, used = x, 0
for st, fr in parts:
cnt = min(int(round(fr * width)), width - used)
if cnt <= 0:
continue
stdscr.addstr(y, cx, STATE_GLYPH.get(st, "█") * cnt, st_attr(st))
cx += cnt
used += cnt
if used < width:
stdscr.addstr(y, cx, empty * (width - used), curses.A_DIM)
y = y0
stdscr.hline(y, 0, curses.ACS_HLINE, w)
y += 1
if y >= y1:
return
gw = 14
head = f" CPU {busy * 100:3.0f}% "
stdscr.addstr(y, 0, head, curses.A_BOLD)
bar(y, len(head), gw, [(ST_RUN, busy)])
sw_rate = f"{switches / secs:.0f}/s" if secs > 0 else "-"
ev_rate = f"{nev / secs:.0f}/s" if secs > 0 else "-"
tail = f" ctxsw {switches} ({sw_rate}) events {nev} ({ev_rate}) window {fmt_time(span)}"
stdscr.addstr(y, len(head) + gw, tail[: max(0, w - len(head) - gw - 1)])
y += 1
# Per-thread utilization: stacked state composition over the window.
bw = max(8, w - 26)
for tid in lanes:
if y >= y1:
break
acc = per.get(tid)
if not acc:
continue
util = acc.get(ST_RUN, 0) / span
name = thread_label(tr, tid)[:11]
stdscr.addstr(y, 0, f" {name:<11}{util * 100:3.0f}% ", curses.A_NORMAL)
parts = [(st, acc.get(st, 0) / span) for st in (ST_RUN, ST_RDY, ST_BLK, ST_SLP, ST_SUS)]
bar(y, 17, bw, parts, empty=" ")
y += 1
def _draw_log(stdscr, tr, h, w, cur_i, log_scroll, use_color, curses):
"""Render the raw CTF event log as a scrollable table."""
stdscr.addstr(
1,
0,
(f"{'#':>7} {'time':>12} {'id':>5} {'event':<28} fields")[: w - 1],
curses.A_UNDERLINE,
)
rows = h - 3 # header(0), column titles(1), footer(h-1)
# Clamp so a large scroll (e.g. "go to latest") lands on the last full page.
top = max(0, min(log_scroll, max(0, len(tr.events) - rows)))
for r in range(rows):
i = top + r
if i >= len(tr.events):
break
ev = tr.events[i]
line = (
f"{i:>7} {fmt_time(ev.ts - tr.t0):>12} "
f"0x{ev.eid:03x} {ev.name:<28} {_fmt_fields(ev)}"
)
attr = curses.A_NORMAL
if i == cur_i:
attr = curses.color_pair(21) if use_color else curses.A_REVERSE
stdscr.addstr(2 + r, 0, line[: w - 1], attr)
def main():
ap = argparse.ArgumentParser(description="Zephyr CTF trace viewer", allow_abbrev=False)
ap.add_argument("binary", help="CTF trace binary (e.g. build/tracing.bin)")
ap.add_argument("--metadata", help="path to TSDL metadata file")
ap.add_argument(
"--text", action="store_true", help="print a static ASCII timeline instead of the TUI"
)
ap.add_argument(
"--width", type=int, default=0, help="output width for --text (default: terminal width)"
)
ap.add_argument(
"--no-timestamp",
action="store_true",
help="trace was built without CONFIG_TRACING_CTF_TIMESTAMP",
)
ap.add_argument(
"-f",
"--follow",
action="store_true",
help="follow the file live as the application writes it "
"(waits for the file/first events to appear)",
)
args = ap.parse_args()
meta_path = find_metadata(args.binary, args.metadata)
defs, used = build_event_defs(meta_path)
if used is None:
sys.stderr.write(
"warning: TSDL metadata not found; using built-in scheduling-event "
"table only (pass --metadata to decode all events)\n"
)
has_ts = not args.no_timestamp
if args.follow:
if args.text or not sys.stdout.isatty():
sys.stderr.write("--follow requires an interactive terminal\n")
return 1
return _run_follow(args.binary, defs, has_ts)
tr = parse_trace(args.binary, defs, has_ts=has_ts)
if not tr.events:
sys.stderr.write("no events decoded; is this a CTF trace? try --no-timestamp\n")
return 1
if args.text or not sys.stdout.isatty():
width = args.width or (os.get_terminal_size().columns if sys.stdout.isatty() else 100)
run_text(tr, width)
return 0
reader = TraceReader(defs, has_ts)
with safe_open(args.binary, "rb") as fh:
reader.feed(fh.read())
import curses
curses.wrapper(run_curses, reader)
return 0
def _run_follow(path, defs, has_ts):
"""Open the trace, catch up on existing bytes, then follow it live."""
import curses
import time
deadline = None
while not os.path.exists(path):
if deadline is None:
sys.stderr.write(f"waiting for {path} to appear (start the application)...\n")
deadline = True
time.sleep(0.3)
reader = TraceReader(defs, has_ts)
fh = safe_open(path, "rb")
try:
reader.feed(fh.read()) # catch up on whatever already exists
curses.wrapper(run_curses, reader, fh)
finally:
fh.close()
return 0
if __name__ == "__main__":
sys.exit(main())