Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ typedef struct {
ghostty_surface_io_mode_e io_mode;
ghostty_io_write_cb io_write_cb;
void* io_write_userdata;
const char* zmx_session;
bool zmx_create;
bool zmx_mode;
} ghostty_surface_config_s;

typedef struct {
Expand Down
135 changes: 88 additions & 47 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -629,40 +629,46 @@ pub fn init(
// This separate block ({}) is important because our errdefers must
// be scoped here to be valid.
{
var env = rt_surface.defaultTermioEnv() catch |err| env: {
// If an error occurs, we don't want to block surface startup.
log.warn("error getting env map for surface err={}", .{err});
break :env internal_os.getEnvMap(alloc) catch
std.process.EnvMap.init(alloc);
};
errdefer env.deinit();

// don't leak GHOSTTY_LOG to any subprocesses
env.remove("GHOSTTY_LOG");
// Determine the IO backend: zmx > manual > exec
// Try zmx first (if configured and binary is available)
var zmx_backend: ?termio.Zmx = null;
if (config.@"zmx-session") |session| {
zmx_backend = termio.Zmx.init(alloc, .{
.session_name = session,
.create_if_missing = config.@"zmx-create",
.working_directory = config.@"working-directory",
}) catch |err| switch (err) {
error.ZmxNotFound => blk: {
log.warn("zmx binary not found, falling back to exec", .{});
break :blk null;
},
else => return err,
};
}

// Initialize our IO backend
if (use_manual_io) {
var io_backend: termio.Backend = if (zmx_backend) |zmx|
.{ .zmx = zmx }
else if (use_manual_io) manual_backend: {
var io_manual = try termio.Manual.init(alloc, .{
.write_cb = manual_write_cb,
.write_userdata = manual_write_userdata,
});
errdefer io_manual.deinit();

var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);

try termio.Termio.init(&self.io, alloc, .{
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
.backend = .{ .manual = io_manual },
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
.renderer_mailbox = render_thread.mailbox,
.surface_mailbox = .{ .surface = self, .app = app_mailbox },
});
} else {
_ = &io_manual;
break :manual_backend .{ .manual = io_manual };
} else exec_backend: {
var env = rt_surface.defaultTermioEnv() catch |err| env: {
log.warn("error getting env map for surface err={}", .{err});
break :env internal_os.getEnvMap(alloc) catch
std.process.EnvMap.init(alloc);
};
errdefer env.deinit();

env.remove("GHOSTTY_LOG");

// Don't leak parent zmx session into child terminals.
// Each terminal gets its own zmx session via the zmx backend config.
env.remove("ZMX_SESSION");

var io_exec = try termio.Exec.init(alloc, .{
.command = command,
.env = env,
Expand All @@ -676,24 +682,28 @@ pub fn init(
.rt_pre_exec_info = .init(config),
.rt_post_fork_info = .init(config),
});
errdefer io_exec.deinit();

// Initialize our IO mailbox
var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);

try termio.Termio.init(&self.io, alloc, .{
.size = size,
.full_config = config,
.config = try termio.Termio.DerivedConfig.init(alloc, config),
.backend = .{ .exec = io_exec },
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
.renderer_mailbox = render_thread.mailbox,
.surface_mailbox = .{ .surface = self, .app = app_mailbox },
});
}
_ = &io_exec;
break :exec_backend .{ .exec = io_exec };
};
errdefer io_backend.deinit();

var io_mailbox = try termio.Mailbox.initSPSC(alloc);
errdefer io_mailbox.deinit(alloc);

var io_config = try termio.Termio.DerivedConfig.init(alloc, config);
errdefer io_config.deinit();

try termio.Termio.init(&self.io, alloc, .{
.size = size,
.full_config = config,
.config = io_config,
.backend = io_backend,
.mailbox = io_mailbox,
.renderer_state = &self.renderer_state,
.renderer_wakeup = render_thread.wakeup,
.renderer_mailbox = render_thread.mailbox,
.surface_mailbox = .{ .surface = self, .app = app_mailbox },
});
}
// Outside the block, IO has now taken ownership of our temporary state
// so we can just defer this and not the subcomponents.
Expand Down Expand Up @@ -1092,6 +1102,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.close => self.close(),

.child_exited => |v| self.childExited(v),
.child_disconnected => |v| self.childDisconnected(v),

.desktop_notification => |notification| {
if (!self.config.desktop_notifications) {
Expand Down Expand Up @@ -1311,6 +1322,35 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void {
self.close();
}

fn childDisconnected(self: *Surface, info: apprt.surface.Message.ChildExited) void {
self.child_exited = true;

log.warn("persistent backend disconnected unexpectedly", .{});

if (self.rt_app.performAction(
.{ .surface = self },
.show_child_exited,
info,
) catch |err| gui: {
log.err("error trying to show native child disconnected GUI err={}", .{err});
break :gui false;
}) return;

switch (self.io.backend) {
.zmx => |*zmx| {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const t: *terminal.Terminal = self.renderer_state.terminal;
zmx.childExitedAbnormally(self.alloc, t, info.exit_code, info.runtime_ms) catch |err| {
log.err("error handling zmx backend disconnect err={}", .{err});
};
},
else => self.childExitedAbnormally(info) catch |err| {
log.err("error handling backend disconnect err={}", .{err});
},
}
}

/// Called when the child process exited abnormally.
fn childExitedAbnormally(
self: *Surface,
Expand All @@ -1324,6 +1364,7 @@ fn childExitedAbnormally(
const command = switch (self.io.backend) {
.exec => |*exec| try std.mem.join(alloc, " ", exec.subprocess.args),
.manual => "manual backend",
.zmx => |*zmx| try std.fmt.allocPrint(alloc, "zmx session: {s}", .{zmx.session_name}),
};
const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms});

Expand Down
41 changes: 41 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,18 @@ pub const Surface = struct {

/// Userdata passed to io_write_cb.
io_write_userdata: ?*anyopaque = null,

/// zmx session name. When non-null, the surface connects to a zmx
/// daemon session instead of spawning a new shell process.
zmx_session: ?[*:0]const u8 = null,

/// Whether to create the zmx session if it doesn't exist.
zmx_create: bool = true,

/// Whether this surface should use zmx mode. When true and
/// zmx_session is null, a session name is auto-generated.
/// This is how zmx mode propagates across splits/tabs.
zmx_mode: bool = false,
Comment on lines +482 to +492
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify the empty-string contract in Surface.Options.

init treats zmx_session="" as "clear/unset", and zmx_mode=true will still auto-generate a session from that state. The field docs still describe any non-null zmx_session as a direct attach and only mention auto-generation for null, so the public API contract is now a little misleading.

📝 Suggested doc update
-        /// zmx session name. When non-null, the surface connects to a zmx
-        /// daemon session instead of spawning a new shell process.
+        /// zmx session name. A non-empty value attaches to that zmx daemon
+        /// session instead of spawning a new shell process. An explicit empty
+        /// string clears any inherited session.

-        /// Whether this surface should use zmx mode. When true and
-        /// zmx_session is null, a session name is auto-generated.
+        /// Whether this surface should use zmx mode. When true and
+        /// zmx_session is null or empty, a session name is auto-generated.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// zmx session name. When non-null, the surface connects to a zmx
/// daemon session instead of spawning a new shell process.
zmx_session: ?[*:0]const u8 = null,
/// Whether to create the zmx session if it doesn't exist.
zmx_create: bool = true,
/// Whether this surface should use zmx mode. When true and
/// zmx_session is null, a session name is auto-generated.
/// This is how zmx mode propagates across splits/tabs.
zmx_mode: bool = false,
/// zmx session name. A non-empty value attaches to that zmx daemon
/// session instead of spawning a new shell process. An explicit empty
/// string clears any inherited session.
zmx_session: ?[*:0]const u8 = null,
/// Whether to create the zmx session if it doesn't exist.
zmx_create: bool = true,
/// Whether this surface should use zmx mode. When true and
/// zmx_session is null or empty, a session name is auto-generated.
/// This is how zmx mode propagates across splits/tabs.
zmx_mode: bool = false,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/apprt/embedded.zig` around lines 482 - 492, The Surface.Options docs are
misleading because init treats an empty string ("") for zmx_session as "unset"
(like null) and zmx_mode=true will auto-generate a session in that case; update
the comment on zmx_session/zmx_mode (and optionally zmx_create) to explicitly
state that an empty string is treated as unset and that when zmx_mode is true
and zmx_session is null or the empty string, a session name will be
auto-generated (whereas a non-empty string attaches directly to that named
session); reference Surface.Options, the zmx_session, zmx_mode and zmx_create
fields and the init behavior in the text so callers understand the empty-string
contract.

};

pub fn init(self: *Surface, app: *App, opts: Options) !void {
Expand Down Expand Up @@ -586,6 +598,29 @@ pub const Surface = struct {
config.@"wait-after-command" = true;
}

// zmx mode: explicit session name takes priority, then zmx_mode flag.
// An explicit empty string clears any inherited session before deciding
// whether to auto-generate one from zmx_mode.
const zmx_session = if (opts.zmx_session) |c_session| blk: {
const session = std.mem.sliceTo(c_session, 0);
break :blk if (session.len > 0) session else null;
} else null;
if (opts.zmx_session != null and zmx_session == null) {
config.@"zmx-session" = null;
}

if (zmx_session) |session| {
config.@"zmx-session" = session;
config.@"zmx-create" = opts.zmx_create;
} else if (opts.zmx_mode) {
// Inherited zmx mode — auto-generate a unique session name
const zmx_alloc = config.arenaAlloc();
const uuid = std.crypto.random.int(u128);
const session = try std.fmt.allocPrint(zmx_alloc, "cmux-{x}", .{uuid});
config.@"zmx-session" = session;
config.@"zmx-create" = true;
}

// Initialize our surface right away. We're given a view that is
// ready to use.
try self.core_surface.init(
Expand Down Expand Up @@ -969,13 +1004,19 @@ pub const Surface = struct {
break :wd self.app.core_app.alloc.dupeZ(u8, cwd) catch null;
};

// Inherit zmx mode: if this surface uses zmx, new surfaces should too.
// Each new surface gets its own fresh zmx session (name auto-generated
// in Surface.init when zmx_mode=true and zmx_session=null).
const zmx_mode: bool = self.core_surface.io.backend == .zmx;

return .{
.font_size = font_size,
.working_directory = working_directory,
.context = context,
.io_mode = self.io_mode,
.io_write_cb = self.io_write_cb,
.io_write_userdata = self.io_write_userdata,
.zmx_mode = zmx_mode,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/apprt/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ pub const Message = union(enum) {
/// command are given in the `ChildExited` struct.
child_exited: ChildExited,

/// A persistent backend disconnected unexpectedly. This is always treated
/// as an abnormal failure regardless of runtime.
child_disconnected: ChildExited,

/// Show a desktop notification.
desktop_notification: struct {
/// Desktop notification title.
Expand Down
44 changes: 44 additions & 0 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,16 @@ class: ?[:0]const u8 = null,
/// * `inherit` - The working directory of the launching process.
@"working-directory": ?[]const u8 = null,

/// Connect to a zmx daemon session instead of spawning a new shell process.
/// When set, the surface connects to the named zmx session over a Unix
/// domain socket. The zmx daemon owns the PTY and persists independently
/// of the surface, enabling session persistence across restarts.
@"zmx-session": ?[]const u8 = null,
Comment on lines +1537 to +1541
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the exec fallback here.

zmx-session is not an unconditional switch away from spawning a shell: the PR behavior falls back to exec with a warning when zmx is unavailable. Since these comments feed the generated manual, the current wording overpromises behavior.

Suggested wording
-/// Connect to a zmx daemon session instead of spawning a new shell process.
-/// When set, the surface connects to the named zmx session over a Unix
+/// Prefer connecting to a zmx daemon session instead of spawning a new shell
+/// process.
+/// When set, the surface connects to the named zmx session over a Unix
 /// domain socket. The zmx daemon owns the PTY and persists independently
 /// of the surface, enabling session persistence across restarts.
+/// If zmx is unavailable, Ghostty falls back to the exec backend and logs
+/// a warning.
 @"zmx-session": ?[]const u8 = null,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Connect to a zmx daemon session instead of spawning a new shell process.
/// When set, the surface connects to the named zmx session over a Unix
/// domain socket. The zmx daemon owns the PTY and persists independently
/// of the surface, enabling session persistence across restarts.
@"zmx-session": ?[]const u8 = null,
/// Prefer connecting to a zmx daemon session instead of spawning a new shell
/// process.
/// When set, the surface connects to the named zmx session over a Unix
/// domain socket. The zmx daemon owns the PTY and persists independently
/// of the surface, enabling session persistence across restarts.
/// If zmx is unavailable, Ghostty falls back to the exec backend and logs
/// a warning.
@"zmx-session": ?[]const u8 = null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/Config.zig` around lines 1537 - 1541, Update the doc comment for
the configuration field @"zmx-session" (the doc block immediately above the
declaration @"zmx-session": ?[]const u8 = null) to clarify that this is not an
unconditional switch away from spawning a shell: retain the existing description
of connecting to a zmx daemon and session persistence, and add a sentence
stating that if the named zmx session or daemon is unavailable the code will
fall back to using exec (spawning a local shell) and emit a warning to the user;
ensure the wording is concise and suitable for generated manual output.


/// Whether to create the zmx session if it doesn't already exist.
/// Only applies when `zmx-session` is set. Default: true.
@"zmx-create": bool = true,

/// Key bindings. The format is `trigger=action`. Duplicate triggers will
/// overwrite previously set values. The list of actions is available in
/// the documentation or using the `ghostty +list-actions` command.
Expand Down Expand Up @@ -10597,6 +10607,40 @@ test "compatibility: gtk-single-instance desktop" {
}
}

test "parse zmx config defaults and override" {
const testing = std.testing;
const alloc = testing.allocator;

{
var cfg = try Config.default(alloc);
defer cfg.deinit();

var it: TestIterator = .{ .data = &.{
"--zmx-session=session-1",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();

try testing.expectEqualStrings("session-1", cfg.@"zmx-session".?);
try testing.expect(cfg.@"zmx-create");
}

{
var cfg = try Config.default(alloc);
defer cfg.deinit();

var it: TestIterator = .{ .data = &.{
"--zmx-session=session-1",
"--zmx-create=false",
} };
try cfg.loadIter(alloc, &it);
try cfg.finalize();

try testing.expectEqualStrings("session-1", cfg.@"zmx-session".?);
try testing.expect(!cfg.@"zmx-create");
}
}

test "compatibility: removed cursor-invert-fg-bg" {
const testing = std.testing;
const alloc = testing.allocator;
Expand Down
1 change: 1 addition & 0 deletions src/termio.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub const Exec = @import("termio/Exec.zig");
pub const Manual = manual.Manual;
pub const ManualConfig = manual.Config;
pub const ManualThreadData = manual.ThreadData;
pub const Zmx = @import("termio/Zmx.zig");
pub const Options = @import("termio/Options.zig");
pub const Termio = @import("termio/Termio.zig");
pub const Thread = @import("termio/Thread.zig");
Expand Down
Loading