Intentional deviation from Ghostty 1.3.1 (DEVIANT_FIXES.md row 32). Zig's os/open.zig runs `try collectOutput(...)` before `wait()`: when collection fails (e.g. a stream exceeds the 50 KiB limit), the error returns early and the child is never waited for, leaving a zombie process for the lifetime of Ghostty. The Rust opener thread now always waits for the child (the pipes are already closed at that point) and still logs the collection error. Test Plan: - cargo test -p ghostty-os open
106 lines
18 KiB
Markdown
106 lines
18 KiB
Markdown
# 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 `pwrite`s 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. | uncommitted (Z18) | `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. | (this register row's commit, 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 are `vec3`, so offsets agree (`ghostty-renderer/src/shadertoy.rs:878`; R1 note).
|
|
- Kitty storage: `delete range` uses `||` (matches everything); eviction stops only when `evicted > req`; `set_limit(0)` resets storage to defaults; screen reset also resets `total_limit`.
|
|
- `lru::get` does not change LRU order. `Tabstops::unset` XORs.
|
|
- `Screen`/keybind docs: `format_entry_docs` emits a blank line only (the `@tagName` lookup never matches).
|
|
- `Search`: an empty needle underflows exactly as in Zig, so the needle must be non-empty.
|
|
- i18n: the `strlen == 7` guard skips the tables for 8-byte locale names.
|
|
- CSI: `CSI K` accepts 0..2 only; ANSI DECRQM is unimplemented; unknown `many keycode` media keys stay a TODO.
|
|
- libghostty C header: `chostty_ipc_target_s` typo kept; `ghostty_inspector_metal_shutdown` is `bool` in the header but `void` in 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)` commits 3068e3a, 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.
|