Repository navigation
Logging
Start with the newest log below. Use debug for frame timing, info for memory
snapshots, and the bug-report checklist at the end. Crash and hang evidence has
its own section; a missing crash dump does not mean a clean exit.
- Log files:
~/.sonicterm/logs/sonicterm.log.* - Fatal-signal fallback path:
~/.sonicterm/logs/sonicterm.log - Panic artifacts:
~/.sonicterm/logs/crashes/ - Session markers:
~/.sonicterm/logs/sessions/ - Bounded breadcrumbs:
~/.sonicterm/logs/breadcrumbs/
tracing-appender uses daily names such as sonicterm.log.YYYY-MM-DD; the file
with the newest modification time is active. Size rotation may add a Unix-time
suffix. On Windows, ~ means the current user's profile directory. Native
runtime smokes on macOS, Windows, and Linux use the explicit logs/ child of
SONICTERM_RUNTIME_SMOKE_DIR instead of the user log tree; their separate
config/ child is used for config/reload state, and HOME is preserved. The
outer runner removes inherited NO_COLOR and retains failed stdout/stderr plus
SonicTerm logs for CI artifacts.
Performance scenario runs, the perf_scenarios example that
scripts/perf-compare.py drives, also stay out of the user log tree: each run
logs into a new scratch directory under the OS temporary directory, and HOME
is unchanged. The harness refuses an inherited RUST_LOG, which would replace
the configured level, so both sides of a comparison log through the same filter,
and it logs its scratch path at startup. The comparison parses the
memory snapshot line (Aggregate snapshot at info)
and, in --laps runs, the [render_timing] line
(Render and performance diagnostics).
Development and Release describes how
each run shows that ~/.sonicterm did not change.
[logging]
level = "warn" # error | warn | info | debug
max_file_size_mb = 10
max_rotated_files = 3
max_age_days = 2
max_crash_dumps = 10
max_crash_age_days = 2
max_crash_bytes = 10485760 # 10 MiB
max_breadcrumb_files = 10
max_breadcrumb_age_days = 2
max_breadcrumb_bytes = 1048576 # 1 MiBwarn is the default. SonicTerm reads [logging] before installing the tracing
subscriber, so the configured level applies to normal startup. RUST_LOG
overrides the configured filter for one run. Stderr follows that same filter
with an added global warn fallback; more specific target directives can still
emit debug lines there, so warn is not a hard stderr ceiling.
Before the appender opens, SonicTerm rotates an active log over
max_file_size_mb unless the value is 0, then applies age and count limits to
older log files. The active file is never deleted. Crash and breadcrumb
artifacts are each bounded independently by count, age, and aggregate bytes;
oldest files are removed until all enabled limits are satisfied. Setting an age
or aggregate-byte limit to 0 disables that axis. Cleanup is fail-soft and
artifact cleanup runs on a background thread.
| Level | What it admits |
|---|---|
error |
errors only |
warn |
warnings, errors, sonic_exit, sonic::gpu device records, and user-visible reclamation/exhaustion warnings |
info |
normal SonicTerm information plus the aggregate memory snapshot
|
debug |
detailed SonicTerm diagnostics, pane/renderer memory lines, state-machine events, render_timing, tear_out_timing, and frame_counters
|
wgpu, naga, sonicterm-vt, and sonicterm-grid remain warning-oriented in
the configured filters. Very hot font-shaper dumps are trace; no configured
level admits them. Use a targeted RUST_LOG directive only when investigating
that path.
At debug, the sonicterm_app::sync_output target records each synchronized update
(DEC 2026) a pane's worker released at the 150 ms bound, with its pane_id and
epoch: synchronized output held past 150 ms; released.
A missing configured font emits an error on the config target, naming its
family, weight, stretch and style. SonicTerm uses fallback fonts, so a running
window does not prove that the requested face loaded. Check [font].family in
sonicterm.toml and whether that font is available to SonicTerm; the diagnostic
links to the English Configuration page, which has a language
switch. Synthesized bold/italic requests and fallback-only entries do not add
missing-font errors.
With the configured warn, info or debug filters, config errors reach
stderr but not the log file or crash history. RUST_LOG replaces the configured
filter rather than extending it. This capture recipe retains the default warning
filters and adds font-configuration errors to all three outputs:
RUST_LOG=config=error,sonic_exit=warn,sonic=warn,sonicterm=warn,sonicterm_vt=warn,sonicterm_grid=warn,memory::reclaimed=warn,wgpu=warn,naga=warn
To preserve an existing custom filter, append config=error to its complete
value instead. The configured error filter also admits these errors. Repeated
errors can describe separate font resolutions; these errors have no deduplication
policy.
A missing-glyph warning instead reports the number of unresolved codepoints and
placeholder rendering without including the requested text. It recommends
installing a covering font or changing [font].family, using the same SonicTerm
configuration page. Its existing per-generation/hour warning throttle is
independent of missing-font errors. Neither message establishes the cause of GPU
software fallback; adapter diagnostics are described below.
Enable [logging] level = "debug" before reproducing an explicit local-path
click failure, or use RUST_LOG=sonicterm_app::app::path_target=debug for one run.
The local path activation unverified event records a click whose detected
explicit path has no current authorized filesystem selection. It uses the
immutable click snapshot, without re-reading the filesystem or the parser.
A confirmed-missing auto-detected path also logs this event; the click then
stays an ordinary terminal click.
| Field | Meaning |
|---|---|
window_id, pane_id, pointed, view_top
|
Clicked window/pane, absolute cell, and viewport origin |
screen_epoch, scrollback_evicted
|
Screen and retained-history identity |
cwd, cwd_revision
|
That pane's OSC 7 authority/path and revision, not the process CWD |
clicked_path |
Explicit path text associated with the click |
candidates |
Bounded candidate set with typed provenance, resolved paths, and cell spans, in probe order |
reason |
Current probe failure key, or path-error-pending when no matching failure is available |
A pending result is not evidence that the file is missing. The event contains
paths, which can be sensitive, but no whole terminal rows or environment dump;
review it before sharing. Paths use escaped debug formatting, and one event may
be large even though candidate enumeration is bounded. Default warn logging
does not emit it. Hover and unverified bare-name clicks do not emit it either.
An absent event proves nothing about success or failure: logging may be disabled,
no target may have been detected, or a rejected target/native-open failure may
have followed another branch. Native-open failures retain their separate
path open failed warning. To investigate a relative-path failure, compare the
same file's relative path, absolute path, and local file URI in the same pane;
retain the matching click identity and do not infer its CWD from another shell.
The default warn level reports input that was refused, including terminal
parser replies. The producer assigns source: Keyboard, Paste, FileDrop,
Ime, PointerButton, PointerMotion, Wheel, FocusReport, TerminalReply,
ScriptDraft, or StateMachine. Sources are never guessed from payload bytes.
| Field | Meaning |
|---|---|
pane_id |
Stable pane identity supplied at the producer |
window_id |
The pane's current window when the event loop handles the rejection; absent after pane closure or when the event loop is unavailable |
source, rejected_bytes, reason
|
Input category, refused byte length, and payload-free reason |
observation="concurrent" |
Queue and writer fields are independent observations, not one rejection-time transaction |
queued_messages, queued_bytes, queue_capacity
|
Waiting message count, payload byte count, and four-slot limit; excludes the active native write |
writer_phase |
Idle, Writing, Flushing, or Stopped; a boundary observation, not a child-health verdict |
in_flight_bytes, in_flight_millis
|
Active message size and time spent in the observed write or flush; time is absent when idle/stopped |
completed_messages |
Native writes whose write_all and flush both succeeded |
The event carries no rejected payload. Its debug representation, warnings, and notification never include typed text, commands, paths, or clipboard content. Notification follows the pane's current window after tab transfers; a closed pane produces a warning but no notification on an unrelated window. When the proxy is absent or event delivery fails, the producer logs the same metadata without a current-window identity.
Production terminal replies enter a separate FIFO with 64 KiB RAM and private
temporary-file spill, outside parser and side-effect locks. UI queue saturation
does not discard replies, trigger rejection warnings, or stop output processing.
Storage/native failure is latched and reported once as terminal reply delivery failed with pane identity and byte count; output, redraw, and exit observation
continue. Native writer/spool read errors are independently logged. Intentional
teardown releases spill storage without a rejection notice. See
Terminal IO and VT for storage and delivery limits.
Four small messages can fill the channel before a healthy writer is scheduled. Controlled tests use the production admission and writer loop to demonstrate that burst draining preserves order. Separate blocked-write and blocked-flush fixtures demonstrate zero queued bytes with one in-flight message, followed by four additional queued messages and explicit refusal of the next message. These fixtures distinguish mechanisms; they do not retrospectively identify which producer or native condition caused an older un-attributed warning.
Queue capacity and per-message limits are unchanged. Each pane coalesces native
pointer motion into one fixed 64-byte slot before queue admission. A full queue
keeps the latest position pending and retries after 10 ms without requesting a
frame; a later position replaces it. Discrete input bundles preceding pending
motion into the same queue message when it fits, preserving byte order without
using an extra slot. A cap-sized or refused discrete message supersedes pending
motion rather than replaying it afterward. Disconnected motion is reported once
and cleared. A changed mouse-tracking/encoding/screen profile invalidates deferred
motion. A busy parser defers motion-only retries until its profile can be checked;
a following discrete input supersedes unvalidated motion rather than delaying the
key or replaying stale bytes. Pane teardown releases its slot. Other UI input still refuses
rather than blocking; worker-owned replies use the spill FIFO instead.
Interpret repeated observations of the same pane and progress counter rather than
a single QueueFull warning.
The default warn level reports a PTY resize the native layer refused, as one
pty resize failed event.
| Field | Meaning |
|---|---|
pane_id |
Stable pane identity of the pane whose PTY refused the resize |
cols, rows
|
Requested geometry, not the geometry the pty currently holds |
error |
The failure rendered by Display. A native refusal shows the platform text; a zero axis shows refusing pty resize to <cols>x<rows>
|
The event carries no terminal content: only the pane id, the requested columns and rows, and the error.
Each pane logs only the first resize failure until a success resets its warning latch; this avoids input-rate logs during tab activation or window drags. Attempts still run: only invalid sizes and successful duplicates are skipped by the IO boundary. The grid keeps the requested geometry. There is no rollback, retry timer, or failure heartbeat.
frame_collection warns once per invalid-topology episode with id (window)
and reason when duplicate/missing leaves or active/zoom disagreement prevent a
complete frame. The latch resets only after a complete held frame passes viewport
reconciliation, not after valid source capture alone. Repeated post-lock validation
failures therefore stay in the same warning episode. Closing-tab NoLayout is
silent; ordinary lock contention does not emit this structural warning.
Set level = "debug", restart, and reproduce the problem. The
render_timing target records frame phases including grid walking, overlay
assembly, glyph upload, surface acquisition, submission, and presentation. It
identifies main or child renderers, mode=full, and damaged_rows. No-op frames
return before completed-frame timing is emitted. Partial GPU damage limits the
draw scissor, not frame assembly. There is no separate render-timing option.
Each redraw that runs to completion writes one line for its window:
[render_timing] window=<label> total=<ms>ms <lap>=<ms>ms ... tail=<ms>ms.
<label> is main or child, each <lap> names a frame phase and the last is
tail, and every value is milliseconds with two decimals. In the log file the
line is the value of the event's line field, after line=.
scripts/perf-compare.py parses it only in --laps runs, which log at debug
and so write it: formatting the line costs time on every frame, so laps runs
form their own set and are never pooled with timed runs, which log at info
and write no render_timing line.
The same DEBUG target records renderer initialization operation boundaries.
Synchronous renderer_init spans carry window_id, role, and shared;
finishing a prepared startup carries window_id, role, and prepared=true
instead. startup_prepare spans identify owner-thread instance and surface
creation by window_id; recovery_init spans identify the requesting
window_id during ContextRequest::run, including startup requests. The
renderer_finish operation covers owner-thread assembly after negotiation.
Operation records retain their entry span as parent. phase="enter" precedes the call; phase="return" records
elapsed_ms and outcome. ok and error describe a returned Result;
returned means only that the call returned, not that initialization or
presentation succeeded. Surface configuration has a separate phase="gate"
record with the existing device gate's accepted reading.
Operations cover instance creation/reuse, surface creation and capabilities, adapter/device requests, surface configuration, pipelines, frame storage, atlas storage/uploads, font stacks, and cell metrics. An unmatched entry can mean an unfinished call, unwind, or lost log output; it is not a diagnosis by itself. Elapsed time includes scheduling and diagnostic overhead, not just native execution. With DEBUG disabled, the timing helper reads no clock and retains no span. These records add no terminal payload, font names, paths, or environment values.
Font operations also use render_timing at DEBUG. Entry and explicit return
records separate shape_impl from fallback_receive, rasterizer_new from
rasterize_glyph, and uncached font resolution from metrics. font_shape
spans carry loaded_font_id and retry iteration; font_raster spans carry
only loaded_font_id and fallback_idx. Neither includes a glyph index or
character. Renderer font_style spans identify bold, italic, and row.
The Windows native font test adds a font_phase parent with window_id,
scale, and the test phase; this parent is specific to that fixture.
Each queued fallback request captures its own dispatcher and parent, rather
than inheriting the first request's context on the reused worker. Its
font_request span carries request_id. The return-only queue_wait record
measures from request-context capture to worker entry, including request
preparation and worker startup when applicable. Lookup records distinguish
fallback_locator, fallback_font_dirs, fallback_built_in, and
fallback_selection. completion_called=true marks the point immediately
before invoking the existing completion callback; false means no handles
were selected and the callback is not invoked. An error outcome from
fallback_receive can mean the sender disconnected because no fallback was
found; it is not by itself a rendering failure.
Disabled font timing reads no diagnostic clock, allocates no request ID, and retains no span or dispatcher. A disabled request suppresses only these timing records, not ordinary worker logs, and restores the previous timing state on return or unwind. Enabled timing adds clocks and output, including a record under the existing pending-fallback lock before the callback, so it can change scheduling. These durations do not distinguish active native work from waiting or scheduling delay, and a run without a stall does not explain a previous one.
Startup logs the selected wgpu adapter, device type, and software-adapter
classification. On RDP, VM, or VDI hosts, look for software-render degrade engaged and compare it with [appearance].software_render_mode on
Configuration. scripts/perf-compare.py reads each scenario run's first wgpu adapter selected line, or else its first wgpu adapter reused line, for backend,
name, driver, device_type, and software_rendering; a comparison names
that adapter in its presenter row, and a run on another adapter than its set's
first valid run makes the pair invalid. At level = "debug", each renderer also writes
renderer LCD subpixel policy at startup and whenever mode, opacity, theme, or
presenter state changes. Its requested, effective, windows_host,
opaque_target, software_presenter, and dual_source_supported fields explain
every LCD-to-grayscale fallback without relying on a screenshot.
At info, DPI transition synchronized records window_id, old_scale,
new_scale, native_scale, and size_scale alongside old_inner, suggested,
minimum, available, and target. renderer_before/renderer_after are
physical surface pixels; cell_before/cell_after are raster-pixel cell extents.
On macOS, size_scale follows the native backing scale because old_inner is
already reported in that domain. The other platforms use the stored old scale.
These paired inputs/outputs distinguish double scaling from a surface or cell
mismatch without recording terminal content.
When the foreground-probe worker cannot start or stops, the app logs one warn on
sonicterm_app::app, "foreground-process probes unavailable", with a reason
field. From then on tab process names and per-tab privilege warnings resolve to no
process instead of sampling; there is no retry.
The frame_counters target holds debug-only counters for what render_timing
cannot see: redraws that were deferred or found a lock busy, frame outcomes
other than a presented frame, present intervals, parser lock waits and holds,
flush-to-redraw delay, dispatch stalls, wake causes, foreground-worker probes,
buffer uploads, row-cache hits, and shaping requests. They change no behavior.
Set [logging].level = "debug"; the Debug filter admits frame_counters. A
RUST_LOG that admits frame_counters=debug works too. Each App reads the
filter once, when it starts, and keeps that decision for its lifetime, so a level
change takes effect only after a restart. A process that embeds the App, such as
a test harness, can force an App's counters on whatever the filter, but only
before that App creates its first window or pane; the App refuses afterwards.
Forced counters count, and their lines appear only where the filter admits
frame_counters.
With the counters off, each instrumented path makes one check and stops there: no clock read, no atomic or thread-local write, and no allocation. With them on, the App allocates once its VT statistics, its dispatch totals, one line state per window and for the app line, and one closed-windows record; each window allocates its counters and each pane one 8-byte pending-flush slot. Nothing is allocated per frame or per batch, apart from building a line at most once a second.
[frame_counters] window=<main|child-N|app> [final=1] span_ms=<ms> <field>=<value> ...
Each window writes window=main or window=child-N, where N is the window's
registration order in the App, and the App writes one window=app line. Each
source writes at most one line a second. Values are deltas since that source's
previous line, span_ms is the time since that line, and zero counts and empty
histograms are left out.
A window line follows a frame attempt, a flush the window took, or any other
window event for it; a RedrawRequested that neither attempted a frame nor took
a flush prints no window line. The app line follows any window or user event.
Maintenance wakes (new_events, about_to_wait, a resume-time wake, and the
30-second retention wake) are counted but never print a line, and no timer is
armed for one, so an App that only wakes for maintenance logs nothing.
At window close and at exit, a source with pending counts writes one last line
marked final=1, and nothing from that source follows it. A closed window's
totals, including its renderer's counts and the handler time of the event that
closed it, move into an App-wide closed-windows aggregate, so the App's totals
never drop. Every count and sum is cumulative and never reset; a reader keeps its
previous snapshot and takes deltas.
| Field | Unit | Meaning |
|---|---|---|
attempts |
count | redraws that passed the deferral rules and went on to collect a frame |
presented |
count | attempts that presented a frame |
cached |
count | attempts that re-presented the cached frame |
settled |
count | attempts that settled without presenting |
retry |
count | attempts the renderer asked to retry |
surface_retry |
count | attempts that hit a surface retry |
stopped |
count | attempts that found the GPU device stopped |
failed |
count | attempts that failed |
contention_parser |
count | frame collections that found a visible pane's parser lock busy |
contention_images |
count | frame collections that found a visible image store busy |
defer_timeout |
count | redraws deferred because a surface timeout is pending within the frame period |
defer_contention |
count | redraws deferred by the lock-contention retry floor |
defer_sync |
count | redraws held because a visible pane's synchronized update (DEC 2026) was open, at admission or by the recheck under the parser guards |
defer_streaming |
count | redraws deferred by streaming-output pacing |
stream_clock_exempt |
count | hardware attempts for new input that settled without presenting and kept the streaming clock, so the echo they waited for is not paced from them |
display_link_ticks |
count | display-link ticks the window accepted: the tick's generation was the running link's and a Link admission was pending; stale ticks are not counted |
display_link_admissions |
count | display-link-paced streaming frames admitted by a tick |
display_link_fallbacks |
count | display-link-paced streaming frames admitted by the two-period fallback ceiling because no tick came |
contention_retry_armed |
count | lock-contention retries armed |
dirt_ack_dropped |
count | presented-frame receipts dropped at the next collection because the pane was not held, its parser changed, or its grid was resized or switched screens after assembly; output written after assembly does not drop a receipt, it only keeps the rows it dirtied. Each drop costs a later re-assembly, never pixels |
native_request_redraw |
count | native redraw requests for the window, on every request path; a dispatch's requests reach the totals when it ends, so a window line shows them one dispatch late (final=1 lines are complete) |
user_request_redraw |
count | output events serviced for the window: PaneOutput from a VT worker's flush (at most one outstanding per pane) and RequestRedraw from a harness or test, counted before the visible-output filter |
redraw_requested |
count |
RedrawRequested events for the window |
present_interval |
ms histogram | time between consecutive presented frames |
handler |
ms histogram | each window_event dispatch for the window |
flush_to_redraw |
ms histogram | oldest pending flush to the first redraw of a window that shows the pane |
The four defer_* counts record the rule that won. The rules are checked in
that order, and a later one is never evaluated once an earlier one holds, so each
deferred redraw counts once (Rendering Modes
describes the retry floor).
The three display_link_* counts are always present and read 0 where no link
runs (Windows, Linux, macOS before 14, the software path). Each display-link-paced
streaming admission is counted once, as a tick admission or a fallback, so
attempts is at least their sum. A phase is unexercised when both are 0,
link-paced when only admissions are non-zero, fallback-only when only
fallbacks are (display-link pacing was unavailable in that run), and mixed
when both are. display_link_ticks minus display_link_admissions counts
unused ticks: ticks refused by a timeout or contention rule, replaced before
use, or spent by an admission that was not streaming. The counts show which
path authorized each frame; they do not prove where frames land relative to
vsync (Rendering Modes).
At each RedrawRequested, flush_to_redraw takes the pending flush of every
pane the window shows: the active tab's panes, or the zoomed pane. A hidden
pane's flush waits until its tab is shown. One observation covers a group of
coalesced flushes.
| Field | Unit | Meaning |
|---|---|---|
wake_init, wake_poll, wake_wait_cancelled, wake_resume_time
|
count |
new_events wakes by cause: Init, Poll, WaitCancelled, ResumeTimeReached
|
wake_user |
count |
user_event dispatches |
native_request_redraw_unregistered |
count | native redraw requests for a window id with no registered counters, such as a window already closed; one app-wide total |
about_to_wait |
ms histogram | each about_to_wait dispatch |
user_event |
ms histogram | each user_event dispatch |
new_events |
ms histogram | each new_events dispatch |
ui_parser_locks |
count | event-loop-thread locks of a pane's parser |
ui_guard_custody_ns |
count (ns) | summed time a frame held any parser guard, from the first guard acquired to the last released, over every collection that took a guard, early failures included |
ui_guard_custodies |
count | collections that took at least one parser guard |
frame_dispatch_ns |
count (ns) | summed time from a frame's first parser guard to the return of the renderer call, over frames that reached the renderer |
frame_dispatches |
count | frames that reached the renderer |
frame_dispatch_failed_ns |
count (ns) | summed time from the first parser guard to the release, over collections that took a guard but never reached the renderer (contention, a synchronized-output hold, a reconcile failure, no renderer); their own population |
frame_dispatches_failed |
count | those failed collections |
ui_parser_wait |
µs histogram | the wait for each of those locks |
fg_probe_calls, fg_probe_panes
|
count | retired event-loop probes; always 0, kept so a comparison against an older base shows that work falling to 0 |
fg_probe |
µs histogram | retired event-loop probe durations; always empty, kept for the same comparison |
fg_worker_probes |
count | foreground-probe worker batches |
fg_worker_panes |
count | panes those batches covered |
fg_results_stale |
count | worker results the event loop dropped: the pane closed, its process identity changed, or its child exited |
fg_worker_probe |
µs histogram | each batch's duration |
These come from the sonicterm-fg-probe worker thread; the event-loop thread never
probes. On macOS a batch looks up each pane's process, re-reading its start token
before and after; on Windows it takes one process-table snapshot. With the gate off
the worker reads the clock once per probed pane and records nothing. Linux and other
platforms capture no process identity, start no worker, and report zeros.
The VT fields print on the window=app line. They are one App-wide aggregate that
every pane's VT worker records into, with no per-pane split. A pane that closed,
or whose worker finishes after it, still adds to it.
| Field | Unit | Meaning |
|---|---|---|
parser_lock_wait |
µs histogram | the VT worker's wait for a pane's parser lock |
parser_lock_wait_ns |
count (ns) | the same waits summed in nanoseconds, over every section |
parser_sections |
count | parser sections recorded, the count parser_lock_wait_ns sums over |
parser_lock_hold |
µs histogram | how long the worker held that lock |
parse |
µs histogram | parsing under the lock |
parse_bytes |
bytes | bytes parsed |
batches |
count | nonempty output batches; a batch that takes the lock several times counts once, and each acquisition is recorded in the histograms |
flushes |
count | output flushes a worker made, with or without a target, sent or suppressed |
flushes_untargeted |
count | flushes while the pane had no redraw target; no timestamp is stored |
flushes_coalesced |
count | flushes that found an earlier flush still pending, which keeps its time |
flushes_suppressed |
count | targeted flushes that sent no event because the pane's output event was still outstanding; a send the event loop refused is not counted |
sync_timeouts |
count | synchronized updates (DEC 2026) a worker released at the 150 ms bound rather than at their reset |
No identity holds between flushes, flushes_untargeted, flushes_coalesced,
and the flush_to_redraw count. A pane that closes drops its pending timestamp,
and separate counters are not read as one snapshot, so read each on its own.
Only quiescent totals, read after every worker has finished, satisfy
flushes_suppressed ≤ flushes − flushes_untargeted; a live snapshot or a phase
delta can break it, and nothing checks it.
The renderer fields print on their window's line. Each count belongs to the renderer that collected it.
| Field | Unit | Meaning |
|---|---|---|
vertex_bytes |
bytes | bytes written to the vertex buffer |
index_bytes |
bytes | bytes written to the index buffer: the whole index pattern on the first frame and the first frame after the buffer grows, 0 on every other frame |
damage_permille_sum |
permille | sum of each frame's damaged share of the surface; divide by damaged_frames for the mean |
damaged_frames |
count | frames whose damage was recorded |
damage_waste_permille_sum |
permille | sum of each frame's union-rectangle share of the surface minus the share its parts exactly cover; divide by damaged_frames for the mean waste |
software_frames |
count | frames the software presenter drew, on Windows with software-render degradation |
gpu_frames |
count | frames drawn through wgpu, including degraded frames on macOS and Linux |
row_cache_hits |
count | row glyph cache lookups that hit |
row_cache_misses |
count | row glyph cache lookups that missed |
shape_requests |
count |
FontStack shaping and measuring requests the renderer made |
full_frames |
count | frames whose render plan was Full; a frame whose plan was Noop is not counted |
partial_frames |
count | presented frames whose render plan was Partial (only the rows that meet the damage were assembled); counted after presentation, so divide by presented frames, not by full_frames
|
partial_fallbacks |
count |
Partial plans reassembled Full in the same frame because the final damage reached a row they did not assemble |
row_cells_hashed |
count | cells hashed into row glyph cache keys, one row per assembled terminal row |
row_cache_invalidate_visits |
count | kept for counter-contract compatibility and always 0: the content-keyed row glyph cache drops no row for dirt |
row_cache_invalidate_us |
µs | kept for counter-contract compatibility and always 0 |
recolor_glyphs_visited |
count | glyphs examined when recoloring glyphs under the cursor, the copy-mode cursor or a search match on the frame's main glyph list: the rows whose ink meets the target plus every glyph outside the terminal rows, such as tab titles; overlay text is not counted |
font_fallback_applies |
count | frames whose font preparation applied a newer fallback notice or generation, clearing shaped rows, missing-glyph atlas entries and the tab-title width epoch once; supporting evidence that a resolved fallback face reached the screen, which tests prove by pixels |
glyph_atlas_growths |
count | glyph atlas doublings, counted at every end-of-frame check and when the renderer settles its statistics; a frame that grows the atlas starts a growth episode |
atlas_growth_abandoned |
count | growth episodes no frame presented: settled when the device stops, before a rebind replaces the device, and when the App settles a retiring or exiting window's statistics; a reset in place abandons nothing |
shape_ns |
ns | time inside every shaping and measuring request, face merging included; a request made inside a timed rasterizer call counts as rasterizing |
raster_ns |
ns | time inside every glyph-atlas rasterizer call, glyph-zero resolution, rasterizer creation and tile conversion included; shaping inside it is not counted again |
raster_calls |
count | glyph-atlas rasterizer calls, terminal and chrome text alike; an atlas hit calls nothing |
raster_tiles |
count | rasterizer calls that returned a tile with pixels; no tile and an empty tile are calls, not tiles |
font_generation_applies |
count | preparations that applied a newer generation of the fallback notice already applied; unlike font_fallback_applies it leaves out the first preparation and a replaced font stack |
font_prepare_ns, font_generation_prepare_ns
|
ns | time inside frame font preparation, invalidation included, for every preparation and for those that applied a newer generation; outside every render attempt |
tab_title_reuses |
count | tab titles drawn from the title cache with no shaping, one per title per assembly pass, presented or not |
tab_title_prepares |
count | tab titles fitted and shaped on a title-cache miss, one per title per assembly pass; their shaping requests also count in shape_requests
|
chrome_run_reuses |
count | chrome-run lookups (the search overlay's icon and label measures and draws) served from the chrome-run cache with no shaping, one per lookup inside the assembly pass |
chrome_run_prepares |
count | chrome-run lookups that shaped, one per lookup inside the assembly pass; their shaping requests also count in shape_requests
|
render_attempts, render_attempts_presented
|
count |
render_releasing calls, and those that presented |
render_attempt_ns, render_attempt_shape_ns, render_attempt_raster_ns
|
ns | time inside those calls, and the shaping and rasterizing time spent inside them |
render_attempt_shape_requests, render_attempt_raster_calls, render_attempt_raster_tiles
|
count | shaping requests, rasterizer calls and tiles inside those calls |
apply_attempts, apply_attempts_presented, apply_attempt_*
|
as render_*
|
the same for the render attempts that carried a fallback generation apply |
assembly |
µs histogram | CPU frame assembly in the renderer: from the frame-key check to the end of overlay assembly, before the atlas-retry check, upload, surface acquire, submit and present; one sample per assembled frame, including frames that later retry or fail to present; a Noop or skipped frame adds none. It is not the app's render lap |
atlas_growth_to_present |
ms histogram | from the start of the first frame that grew the glyph atlas to the next successful present; one sample per presented growth episode |
On Windows a frame the GDI presenter draws counts as software_frames; the
hosted Windows CI runner has no GPU, so its runs report software_frames and no
gpu_frames. A frame presented through wgpu, including on its software adapter,
counts as gpu_frames.
shape_requests counts each call to FontStack::shape_text_for_frame or
measure_text_width_for_frame that the renderer makes through
frame_stats::shape_request, failures included; a call skipped for empty text is
not a request. It counts requests, not HarfBuzz attempts or fallback retries. A
title or chrome run served from its cache makes no request.
Each fallback generation apply is carried by exactly one render attempt: a
preparation that applies a newer generation owes it, and the next
render_releasing call takes it, however many preparations or copies of the
font token come between. A retry is a later call and carries none. A helper the
same renderer opens during an attempt, such as notification text layout, joins
that attempt; another renderer's work does not. Every _ns field is summed
nanoseconds; perf-compare subtracts and pools them exactly and shows them in
microseconds. Per phase it adds a pooled split of every attempt and of the apply
attempts: the matched totals of the runs that carry them all are summed, then
divided into shaping, rasterizing and remaining shares, with means per attempt.
Every duration is a cumulative bucket histogram with an exact sum.
| Unit | Bucket upper bounds |
|---|---|
| ms | 4, 7, 9, 12, 17, 25, 34, 50, 100, then above 100 |
| µs | 10, 50, 100, 500, 1000, 5000, then above 5000 |
A value equal to a bound falls in that bound's bucket. On a line, a histogram prints its bucket counts in order, its sum, its p95, and its maximum:
<name>_ms=[<count>,...] <name>_sum_us=<µs> <name>_p95_le_ms=<bound> <name>_max_le_ms=<bound>
A µs histogram uses _us in place of _ms. The sum is exact: the sum of the
recorded microsecond values, at the clock's resolution and with the
instrumentation's own cost included, so the sum divided by the bucket total is the
mean. The p95 and the maximum are never exact. _le_<unit>=N means at or below
the bound N, and _gt_<unit>=N means in the overflow bucket above the largest
bound N.
Around each lock of a pane's parser, the VT worker reads the clock four times:
before lock(), as it returns, after parsing, and after the keyboard-snapshot
store, before the guard drops. The wait is the first interval, the parse the
second, and the hold runs from the second read to the fourth. The last three reads
happen under the lock and lengthen the hold slightly; that is the enabled cost.
Every subtraction and counter update waits until the guard has dropped.
Every event-loop-thread lock of a pane's parser goes through one lock_parser
helper. Inside a dispatch of an App whose counters are on, it reads the clock
before and after lock(), then, with the guard held, adds one bucket increment
and one sum to thread-local counters that the App takes at the end of the
dispatch. Otherwise it is exactly lock().
flowchart TD
batch["VT worker finishes an output batch"] --> target{"pane has a redraw target?"}
target -- no --> untargeted["count flushes_untargeted, store no time"]
target -- yes --> pending{"a flush still pending?"}
pending -- yes --> coalesced["keep the older time, count flushes_coalesced"]
pending -- no --> store["store the flush time in the pane's slot"]
coalesced --> watch["an armed echo watch records the publication"]
store --> watch
watch --> token{"an output event outstanding?"}
token -- yes --> suppressed["send nothing, count flushes_suppressed"]
token -- no --> send["send PaneOutput"]
send -- refused --> refused["clear the token"]
suppressed --> decision["an armed echo watch records the decision"]
send -- sent --> decision
refused --> decision
send -- sent --> redraw["first RedrawRequested of a window that shows the pane"]
redraw --> take["take the time and record its age in flush_to_redraw"]
The worker stores the flush time before it sends the output event, so the event loop never wakes before the time is published. A flush published while a redraw is taking the slot is taken by that redraw or left for the next one, never lost or counted twice.
Readings are observational. Fields are read one after another, not as one atomic snapshot, and a count belongs to the line or snapshot that read it after it was published; several batches may publish between two field reads.
Each pane also has an echo watch while the gate is on. The perf harness arms it
for S2/default and S2/flood samples, and only when it is built with perf-echo-trace.
Arming issues a nonzero token that an App
never reuses; the harness takes the record once, when a frame is credited, and every
other close of the sample takes and discards it. A writer changes the record only for
the armed token, while it is not yet taken, and only with an instant no earlier than
the arm, so a writer paused across a take or a re-arm changes nothing. The lock order
is parser, then watch slot; arming and taking take only the slot.
Under the parser guard, the worker reads the target cell before parsing (after the
locked_at read) and after it (after the parsed_at read, before the keyboard
snapshot is stored). The section in which the cell goes from absent to present, with
the row identity (scrollback_evicted, screen_epoch, size_generation) unchanged
at both reads, is the appearance: it records the generation its batch publishes,
the parse instant and whether a synchronized update is set. Any identity change,
an echo already present before an appearance, and an echo absent again after it are
recorded as sticky facts.
The split's boundary is the publication: after publish_flush returns and before
the token decision, an armed watch with an appearance and no publication reads the
clock under its slot lock. The decision that follows, sent, suppressed or
refused, is recorded after it. The watch never changes the decision or the send.
| Part | From | To |
|---|---|---|
input_to_parse_ms |
injection | the appearance section's parse instant |
parse_to_publication_ms |
that parse instant | the publication |
publication_to_present_ms |
the publication | the end of the credited dispatch |
delivery_lag_us |
the publication | the worker recording its decision, including the send call for sent
|
The three parts add up exactly to the sample's latency. delivery_lag_us is not
event-loop delivery latency. A suppressed publication is split: its
publication_to_present_ms includes the remaining queue wait of the earlier event,
which is real latency. A refused one reads send-refused.
| Work | Lock wait | parse_us |
Hold |
|---|---|---|---|
The unarmed Acquire load and the pre-read |
no | yes | yes |
| The post-read and any recorded-fact slot lock | no | no | yes |
Unarmed, a section and a flush each cost one load. Armed, the slot is locked once to fetch the target, at most once per new fact, once per eligible publication and once for its decision. The flush path reads the clock 0 times unarmed or with no eligible publication, once for an eligible untargeted flush and twice for an eligible targeted one.
Synchronized output: a worker holds its update and publishes at the reset, or at the
published deadline, so its hold is included in parse_to_publication_ms and
excluded from flush_to_redraw, which starts at the publication. A window cannot hold past that deadline for the same epoch, so
publication_to_present_ms includes a hold only from another visible pane or a later
epoch, each stretch capped at 150 ms. sync_open is the parser's set bit after the
appearance section, not proof of a hold.
An armed echo watch can also own its window's echo timeline, a per-sample record of the event loop from the arm to the credited frame. The first pane armed in a window owns it while that owner lasts; another pane armed in the same window records no timeline, and re-arming the owning pane replaces it. The owner's successful take ends the owner, as do the pane leaving the window or being retired and the window closing.
The owner's record keeps at most 64 events, each stamped in nanoseconds from the arm;
the 65th sets overflow and is dropped. It records:
- the latest PTY read stamp among the chunks of the section where the echo appeared;
- each output check that evaluated the watched pane, except a repeat of the previous check; recording stops only once the appearance is known and a kept check has reached it;
- each accepted display-link tick, by the window's accepted-tick count (never reset at the arm) and its link generation;
- each redraw admission decision for the watched window, and each clearing of a held permit other than its own consumption;
- the entry and return of each watched
RedrawRequesteddispatch, and the enter and exit of its renderer call.
A ring of 256 entries keeps every completed output service in the owner's window, the
watched pane's included; the 257th evicts the oldest and records its loop_seq. The
comparison's flood count leaves the watched pane out.
take_echo_timeline_v1 transfers the record exactly once. The owner's take of the echo
watch freezes it with a copy of the window's ring, and the transfer moves it out of the
App. The answers, in the order they are decided: the gate off GateOff; an unknown pane
NoPane; another token Mismatch; a second transfer AlreadyTaken; an arming that owned
no timeline NotRecorded, before any check of the take; a watch not yet taken
NotTaken; and a take that did not freeze the record, because its timeline ended before
it, NotRecorded. A pane created
while the gate is on has its PTY reader stamp each chunk with the instant its native read
returned; that stamp is the record's read.
The harness binds each credited sample to the one watched dispatch whose entry and
return lie inside the credited forward, validates the raw record, and writes the
sample's echo_timeline and the run's echo_timeline_coverage to result.json;
nothing is logged (Comparing performance
describes the fields).
While the gate is on, the perf harness may arm one S10 attribution watch per pane with the run's sentinel line, the prompt and the phase's update count; arming returns a nonzero arming id and re-arming a pane replaces its watch. With the gate off, or for an unknown pane, an empty or over-long marker (more than 128 bytes) or a phase of no updates, nothing is armed.
Each main-window attempt that passes the guarded synchronized-output recheck
copies, under the frame's parser guards, each watched pane's state: its
resets, epoch and set, whether the sentinel and the prompt show from the
cursor's row up to 8 rows above it, and why the frame was admitted. Only an
attempt that presents writes them, one WARN line per watched pane on
sonic::perf_present, which the default sonic=warn filter admits:
perf_present kind="present" arming=<id> seq=<n> window="main" pane=<id> resets=<n> epoch=<n> set=<bool> sentinel=<bool> prompt=<bool> admission="<admission>"
seq is the main renderer's presented count after that frame. admission is
closed, forced-first, forced-visibility, forced-device, forced-resize,
forced-surface, timeout or credit. A failed or retried attempt writes
nothing. A watch writes at most 4 × updates + 64 present lines, then one
kind="overflow" line with emitted=<n>, then nothing. Disarming drains nothing.
The counters themselves do not split keystroke latency at the flush; the S2 echo
watch above does, for S2/default and S2/flood. flush_to_redraw measures delivery and
scheduling delay to the first redraw, and does not credit a presented frame. Maxima and p95s are bucket bounds, sums include the
instrumentation's own cost, and shaping counts are requests, not HarfBuzz work.
Each wgpu device keeps one error state, shared by every window built from the
same GPU context. The sonic::gpu target writes one record per state change and
one for the first isolated fault; the default filter's sonic=warn admits them.
Repeated errors update counts without writing new records.
| Message | Level | Written when |
|---|---|---|
GPU device stopped accepting work |
error |
a Validation, OutOfMemory, or Internal error moves the device from Usable to Unusable
|
GPU device lost |
error |
the device-lost callback records Lost, including after an intentional destroy |
contained isolated GPU error |
warn |
the first isolated fault from the test fault hook; later ones only count |
| Field | Meaning |
|---|---|
generation |
process-unique number of the device |
state |
device state when the record was written |
kind |
error class that caused the record: validation, out of memory, internal, or device loss |
operation |
label of the renderer operation that raised the error, such as render.submit, try_resize, or glyph_upload.rebuild
|
description |
wgpu's error message |
lost_reason |
wgpu's loss reason; empty except on loss records |
destroy_requested |
whether SonicTerm destroyed the device on purpose; on state-change records |
validation, out_of_memory, internal, isolated, lost
|
coalesced counts per error kind |
After an error record, every window stops drawing on that device. Shells,
input, sessions, and window lifecycle keep working. A recorded loss starts
shared-device recovery; an unusable device without loss remains stopped.
Each affected renderer logs one warning, render error for main or
child render error for another window, the first time it observes the stop.
Architecture Internals has the containment rules.
The sonic::gpu::recovery target is admitted by the default sonic=warn filter.
Warnings identify scheduling, request admission or refusal, negotiation and
renderer preparation/commit failures, timeouts, busy-worker refusals, actual
request completion (outcome and decision), and a successful
shared GPU recovery committed. Error records identify a
disconnected worker, an unusable device without a loss, and
shared GPU recovery exhausted; terminal sessions remain running.
generation identifies the committed or newly committed device, ticket
identifies an admitted request, attempt is its one-based budget position,
delay_ms is the scheduled backoff in milliseconds, and rebound is the
number of renderers committed together. Failure records include the native
error where available. These records contain no terminal output or input.
A successful commit record proves replacement and gate acceptance, not native
scanout or that a later frame presented. The stability timer starts only on an
acknowledged Presented frame. A request timeout does not prove the native
worker exited, and shutdown disposal is best-effort; compare request identities
and later completion records rather than interpreting silence as cleanup.
The retry policy and limits are on Rendering Modes.
At level = "info", target="memory" writes one memory snapshot at most
every 30 seconds. It combines OS process figures, all sampled pane seams, visible
and warm renderers, and one shared-device allocator reading:
The following log examples are schematic, not captured measurements. Angle-bracket
values stand for runtime fields; <metric> can be a byte count or unsupported,
and <delta> can be a signed change or unavailable. Lines wrap for readability.
memory snapshot process_private_committed_bytes=<metric> process_resident_bytes=<metric>
process_virtual_bytes=<metric> process_private_committed_delta=<delta>
process_resident_delta=<delta> process_virtual_delta=<delta>
session_total_bytes=<bytes> session_delta=<delta>
grid_visible_bytes=<bytes> grid_history_bytes=<bytes> grid_alternate_bytes=<bytes>
parser_bytes=<bytes> hyperlink_bytes=<bytes> inline_media_bytes=<bytes>
pty_output_bytes=<bytes> pty_input_bytes=<bytes> panes_total=<count> panes_sampled=<count> panes_contended=<count>
renderer_total_bytes=<bytes> renderer_total_items=<count>
renderer_row_glyph_cache_bytes=<bytes> renderer_row_glyph_cache_items=<count>
renderer_row_quad_cache_bytes=<bytes> renderer_row_quad_cache_items=<count> renderer_delta=<delta>
live_renderers=<count> live_fg_probe_workers=<count> renderers="visible[<window-id>] glyph=<bytes>/<items> image=<bytes>/<items> row_glyph=<bytes>/<items> row_quad=<bytes>/<items> software=<bytes>/<items> vertex=<bytes>/<items> total=<bytes>/<items>; warm[<slot>] glyph=<bytes>/<items> image=<bytes>/<items> row_glyph=<bytes>/<items> row_quad=<bytes>/<items> software=<bytes>/<items> vertex=<bytes>/<items> total=<bytes>/<items>"
allocator_state=measured allocator_source=main allocator_label=<window-id>
allocator_allocated_bytes=<bytes> allocator_reserved_bytes=<bytes>
allocator_allocations=<count> allocator_blocks=<count> allocator_largest_block_bytes=<bytes>
[checkpoint_index=<index> checkpoint_label="<label>" checkpoint_attempt=<attempt> checkpoint_complete=<bool>
trimmed=<bool> trim_source="<source>" trim_seq=<count>]
Process figures come from the OS, so they include allocator fragmentation, retired pages, mapped files, GPU-driver mappings, and thread stacks that SonicTerm's own seams do not count.
| Field | Meaning |
|---|---|
process_private_committed_bytes |
memory charged to this process alone; Windows reports PrivateUsage, macOS and Linux report unsupported
|
process_resident_bytes |
pages currently resident in physical memory; macOS reports resident size, Windows reports WorkingSetSize, and Linux reports unsupported
|
process_virtual_bytes |
reserved address space; macOS and Windows measure it, Linux reports unsupported; measured values can be hundreds of gigabytes without representing consumed memory |
*_delta |
change since the preceding snapshot; +0 is measured, while unavailable means no comparable sample |
panes_total |
all panes visited |
panes_sampled |
panes included in session_total_bytes
|
panes_contended |
panes skipped because a parser or inline-image lock was busy; non-zero makes the session total partial |
renderer_total_bytes / renderer_total_items
|
CPU-side storage across visible and warm renderers |
renderer_row_glyph_cache_bytes / renderer_row_glyph_cache_items
|
row glyph cache payload plus tracking storage and cached row count, summed across renderers (each bounded to 512 MiB) |
renderer_row_quad_cache_bytes / renderer_row_quad_cache_items
|
per-row background/decoration quad cache storage and cached row count across renderers |
live_renderers |
process-wide renderer count; a count above the renderers entries can expose an unreachable live renderer |
live_fg_probe_workers |
this App's foreground-probe worker threads: 0 before the first demand or after the worker stops, otherwise 1 |
renderers |
per-renderer role and glyph/image/row-cache/software storage breakdown; each entry ends with renderer_trimmed and renderer_gpu_released_requested_bytes, the GPU request sizes a covered-window trim gave back, outside total; the renderer-local names keep them apart from the line's own trimmed tag |
allocator_state |
measured, unsupported for a backend without a report or a stopped GPU device, or none before a renderer exists |
allocator_source / allocator_label
|
renderer class and identifier used for the one shared-device reading |
allocator_allocated_bytes |
bytes assigned to live wgpu allocations |
allocator_reserved_bytes |
bytes reserved in wgpu allocator blocks |
allocator_allocations |
live allocation count |
allocator_blocks |
allocator block count |
allocator_largest_block_bytes |
largest allocator block in bytes |
checkpoint_index / checkpoint_label / checkpoint_attempt
|
only on a sample taken for a perf checkpoint: which checkpoint, and which attempt at it, from 1 |
checkpoint_complete |
only on a checkpoint sample: true when no pane was contended and every pane was sampled |
trimmed / trim_source / trim_seq
|
only on a checkpoint sample from a build with the trim hook: whether a visible renderer was trimmed in its current covered stretch, what requested the latest trim (scheduler, hook, or none before any), and the trims since startup |
The allocator is reported once per shared device/context, not once per renderer. Sampling shares the retention cadence. An idle session wakes for a due sample, but that wake suppresses redraw and draws no frame.
At debug, a covered-window trim writes one covered window trimmed line on
memory with the window, presenter, released frame-texture size, present-buffer
bytes before the trim, gpu_released_requested_bytes and reason="occlusion_trim";
a released image atlas writes its own release line with the same reason.
scripts/perf-compare.py reads this line from every scenario run
(Comparing performance). Each
scenario's final memory checkpoint comes at least 60 s after GO, when the
harness releases the workloads (5 s with --short, as in the smoke). Most
scenarios end with an idle phase that lasts at least until then; S4 and S5 end
on their 60 s stream phase, with the date loop still running, and S12 ends on
its 10 s uncovered hold. S11 and S12 also take intermediate checkpoints. A
harness built with perf-hook-checkpoint-memory takes its own tagged sample at
each checkpoint, and a checkpoint's figures come only from those samples;
without the hook a checkpoint has no memory figures (n/a: unsupported). The
process_* byte fields read a byte count or unsupported,
while session_total_bytes and renderer_total_bytes are always integers.
renderer_total_bytes counts renderers' CPU-side storage only, and the macOS
process sample has no footprint figure, so in a managed run perf-compare.py
answers each checkpoint request with a macOS footprint reading. It bounds
footprint at 40 s and writes the checkpoint's .done file only after
footprint has exited and been reaped, or when it never launched; otherwise
.done is withheld, and the harness's own wait ends the run. If .done does
not appear within 60 s, the harness ends the run at once as invalid (exit 3),
with a reason that names the checkpoint, and the next phase never starts. That
is not an occlusion: the smoke fails instead of retrying it, and a comparison
retries the run.
At level = "debug", the same cadence writes one pane retention line for each
sampled pane, then one session retention line. A pane whose parser or
inline-image lock is busy is skipped, not waited on.
pane retention pane="<window-id>/<pane-id>" total_bytes=<bytes>
grid_visible_bytes=<bytes> grid_history_bytes=<bytes>
grid_alternate_bytes=<bytes> parser_bytes=<bytes> hyperlink_bytes=<bytes>
inline_media_bytes=<bytes> pty_output_bytes=<bytes> pty_input_bytes=<bytes>
largest_seam="<seam-name>" largest_seam_bytes=<bytes>
session retention panes=<count> total_bytes=<bytes> grid_visible_bytes=<bytes>
grid_history_bytes=<bytes> grid_alternate_bytes=<bytes> parser_bytes=<bytes>
hyperlink_bytes=<bytes> inline_media_bytes=<bytes>
pty_output_bytes=<bytes> pty_input_bytes=<bytes>
The eight seam fields are disjoint and sum to total_bytes:
| Field | What it owns | First response |
|---|---|---|
grid_visible_bytes |
current screen rows, prompt storage, rare attributes, and grid-container overhead | no action; this includes the screen |
grid_history_bytes |
retained scrollback | lower scrollback if needed |
grid_alternate_bytes |
saved primary-screen rows and history while the alternate screen is active | leave the full-screen program |
parser_bytes |
in-flight escape or media-capture buffers | recheck the next sample; normally transient |
hyperlink_bytes |
interned OSC 8 URI and id strings | no action; bounded and reclaimed when links leave retained history |
inline_media_bytes |
decoded inline images retained by panes | display fewer images or close image-heavy panes |
pty_output_bytes |
local PTY output queued or in flight | let output drain |
pty_input_bytes |
input queued toward the shell, usually a large paste | let the shell drain it |
Read largest_seam first, then compare at least five consecutive samples. A
large flat working set is different from a value that rises every sample. Pane
labels contain window and pane identifiers; a pane keeps its pane id after a tab
moves even though the window id changes.
Each visible or warm renderer also writes one renderer retention line:
renderer retention window="<window-id>" role="visible" total_bytes=<bytes>
glyph_atlas_bytes=<bytes> glyph_atlas_items=<count>
image_atlas_bytes=<bytes> image_atlas_items=<count>
row_glyph_cache_bytes=<bytes> row_glyph_cache_items=<count>
row_quad_cache_bytes=<bytes> row_quad_cache_items=<count> software_frame_bytes=<bytes>
vertex_scratch_bytes=<bytes> vertex_scratch_items=<count>
row_ink_bytes=<bytes> row_ink_items=<count>
frame_scratch_bytes=<bytes> frame_scratch_items=<count>
chrome_cache_bytes=<bytes> chrome_cache_items=<count>
renderer retention window="warm[<slot>]" role="warm" total_bytes=<bytes>
glyph_atlas_bytes=<bytes> glyph_atlas_items=<count>
image_atlas_bytes=<bytes> image_atlas_items=<count>
row_glyph_cache_bytes=<bytes> row_glyph_cache_items=<count>
row_quad_cache_bytes=<bytes> row_quad_cache_items=<count> software_frame_bytes=<bytes>
vertex_scratch_bytes=<bytes> vertex_scratch_items=<count>
row_ink_bytes=<bytes> row_ink_items=<count>
frame_scratch_bytes=<bytes> frame_scratch_items=<count>
chrome_cache_bytes=<bytes> chrome_cache_items=<count>
| Field | What it owns | First response |
|---|---|---|
glyph_atlas_bytes |
CPU glyph-atlas capacity for this renderer | bounded; a warm entry is controlled by warm_window_pool
|
glyph_atlas_items |
glyph entries in that atlas | use with bytes to distinguish occupancy from capacity |
image_atlas_bytes |
CPU inline-image atlas pixel-buffer capacity, including the nonempty 1×1 placeholder allocation | reduce image use or renderer count |
image_atlas_items |
inline-image atlas entries | use with bytes to identify image occupancy |
row_glyph_cache_bytes |
cached glyph-record, underline, tofu and missing-character vector capacities plus tables, slot vectors and pin lists | at most 512 MiB per renderer; a pane is released when an assembly pass's begin_frame omits that pane |
row_glyph_cache_items |
cached glyph rows | a falling count with flat bytes can mean reusable table capacity remains |
row_quad_cache_bytes |
hash-table backing plus cached background/decoration quad vector capacities | compare with cached rows and pane/window churn |
row_quad_cache_items |
cached quad rows | a falling count confirms row eviction even when table capacity is sticky |
software_frame_bytes |
full-window Windows software-present buffer | reduce window size; zero outside that path |
vertex_scratch_bytes |
the UploadStaging part: the presentation pipeline's reused CPU vertex-assembly buffer plus each atlas upload's dirty and coalesced rect lists and staging buffer |
the vertex buffer follows the largest recent frame and shrinks to twice a frame's use once over four times that use and over 1 MiB; a sync releases the rect lists; a staging buffer holds at most one atlas during a sync and follows the vertex rule, keyed on that sync's largest write, after each wgpu sync; a covered-window trim releases all of it |
vertex_scratch_items |
1 while the vertex buffer holds an allocation, else 0 | — |
row_ink_bytes |
the RowInk part: the allocated buckets of the per-row ink table (where each presented row drew, per pane and slot) plus one frame's staging buffer |
bounded by the visible rows; a pane that shrinks or closes releases its records at the next presented frame |
row_ink_items |
committed per-row ink records, one per visible row | — |
frame_scratch_bytes |
the FrameScratch part: the per-frame draw vectors (glyphs, quads, overlays, images, row spans, underlines, staging indices, tofu, pane rects, column edges and row keys) the renderer keeps between assembled frames |
every restoration clears each vector and holds it within its cap (16,384,000 bytes in total); only a completed assembly also shrinks a vector to twice its use once over four times that use and over 1 MiB and drops column-edge slots above its peak, so a failed or retried frame keeps its warm capacity; zero while a frame holds it |
frame_scratch_items |
scratch vectors that hold an allocation | — |
chrome_cache_bytes |
the ChromeCache part: the 64-slot tab-title table and the 32-slot chrome-run table, each kept run's text and glyphs, and the kept UI palette's color strings |
bounded by the fixed tables and their admission limits (256 text bytes, 512 glyphs) and by a 4 KiB palette allowance (a larger palette is derived again on every request, never kept); a font change, scale change or clear_shape_cache empties both run tables |
chrome_cache_items |
kept tab titles plus kept chrome runs | — |
role="warm" means the renderer belongs to the standby pool, not a visible
window; closing a window does not release it. Renderer figures are host memory,
not GPU video memory.
The default warn filter admits warnings on memory::reclaimed because they
explain visible loss:
grep 'memory::reclaimed' ~/.sonicterm/logs/sonicterm.log*-
cancelled a media capture that stopped receivingmeans no bytes arrived for two 30-second samples. The incomplete image will not appear and staging was reclaimed. -
discarded inline images from idle panes to stay within the process ceilingmeans older decoded images were removed; resend any still needed.
The default filter also admits inline image atlas full; skipped older images without evicting text glyphs on sonic::glyph_atlas; renderer atlas pressure
prevented older images from being uploaded. inline media evicted to hold the process-wide ceiling is a warning on the memory target and therefore appears
when level is info or debug; it means a pane removed older images while
retaining at least its newest image.
The panic hook runs on every thread and writes a session-tagged
crashes/crash-<timestamp>.log containing version, panic payload, source
location, forced backtrace, and up to 50 admitted tracing events. Normal shutdown
writes sonic_exit warning lines. On Unix, the first of SIGSEGV, SIGBUS, SIGILL,
SIGABRT, and SIGFPE to arrive appends a fixed FATAL: SIG… line through an
async-signal-safe path. The handler then calls the action installed before it,
at most once per process, with the original signal information, so Rust's
runtime can still name a thread that overflowed its stack. When that action
returns, or there was none, the handler raises the signal again under its
default action. That ends the process, so the operating system can still
produce its diagnostics, which describe the raised signal rather than the
original fault; an ignored fatal signal still ends the process. A stack
overflow is therefore logged as FATAL: SIGSEGV (or SIGBUS), although the
process then ends by the SIGABRT that follows Rust's report. Windows relies on
WER or LocalDumps when the system is configured to create them.
Crash history uses the selected RUST_LOG/configured filter without widening
it, plus a DEBUG ceiling and an explicit persistence predicate. TRACE is never
retained there, even when an output sink opts in. Font shaping text and
collections use sonicterm_font::payload, which is excluded from crash history
at every level; normal warning/error targets retain safe stage/count diagnostics.
Routine white-text and untinted-color-glyph emission is not a warning.
Each record owns at most 4 KiB of variable payload, including at most 256 bytes
of target. The ring also enforces a 64 KiB aggregate variable-capacity bound and
the 50-record limit, evicting oldest records. Formatting uses bounded storage
and UTF-8-safe truncation; [truncated] fits inside each cap. Fixed metadata is
bounded separately by record count. Panic payload text and the rendered summary
each have an independent 4 KiB bound, read from borrowed panic data.
Backtrace capture/output and a chained panic hook are separate surfaces. These
limits cover recorder-controlled formatting/retention, not allocations inside
arbitrary producer Debug implementations or a guarantee that arbitrary logs
contain no secrets. Payload TRACE remains opt-in normal-sink evidence; structured
breadcrumbs keep their separate metadata-only contract.
A hang may produce no panic artifact. On macOS, sample before force-quitting:
sample <pid> 10 -file /tmp/sonicterm-hang.sample.txt
grep -nE 'dispatch_sync_f_slow|redraw_target|__psynch_cvwait' \
/tmp/sonicterm-hang.sample.txtSIGKILL, Force Quit, TerminateProcess, power loss, and a hard OOM run no
cleanup code. SonicTerm cannot write a final line or post-failure dump in those
cases. Instead it leaves two pre-failure records:
-
sessions/session-<id>.markerrecords only session id, pid, version, platform, start time, and state. A stale marker means the session was not marked clean; native PTY teardown that did not settle also leaves it behind. It does not identify the cause. Live sibling processes are skipped, damaged markers still count as evidence, and each prior marker is reported once on the next launch. -
breadcrumbs/breadcrumbs-<id>.logis a bounded atomic snapshot with no terminal text, commands, environment values, tokens, or credentials. It pins the latest version, platform, renderer, counts, full process resource sample, retention, allocator state, and bounded lifecycle transitions. Itsevent=retentionrecord includesrenderer_bytes,row_glyph_cache_bytes/row_glyph_cache_items, androw_quad_cache_bytes/row_quad_cache_items, so the last complete pre-failure snapshot preserves both row-cache size and occupancy. A separate fixed-costevent=resource_history private_committed=... resident=...sample is taken immediately and every 5 seconds, retaining at most 48 samples. Virtual address space appears only in the fullevent=resourcerecord.
A breadcrumb rewrite replaces the old file atomically. The surviving file after a hard kill is the latest complete pre-failure snapshot, not a dump and not proof of cause. The default file budget is 64 KiB; configured limits must be able to hold all mandatory records, lifecycle capacity, and one maximum-width history line. The absolute minimum is 4096 bytes.
On the next launch, SonicTerm also checks OS records by conservative filename convention:
| Platform | Locations checked |
|---|---|
| macOS |
~/Library/Logs/DiagnosticReports, /Library/Logs/DiagnosticReports (.ips) |
| Windows |
%LOCALAPPDATA%\CrashDumps, %LOCALAPPDATA%\Microsoft\Windows\WER\ReportQueue, ...\ReportArchive
|
A match only “may relate to” SonicTerm. The Windows check does not read WER registry configuration, so no file means only that the standard locations held no match.
Include:
- SonicTerm and OS versions.
- The last 200 lines of the newest
sonicterm.log*file. - The relevant panic artifact, OS record, or process sample, if present.
- Exact reproduction steps and a screenshot or short recording for visual, input, VT, font, or layout defects.
- Hardware/software adapter information for rendering problems.
- For memory growth, at least five consecutive
memory snapshotrecords; withdebug, also include the identified pane'spane retentionlines and allsession retentionand relevantrenderer retentionlines over the same interval. State the pane'slargest_seamand what the session was doing.
Do not post secrets, tokens, full environment dumps, terminal output, or sensitive command data.