18 KiB
Deviant fixes: where the Rust port intentionally differs from Ghostty 1.3.1
The rule
The port is faithful to Ghostty 1.3.1 (including its quirks), with one
exception: genuine upstream bugs are fixed, not replicated. That means
memory-safety problems and undefined behaviour (use-after-free, stale
pointers, out-of-bounds reads, leaks), infinite loops, data loss or
corruption (lost or overwritten output), and crashes/asserts reachable from
input. See rule 2 in AGENT_GUIDE.md.
From now on every such fix is committed on its own as
fix-deviant(<scope>): ... and gets a row in the first table below. This
register also covers the deviations made before that convention; their
commits bundle other work, so the "Commit" column names the commit that
introduced the Rust behaviour (found with git log -S / git blame), not a
dedicated fix commit.
Conventions: Zig paths are relative to ghostty/src/. Line numbers refer to
the Zig tree used for the port. "unverified" means I could not confirm the
detail from the notes or code; "none" means no test exercises the deviation.
"uncommitted" means the change was in the working tree (agent Z17, ghostty-cli)
when this file was written.
Upstream bugs fixed (behaviour deliberately differs)
| # | Area | Zig location | Ghostty behaviour | Rust behaviour | Commit | Test |
|---|---|---|---|---|---|---|
| 1 | Kitty graphics: placement pins | terminal/kitty/graphics_storage.zig:148-176 (addPlacement), :517- (evictImage) |
The tracked pin of a placement is leaked when its image is evicted and when an external placement id is reused (getOrPut then overwrite of the value). |
add_image/add_placement/set_limit take the PageList and release the replaced/evicted placement's tracked pin. |
d47408f |
storage_add_placement_replaces_and_releases_pin, storage_eviction_prefers_unused_then_oldest (ghostty-terminal/src/kitty/graphics_storage.rs) |
| 2 | Kitty graphics: Placement::rect |
terminal/kitty/graphics_storage.zig:~789-798 (grid_size.rows - 1, grid_size.cols - 1) |
Unsigned underflow for a 0-sized grid. | saturating_sub(1) on rows/cols. |
d47408f |
none |
| 3 | Kitty graphics: file medium S= |
terminal/kitty/graphics_image.zig:268-269 (appendRemaining(.limited(size))) |
StreamTooLong whenever exactly size bytes are read, so any satisfiable S>0 fails with InvalidData (Zig tests only use S=0). |
Data is truncated to min(S, 400MB), as the Kitty spec says. |
d47408f (note clarified in 687752c) |
image_load_file_offset_and_size (ghostty-terminal/src/kitty/graphics_image.rs) |
| 4 | Kitty graphics: zlib size boundary | terminal/kitty/graphics_image.zig:412 (.limited(max_size)) |
Decompression errors at exactly 400MB of output. | Errors only above 400MB (take(MAX_SIZE + 1) then len > MAX_SIZE). |
d47408f |
none |
| 5 | Renderer: link matching | renderer/link.zig:99-123 (defer offset = end) |
A zero-length regex match leaves offset unchanged, so the loop never terminates. |
A zero-length match advances by one character. | 871c301 |
none dedicated (render_cell_map* in ghostty-renderer/src/link.rs do not cover it) |
| 6 | Input: keybind formatter | input/Binding.zig:2112-2150 (Set.Value.formatEntries) |
The shared buffer is rewound to a fixed position after each sub-entry, which corrupts the prefix of sibling entries for chained leaves in sequences. | Each entry is formatted from its own prefix. | e034322 |
set_format_entries_sequence_shares_prefix (ghostty-input) |
| 7 | Core: selection replacement | Surface.zig:2310-2322 (setSelection) |
select() untracks the previous selection's pins, then sel.eql(prev) compares through those released pins (stale pointers). |
The comparison happens before the old selection is replaced. (Rust handles are checked, so the naive port panicked; the e2e tests found it.) | 2f58f02 |
select_all_twice_with_copy_on_select_does_not_panic (ghostty-core/tests/e2e_bindings.rs), drag_selects_and_copy_on_select_fills_selection_clipboard (e2e_mouse.rs). Dedicated eql-before-replace test: unverified |
| 8 | Datastruct: lru eviction report |
datastruct/lru.zig:163-166 |
entry.data.key is overwritten with the new key before entry.data is copied out as evicted, so the evicted key is wrong. |
GetOrPutResult.evicted carries the old key and old value. |
5b8c92b |
get_or_put checks only evicted.value; the key is not asserted (ghostty-datastruct/src/lru.rs) |
| 9 | Terminal: stale cell pointer in grapheme attach | terminal/Terminal.zig:482 and :539 |
prev.cell survives a page reallocation (increaseCapacity in printCell of the spacer tail), so appendGrapheme writes into freed memory and the grapheme (for example U+FE0F) is lost. |
Cells are addressed by index; the grapheme is kept. | 4eb03f6 |
difftest_upstream_uaf_grapheme_survives_page_growth (ghostty-terminal/src/stream_readonly.rs); the difftest harness stores such mismatches as suspect* |
| 10 | Terminal: asserts and segfaults reachable from input | Terminal.zig:375 (assert(!graphemeBreak) after mode 2027 with U+2028/U+2060/BOM), terminal/page.zig:1012 (assert(y < size.rows); Devanagari conjuncts at the right edge with margins), Terminal.zig:834 (assert(!cell.hyperlink); REP with a huge count while a hyperlinked page grows) |
Zig aborts or segfaults (roughly 0.1-0.4 % of generated difftest cases). | Rust handles the input without panicking. Zig-aborting cases are excluded from the differential comparison, not treated as divergences. | 4eb03f6 |
difftest_grapheme_wrap_with_margins_moves_prev_cell and the harness (tools/difftest, ZIG CRASH handling) |
| 11 | VT: tmux control-mode ids | terminal/tmux/control.zig:199,230,270,308 (parseInt(usize) catch unreachable) |
An id that overflows usize hits unreachable (crash/UB). |
The line is treated as a failed match. | b2b248e |
none |
| 12 | VT: SGR separator bitset | terminal/sgr.zig:228,514 (params_sep.isSet(idx) past MAX_PARAMS) |
Asserts / out-of-range in StaticBitSet for long parameter lists. |
Bounds-tolerant sep_is_set. |
b2b248e |
sgr_underline_colon_with_trailing_separator_and_short_slice (ghostty-vt/src/sgr.rs); whether Zig's path is reachable: unverified |
| 13 | OS: NOTIFY_SOCKET |
os/systemd.zig:100-116 |
A leading @ (abstract socket) is accepted by the check, then copied literally into the address, which cannot work. |
A leading @ becomes an abstract-namespace address. |
f8c5ce0 |
none (the notify tests use a path socket) |
| 14 | Renderer: GL Buffer::init_fill |
renderer/opengl/buffer.zig:62 |
len = data.len * @sizeOf(T), so len is in bytes while the rest of the type counts elements. |
len is the element count. |
568ce78 |
none |
| 15 | Renderer: Pipeline teardown |
renderer/opengl/Pipeline.zig:72-74 |
deinit destroys only the program; the FBO and VAO leak. |
Drop also deletes the FBO and VAO. | 568ce78 |
none |
| 16 | Renderer: custom-shader uniforms | renderer/generic.zig:2061-2064 (cursor_text.color, and the selection colours) |
The union payload is read unconditionally; invalid for cell-foreground / cell-background. |
Uniforms are set only for explicit colours. | 871c301 |
none |
| 17 | Renderer (Metal): texture upload | renderer/metal/Texture.zig (replaceRegion) |
The data length is not checked against the region. | A too-short slice is rejected (safe API). Zig line: unverified. | bab49ee |
none |
| 18 | Input: binding order with catch-all | input/Binding.zig:287-294 (sort key of trigger.key) |
Reads the key of a catch-all trigger from the C union, which is undefined. | A catch-all sorts as 0. | e034322 |
none |
| 19 | Font: unsupported FreeType pixel mode | font/face/freetype.zig:707 |
@panic("unsupported pixel mode"). |
Error::UnsupportedPixelMode. |
2580176 |
none |
| 20 | Font: OpenType SVG table | font/opentype/svg.zig:45-46 |
Reads records[0] and the last record unconditionally, even for an empty document list. |
An empty list returns an error. | 2580176 |
none |
| 21 | Termio (Windows): process thread handle | Command.zig:347 (CreateProcessW) |
The thread handle returned by CreateProcessW is never closed (leak). |
It is closed. | 76771be |
none (compile-checked only on Windows) |
| 22 | Termio (Windows): focus_gained |
termio/Exec.zig (Windows branch; exact line unverified) |
@panic on Windows. |
No-op. | 76771be |
none |
| 23 | GTK: close confirmation dialog (fallback build) | apprt/gtk/class/close_confirmation_dialog.zig:45-46 |
The fallback registers a signal named close-request, which already exists on GtkWindow; GObject aborts (dead code upstream). |
The fallback names it dialog-close-request; the default build is unchanged. |
f2252bb |
runtime check recorded in notes/compat-GC.md; no unit test |
| 24 | libghostty: ghostty_surface_inherited_config |
apprt/embedded.zig:1573, :941 (dupeZ of the working directory) |
Allocates a new copy per call and never frees it. | The strings are kept in the surface and freed with it. | 75bf604 |
none |
| 25 | libghostty: invalid enum values from C | apprt/embedded.zig (mouse state/button, split direction, colour scheme, key action, pressure stage) |
Undefined behaviour or safety panic. | Ignored / returns false; null handles are tolerated. | 75bf604 |
unverified |
| 26 | Crash reporting: envelope attachments | crash/sentry_envelope.zig:399-419 (Attachment.encode) |
Never writes the type header, so the output cannot be parsed back. |
Adds "type":"attachment" when absent. |
b3145ad |
envelope_parse_multiple (ghostty-crash/src/envelope.rs) is the nearest test; a dedicated test: unverified |
| 27 | Build data: webgen output | build/webgen/main_actions.zig:5-6, main_commands.zig:6, main_config.zig:6 |
A 2048-byte stdout buffer that is never flushed, so the generated website files are truncated. | The complete files are emitted. | e4cdd8e |
none (checked byte-identical to Zig output of a patched copy, per final-Z4.md) |
| 28 | CLI: +list-colors plain output |
cli/list_colors.zig:59-72 |
4096-byte buffer, never flushed; piped output stops midway through PeachPuff3 (12,297 bytes). |
Flushed (15,434 bytes). | 6286e02 |
none; found by RV11 (review-rv11-codex.md) |
| 29 | CLI: +list-themes diagnostics |
cli/list_themes.zig:124-131,171 |
The messages ("Could not find the Ghostty resources directory", "No themes found...") go to a 4096-byte stderr buffer that is never flushed, so they are lost. | Flushed when the action ends. | 410d8fc fix-deviant(cli) |
zig_writer::tests::unflushed_buffer_is_flushed_on_drop |
| 30 | CLI: stdout as a regular file | std.fs.File.writer (Zig 0.15.2) as used in cli/show_face.zig:68-73, cli/help.zig:34, cli/validate_config.zig:43-46, cli/show_config.zig:84 |
The writer pwrites at its own offset starting at 0: with stdout redirected to a file, output overwrites itself and earlier file content, and Writer.end truncates. |
Writes sequentially at the inherited descriptor's current offset (no pwrite from offset 0, so earlier content and the writers' own output are not overwritten; this appends only when the descriptor is opened for append, e.g. >>); no truncation. |
410d8fc fix-deviant(cli) (append behaviour first in f76179f) |
zig_writer::tests::regular_file_output_is_complete_and_appended |
| 31 | GTK: +new-window D-Bus arguments |
apprt/gtk/ipc/new_window.zig:49-53 (Bytes.new(ptr, len + 1) + Variant.newFromBytes(..., trusted = true)) |
Arguments are passed as trusted GVariant strings without validation; invalid UTF-8 or an interior NUL makes GIO abort the process while serializing (input-triggered crash). | arguments_variant validates first and returns Error::IpcFailed (message on stderr, no D-Bus call); valid arguments are byte-identical to Zig. |
65dd9d5 fix-deviant(gtk) |
ipc::new_window::tests::invalid_dbus_strings_are_rejected_without_abort (ghostty-apprt-gtk) |
| 32 | OS: URL opener child reaping | os/open.zig (try collectOutput(...) before wait()) |
When collecting the opener's output fails (e.g. more than 50 KiB on a stream), the error returns before wait(), leaving the child as a zombie. |
The child is always reaped; the collection error is still logged. | 4a55abf fix-deviant(os) |
ghostty-os open tests |
Upstream quirks deliberately kept (not fixed)
These look odd but are not memory-safety, loop, corruption or crash bugs, so the port matches Ghostty.
shadertoy::Uniforms: the selection foreground/background fields are swapped relative to the GLSL block. Both arevec3, so offsets agree (ghostty-renderer/src/shadertoy.rs:878; R1 note).- Kitty storage:
delete rangeuses||(matches everything); eviction stops only whenevicted > req;set_limit(0)resets storage to defaults; screen reset also resetstotal_limit. lru::getdoes not change LRU order.Tabstops::unsetXORs.Screen/keybind docs:format_entry_docsemits a blank line only (the@tagNamelookup never matches).Search: an empty needle underflows exactly as in Zig, so the needle must be non-empty.- i18n: the
strlen == 7guard skips the tables for 8-byte locale names. - CSI:
CSI Kaccepts 0..2 only; ANSI DECRQM is unimplemented; unknownmany keycodemedia keys stay a TODO. - libghostty C header:
chostty_ipc_target_stypo kept;ghostty_inspector_metal_shutdownisboolin the header butvoidin Zig (we follow Zig). - Braille sprites:
debug_assert!where Zig asserts, so odd cell sizes (for example 14x23) trip it in builds with debug assertions, as in Zig. - Windows: no file locking in the ssh-cache
DiskCache(Zig does not lock on Windows either). - Config theme-name basename and numeric parsing follow Zig 0.15.2, not 0.16 (
fix(config)commits3068e3a,a57ff41).
Other deliberate deviations (platform/Rust-imposed or out of scope)
Only entries with an observable behavioural difference are listed.
| Area | Zig/Ghostty behaviour | Rust behaviour | Where | Commit |
|---|---|---|---|---|
| Swift macOS app | Ships macos/ |
Not ported; the Rust side provides the libghostty C API, Metal/CoreText code, XCFramework script. Only compile-checked (just check-macos), never run (no macOS here). |
ARCHITECTURE.md (Out of scope), notes/macos-M1*.md, final-Z4.md |
bab49ee, 75bf604, 231ee35 |
| Windows (and wasm natively) | Native targets | Windows is compile-checked only (just check-windows), never run. wasm is built and its Node smoke/ABI tests run (just check-wasm, scripts/wasm-check.sh), but not in a real browser. |
notes/windows-WIN.md, notes/wasm-W1.md |
76771be, d89691b |
| Crash reporting | Sentry with Breakpad on macOS, SIGSEGV handlers, local DB/cache, sessions, init thread |
In-process reporter: .ghosttycrash envelopes in $XDG_STATE_HOME/ghostty/crash, panic hook and signal handler, minidumps on macOS/Windows. No Sentry DB/cache dir, no sessions, no init thread; reports are written on any panic; the crash dir is resolved at init; the signal path writes no attachment. |
notes/crash-CR.md, ARCHITECTURE.md (Scope changes) |
b3145ad, 72bfac1 |
| Surface copy-on-select on release | setSelection returns early when the new selection equals the old one |
Left-release copies directly via copy_on_select, because the early return would never copy |
notes/ghostty-core.md |
ad76ae2 |
| Locking / threading | Renderer mutex held while sending mailbox messages and running mouse reports (.locked sends); process_links called unlocked |
Reports, IO messages and runtime actions are collected and run after the lock is released; process_links takes the lock itself; termio overflow goes to an ordered outbox |
notes/ghostty-core.md, notes/ghostty-termio-IO*.md |
6629fb0, ad76ae2 |
| Rendering of overlay / images | z2d overlay, wuffs image decoding, glslang+SPIR-V+spirv-cross | Direct RGBA overlay with approximate hairline placement; png/jpeg-decoder; naga GLSL front end (narrower than glslang, with a sampler2D helper rewrite) |
notes/ghostty-renderer-R1.md, R2.md, gaps-X2.md; just visual-parity reports 0 differing pixels for the tested scenes |
568ce78, 871c301, f68b11d |
| Oniguruma | Native regex library for links and tmux | Hand-written matchers for the tmux parser; link regexes use the minimal ghostty-onig FFI crate |
notes/ghostty-vt-W2.md |
b2b248e, 2aa2a12 |
| zlib / PNG / base64 / shm | zig std, wuffs, simdutf, mmap | flate2, png, base64 crate (ASCII whitespace ignored), rustix shm reads without mmap |
notes/ghostty-terminal-kitty.md, notes/ghostty-vt-W2.md |
d47408f, b2b248e |
| VT parser buffer | Fixed 2048-byte buffer with a reserved NUL | Growing Vec<u8> with a cap that models the reserved-NUL capacity; no observable difference is known (internal representation only) |
notes/ghostty-vt-W1.md |
b2b248e |
| CSI W logging | Logs a spurious "invalid cursor tabulation control" after CSI 2 W / CSI 5 W |
No log line (log-only) | notes/ghostty-vt-stream.md |
47b479d |
| Searching | CircBuf + overlap buffer, libxev thread |
VecDeque, crossbeam-channel, 24 ms refresh timer; identical results, assertions about buffer layout dropped |
notes/ghostty-terminal-search-tmux.md |
8e9be42 |
| Config strings | UTF-8 not required in many places | Escaped-bytes representation (EncodedOs) so non-UTF-8 survives; behaviour and error names matched to Zig (for example theme=/etc/passwd/x gives error.NotDir, checked against the oracle) |
notes/review-rv11-codex.md (item 3) |
672e8a4, 4bbe0d7 |
+version / build info |
Real build metadata | Placeholder build_info values in places; gtk-opengl-debug follows cfg!(debug_assertions) (the release profile has them on, production off) |
notes/ghostty-cli.md, notes/ghostty-config.md |
6286e02 |
| OSC 52 base64 acceptance | Surface.zig:2149,2163 rules | Was different in both directions (Codex core audit); fixed to match Zig | notes/review-core-codex.md |
2f58f02 |
Maintenance
When you fix a genuine upstream bug, commit it alone as
fix-deviant(<scope>): ... and add a row to the first table with the hash,
the Zig file:line and the test. Replace "uncommitted" entries with hashes once
the Z17 work is committed.