cli: keep cached SSH terminfo installs current (#13844)

On a local cache miss, always send our embedded terminfo source to the
remote `tic` instead of accepting any existing entry reported by
`infocmp`.

We also version cache entries using a content-derived hash of our
embedded terminfo. Non-matching entries produce a cache miss and trigger
(re)installation.
This commit is contained in:
Mitchell Hashimoto
2026-08-15 14:24:37 -07:00
committed by GitHub
6 changed files with 63 additions and 38 deletions

View File

@@ -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);

View File

@@ -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);
@@ -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`.
@@ -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
@@ -491,20 +497,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, &.{

View File

@@ -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 => {

View File

@@ -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)

View File

@@ -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;

View File

@@ -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 {