Notes for coding agents (and humans) working in evp. Keep this short — it should help you avoid stepping on the same rakes we already found.
- Musl Target: This environment compiles for the
x86_64-unknown-linux-musltarget by default. When calling the compiled binary directly, make sure to use./target/x86_64-unknown-linux-musl/...rather than the standard./target/...directories. - Standalone Build:
evpis a workspace member of a multi-root setup (alongsidelibghostty-rsandvhs) but builds standalone viacargo build. - Prebuilt Ghostty: For Copilot/restricted builds, always use the prebuilt libghostty pkg-config artifact in
assets/libghostty; do not rely on vendored Ghostty fetches fromlibghostty-vt-sys. KeepGHOSTTY_SOURCE_DIRunset. - Refresh Artifact: Refresh the prebuilt artifact using
docker buildx bake extract-libghostty. - Performance Profile: A release build is required for timing or smoke testing — debug is 15-20× slower because of glyph rasterization and gifski quantization:
- Debug: ~200ms/frame
- Release: ~14ms/frame
- Smoke test:
./target/x86_64-unknown-linux-musl/release/evp ./examples/hello.tape --output /tmp/x.gif - Trace logs: Append
--log-level trace(very chatty). - Code Formatting: Remember to run
cargo fmtafter making any edits to Rust code files.
- Centralized Source of Truth: All font loading, glyph fallback selection, and cell metrics calculations are centralized in
src/font.rs. Avoid ad-hoc font parsing or loading. - Lazy Decompression: Embedded WOFF2 files (JetBrains Mono Nerd Font Mono variants, Noto Sans Mono, Noto Sans Symbols 2, CJK JP subset, Unifont Upper/CSUR) are lazily decompressed to TTF once at runtime using
OnceLock, caching the decoded TTF bytes. - FontSet: Holds loaded fonts and ordered style-specific indices. When looking up a glyph,
FontSet::select_for_charwalks the list to find the first face covering the character, falling back to the primary regular font if none do. - Script Settings: Font selection is configured strictly via the
.tapesettings (e.g.Set Font ...). The--fontCLI option has been removed.
- Subsetting is Mandatory: We use the
font-subsetcrate to create WOFF2 subsets of all required fonts. Only the glyphs actually used in the recording are embedded. If subsetting fails, the renderer returns an error. - WOFF2 base64 Data: Embedded fonts are always base64-encoded WOFF2 strings in
@font-faceblocks. - Conditional Embedding: The style block dynamically determines which font variants (bold, italic, CJK fallbacks, etc.) are actually required by scanning the character sets used in the recording. The default font is only embedded if it is actually used.
evp runs the runner plus one raw-frame consumer worker per output or library recording request:
- PTY/runner — drives libghostty + the script timeline. Captures one
RawFrameper frame deadline and hands clones to consumers with non-blocking sends. - RawFrameConsumer worker — gif/svg/json renderers write output files; the optional
FullRecordingconsumer builds an in-memoryRecording.
-
PTY Thread Must Never Block: The PTY thread uses
try_sendon each consumer channel. If a queue is full, the frame is dropped andraw_frame_consumer_dropped_framesis logged at the end. -
Channel Capacity: Bounded channel capacity is
4096to absorb startup bursts. -
Sender-Drop Discipline (The Deadlock Gotcha): Every channel has multiple senders (
tx.clone()). A worker exits itsrx.recv()loop only when all senders drop. If you hold onto a clone past thejoin()call, the worker blocks forever andJoinHandle::joindeadlocks.Concretely:
let RendererHandle { tx, join } = self; drop(tx); // Drop the clone we exposed to the runner match join { ... h.join() } // Now the worker can exit safely
-
Gifski rules (
src/render_gif.rs):gifski::new()returns(collector, writer). The writer'swrite()blocks until the collector is dropped. Never callwriter.write()on the same thread as the collector loop unless you've already dropped the collector, or you will deadlock instantly.
- libghostty types (
Terminal, render iterators) are!Send + !Sync. They stay on the runner thread. Only ownedRawFramevalues (plainVec+ cursor + colors) cross thread boundaries. - The runner does not own a
RecordingBuilder. Only the optionalFullRecordingconsumer builds an in-memoryRecording.
examples/benchmark_render.rs is the canonical timing harness. Do not silently change these settings:
Set TypingSpeed 80ms— chosen so per-characterTypeevents land on distinct frame deadlines at 30 fps (one keystroke ≈ 2-3 frames).Set Framerate 30— matches typical demo output.RENDER_REPEATS = 4— renders the sameRecording4 times to filter out cold-cache noise (Pass 1 font loads, allocator warmups).- Benchmarks build their filesystem fixture under
/tmp/evp-bench-fs-*and leave them for easy manual rerunning.
Filter logs with:
RUST_LOG=evp::runner=debug,evp::render_gif=info,evp::render_svg=info ./target/x86_64-unknown-linux-musl/release/evp ...Look for milestones in order:
spawning pty -> applied terminal theme theme=... -> render thread finished path=... -> frames captured.
If render thread finished doesn't print, it is a renderer-thread deadlock.
- Empty Diff Frames:
Frame::Diffwith nochangesis intentional to keep the timeline aligned with the framerate for animations like cursor blinking. - Padding: The recording extends
total_durationby ~4 frame intervals past the last script event to keep the final state visible and avoid stuck loops. - Pass 1 Slowness: The first pass of
benchmark_renderis slower due to font loading and allocator warmup.
ci/libghostty-pkgconfig.Dockerfilebuilds the prebuiltlibghostty-vtstatic library, headers, and pkg-config files intoassets/libghostty.- Run
docker buildx bake extract-libghosttyto refreshassets/libghosttyso local and CI builds do not need network access for Zig/Ghostty fetches. ci/vhs.Dockerfileis based on the charmbracelet VHS image and bakes inscripts/stress_test_program.pyandscripts/stress_test.tapefor the stress-test comparison run.
- PTY Process Deadlock: Standard shells (like
dash//bin/sh) can ignoreSIGHUPinside virtual PTY/container environments. Always useSIGKILLinstead ofSIGHUPto reap child processes inChild::drop(src/pty.rs), otherwise test suites will deadlock/hang indefinitely.