From fff9f62f38cfef56ada5239ea22555b35c1e063c Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 15 Aug 2026 10:48:08 -0400 Subject: [PATCH 1/2] cli: always install embedded SSH terminfo Remove the remote infocmp short-circuit from +ssh setup. The command already sends Ghostty's embedded terminfo source to tic, so accepting any existing entry leaves the payload unused and can preserve stale data. --- src/cli/ssh.zig | 11 ++++------- src/config/Config.zig | 7 +++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index f20188d91..b3044766f 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -128,9 +128,9 @@ pub const Options = struct { /// forwarding to succeed. /// /// 2. **Terminfo install** (`--terminfo`). On the first connection to a -/// given destination, installs Ghostty's terminfo entry on the remote -/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a -/// shared `ControlMaster` connection. Successful installs are cached +/// given destination, installs Ghostty's embedded terminfo entry on the +/// remote host using `ssh tic -x -` over a shared `ControlMaster` +/// connection. Successful installs are cached /// (see `ghostty +ssh-cache`) so subsequent connections skip this /// step. When terminfo is successfully installed or already cached, /// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`. @@ -491,20 +491,17 @@ fn installRemoteTerminfo( // the most common failure source) and inherit ssh's stderr so it // reaches the user's terminal. Other steps stay quiet either way. const remote_script = if (opts.verbose) - \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 \\exit 1 else - \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 \\exit 1 ; // Set up an SSH ControlMaster scoped to this single install: - // - ControlMaster=yes makes our client also act as the master, - // so `infocmp | ssh tic` runs over a single connection. + // - ControlMaster=yes makes our client also act as the master. // - ControlPersist=no tears the master down when our client // exits; no socket lingers on the remote side. const argv = try std.mem.concat(alloc, []const u8, &.{ diff --git a/src/config/Config.zig b/src/config/Config.zig index 814532b5d..993a4f6e1 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2931,10 +2931,9 @@ keybind: Keybinds = .{}, /// (Available since: 1.2.0) /// /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. -/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when -/// connecting to hosts that lack it. Requires `infocmp` to be available locally -/// and `tic` to be available on remote hosts. Once terminfo is installed on a -/// remote host, it will be automatically "cached" to avoid repeat installations. +/// Attempts to install Ghostty's embedded terminfo entry using `tic` on local +/// cache misses. Requires `tic` to be available on remote hosts. Successful +/// installations are cached locally to avoid repeat installations. /// If desired, the `+ssh-cache` CLI action can be used to manage the installation /// cache manually using various arguments. /// (Available since: 1.2.0) From 69b9abf09ebad2b11a6850a28271676f6bfeb108 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Sat, 15 Aug 2026 11:04:31 -0400 Subject: [PATCH 2/2] cli: version SSH terminfo cache entries Derive a version from the encoded Ghostty terminfo and require callers to pass it explicitly when reading or writing the SSH cache. Cache entries created for older or different payloads no longer suppress a required installation. --- src/cli/ssh-cache/DiskCache.zig | 51 ++++++++++++++++++++------------- src/cli/ssh.zig | 20 ++++++++----- src/cli/ssh_cache.zig | 2 ++ src/terminfo/ghostty.zig | 9 ++++++ src/terminfo/main.zig | 1 + 5 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/cli/ssh-cache/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig index bc5619d71..ea64dfe83 100644 --- a/src/cli/ssh-cache/DiskCache.zig +++ b/src/cli/ssh-cache/DiskCache.zig @@ -58,6 +58,7 @@ pub fn add( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, timestamp: i64, ) !void { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -111,16 +112,19 @@ pub fn add( // `deinitEntries` defer to walk. if (entries.getPtr(key)) |existing| { existing.timestamp = timestamp; + const version_copy = try alloc.dupe(u8, version); + alloc.free(existing.terminfo_version); + existing.terminfo_version = version_copy; } else { const key_copy = try alloc.dupe(u8, key); errdefer alloc.free(key_copy); - const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); - errdefer alloc.free(terminfo_copy); + const version_copy = try alloc.dupe(u8, version); + errdefer alloc.free(version_copy); try entries.put(key_copy, .{ .hostname = key_copy, .timestamp = timestamp, - .terminfo_version = terminfo_copy, + .terminfo_version = version_copy, }); } @@ -225,12 +229,13 @@ pub fn prune( return expired.items.len; } -/// Check if a key exists in the cache. -/// Returns false if the cache file doesn't exist. +/// Check if a key with `version` exists in the cache. +/// Returns false if the cache file doesn't exist or the version doesn't match. pub fn contains( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, ) !bool { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -249,7 +254,8 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(key); + const entry = entries.get(key) orelse return false; + return std.mem.eql(u8, entry.terminfo_version, version); } fn fixupPermissions(file: std.Io.File) !void { @@ -506,20 +512,25 @@ test "disk cache operations" { // Setup our cache. Adding the same key twice exercises both the new // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try testing.expect(try cache.contains(alloc, "example.com")); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!try cache.contains(alloc, "example.com", "v2")); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(try cache.contains(alloc, "example.com", "v2")); // List var entries = try cache.list(alloc); - deinitEntries(alloc, &entries); + defer deinitEntries(alloc, &entries); + try testing.expectEqualStrings( + "v2", + entries.get("example.com").?.terminfo_version, + ); // Remove reports that it removed the entry, and a second remove of the // same key reports nothing to remove. try testing.expect(try cache.remove(alloc, "example.com")); try testing.expect(!try cache.remove(alloc, "example.com")); - try testing.expect(!(try cache.contains(alloc, "example.com"))); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!(try cache.contains(alloc, "example.com", "v2"))); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); } test "disk cache cleans up temp files" { @@ -535,8 +546,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.org", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.org", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -566,20 +577,20 @@ test "disk cache prune" { const day = std.time.s_per_day; const hour = std.time.s_per_hour; const now = std.Io.Timestamp.now(testing.io, .real).toSeconds(); - try cache.add(alloc, "recent.com", now - hour); - try cache.add(alloc, "old.com", now - 100 * day); + try cache.add(alloc, "recent.com", "v1", now - hour); + try cache.add(alloc, "old.com", "v1", now - 100 * day); // Prune entries older than 90 days: only old.com goes. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); - try testing.expect(try cache.contains(alloc, "recent.com")); - try testing.expect(!try cache.contains(alloc, "old.com")); + try testing.expect(try cache.contains(alloc, "recent.com", "v1")); + try testing.expect(!try cache.contains(alloc, "old.com", "v1")); // Pruning again removes nothing. try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); // Sub-day granularity: a 30-minute max age prunes the hour-old entry. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); - try testing.expect(!try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "recent.com", "v1")); } test "disk cache prune missing file" { @@ -707,7 +718,7 @@ test "disk cache add survives allocation failure" { ); const alloc = failing.allocator(); - if (cache.add(alloc, "user@example.com", 100)) |_| { + if (cache.add(alloc, "user@example.com", "v1", 100)) |_| { if (!failing.has_induced_failure) break; } else |err| { try testing.expectEqual(error.OutOfMemory, err); diff --git a/src/cli/ssh.zig b/src/cli/ssh.zig index b3044766f..ed4a79755 100644 --- a/src/cli/ssh.zig +++ b/src/cli/ssh.zig @@ -7,7 +7,7 @@ const diagnostics = @import("diagnostics.zig"); const Action = @import("ghostty.zig").Action; const DiskCache = @import("ssh_cache.zig").DiskCache; const internal_os = @import("../os/main.zig"); -const ghostty_terminfo = @import("../terminfo/main.zig").ghostty; +const terminfopkg = @import("../terminfo/main.zig"); const global = @import("../global.zig"); const log = std.log.scoped(.ssh); @@ -252,7 +252,11 @@ fn runInner( } else null; if (cache) |c| { - const cached = c.contains(alloc, dest) catch |err| cached: { + const cached = c.contains( + alloc, + dest, + terminfopkg.version, + ) catch |err| cached: { if (DiskCache.isFailure(err)) warnPrint( stderr, "unable to read the cache '{s}': {t}", @@ -313,10 +317,12 @@ fn runInner( // Attempt to cache (if needed) on a successful ssh execution. if (exit_code == 0) if (session.to_cache) |entry| { - if (entry.cache.add(alloc, entry.dest, std.Io.Timestamp.now( - global.io(), - .real, - ).toSeconds())) |_| { + if (entry.cache.add( + alloc, + entry.dest, + terminfopkg.version, + std.Io.Timestamp.now(global.io(), .real).toSeconds(), + )) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { if (DiskCache.isFailure(err)) { @@ -473,7 +479,7 @@ fn installRemoteTerminfo( ) !void { var buf: std.Io.Writer.Allocating = .init(alloc); defer buf.deinit(); - try ghostty_terminfo.encode(&buf.writer); + try terminfopkg.ghostty.encode(&buf.writer); const terminfo = buf.written(); // ControlPath is in TMPDIR with a short, random basename. ssh uses diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index f509f69d4..4142e1600 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -5,6 +5,7 @@ const args = @import("args.zig"); const global = @import("../global.zig"); const Action = @import("ghostty.zig").Action; const Duration = @import("../config.zig").Config.Duration; +const terminfopkg = @import("../terminfo/main.zig"); pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -198,6 +199,7 @@ pub fn runInner( cache.add( alloc, dest, + terminfopkg.version, std.Io.Timestamp.now(global.io(), .real).toSeconds(), ) catch |err| switch (err) { error.InvalidCacheKey => { diff --git a/src/terminfo/ghostty.zig b/src/terminfo/ghostty.zig index 2b7434cbf..ea64ffcf3 100644 --- a/src/terminfo/ghostty.zig +++ b/src/terminfo/ghostty.zig @@ -392,6 +392,15 @@ pub const ghostty: Source = .{ }, }; +/// A content-derived version of the encoded terminfo source. +pub const version = version: { + @setEvalBranchQuota(100_000); + var hashing: std.Io.Writer.Hashing(std.hash.Wyhash) = + .initHasher(.init(0), &.{}); + ghostty.encode(&hashing.writer) catch unreachable; + break :version std.fmt.comptimePrint("{x}", .{hashing.hasher.final()}); +}; + test "encode" { // Encode var buf: [1024 * 16]u8 = undefined; diff --git a/src/terminfo/main.zig b/src/terminfo/main.zig index c76c328cc..f6c709b91 100644 --- a/src/terminfo/main.zig +++ b/src/terminfo/main.zig @@ -6,6 +6,7 @@ //! extract this into a more full-featured library on its own. pub const ghostty = @import("ghostty.zig").ghostty; +pub const version = @import("ghostty.zig").version; pub const Source = @import("Source.zig"); test {