mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 08:01:45 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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, &.{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user