Commit a0fc894

Chance Zibolski  ·  2026-08-04 23:57:14 -0400 EDT
parent 00897d8
fix(attach): drop stray NUL from replayed OSC 7

The formatter's `extra.pwd` writes `terminal.pwd.items`, and
`Terminal.setPwd` appends a NUL sentinel to that buffer, so every OSC 7
we replayed carried a stray \x00 before the terminator:

    \x1b]7;file://HOST/private/tmp\x00\x1b\\

Terminals that only apply the value tolerate it. One that records what
it receives persists the NUL and then fails to parse its own state back,
which is what left kitty unable to start.

Emit OSC 7 from `getPwd()` instead, which returns the same bytes without
the sentinel. `serializeTerminal` had the same bug, so `zmx history --vt`
was emitting it too.

Refs #222
2 files changed,  +92, -2
+1, -0
......@@ -13,6 +13,7 @@ Use spec: https://common-changelog.org/
1313 ### Fixed
1414
1515 - Clear screen when switching sessions to prevent term state corruption
16+- Stray NUL byte in the OSC 7 sequence replayed on attach
1617
1718 ### Changed
1819
+91, -2
......@@ -638,6 +638,24 @@ pub fn isUserInput(payload: []const u8) bool {
638638 return false;
639639 }
640640
641+/// Emit the terminal's pwd as OSC 7.
642+///
643+/// This replaces the formatter's own `extra.pwd`, which writes
644+/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
645+/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
646+/// terminator. `getPwd()` returns the same bytes without the sentinel.
647+///
648+/// The NUL is not cosmetic: a client that records what it receives (kitty
649+/// writing its session file, for one) persists the NUL and then fails to parse
650+/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
651+fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
652+ const pwd = term.getPwd() orelse return;
653+ if (pwd.len == 0) return;
654+ writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
655+ std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
656+ };
657+}
658+
641659 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
642660 var builder: std.Io.Writer.Allocating = .init(alloc);
643661 defer builder.deinit();
......@@ -723,7 +741,7 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
723741 .modes = true,
724742 .scrolling_region = true,
725743 .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
726- .pwd = true,
744+ .pwd = false, // emitted below without the sentinel the formatter includes
727745 .keyboard = true,
728746 .screen = .all,
729747 };
......@@ -733,6 +751,8 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
733751 return null;
734752 };
735753
754+ writePwd(&builder.writer, term);
755+
736756 // The formatter has no title extra and never emits OSC 0/1/2, so the title
737757 // has to be replayed separately or an attaching client shows whatever its
738758 // terminal defaults to, usually the client process name. OSC 2 does not
......@@ -785,7 +805,7 @@ pub fn serializeTerminal(
785805 .modes = true,
786806 .scrolling_region = true,
787807 .tabstops = false,
788- .pwd = true,
808+ .pwd = false, // emitted below without the sentinel the formatter includes
789809 .keyboard = true,
790810 .screen = .all,
791811 },
......@@ -797,6 +817,8 @@ pub fn serializeTerminal(
797817 return null;
798818 };
799819
820+ if (format == .vt) writePwd(&builder.writer, term);
821+
800822 const output = builder.writer.buffered();
801823 if (output.len == 0) return null;
802824
......@@ -1284,6 +1306,73 @@ test "serializeTerminalState omits the title when none is set" {
12841306 try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
12851307 }
12861308
1309+test "serializeTerminalState replays the pwd without a NUL sentinel" {
1310+ const alloc = testing.allocator;
1311+ const io = testing.io;
1312+
1313+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
1314+ .cols = 80,
1315+ .rows = 24,
1316+ });
1317+ defer term.deinit(alloc);
1318+
1319+ var stream = term.vtStream();
1320+ defer stream.deinit();
1321+
1322+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1323+ stream.nextSlice("hello");
1324+
1325+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1326+ defer alloc.free(output);
1327+
1328+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1329+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1330+}
1331+
1332+test "serializeTerminalState omits the pwd when none is set" {
1333+ const alloc = testing.allocator;
1334+ const io = testing.io;
1335+
1336+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
1337+ .cols = 80,
1338+ .rows = 24,
1339+ });
1340+ defer term.deinit(alloc);
1341+
1342+ var stream = term.vtStream();
1343+ defer stream.deinit();
1344+
1345+ stream.nextSlice("hello");
1346+
1347+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1348+ defer alloc.free(output);
1349+
1350+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
1351+}
1352+
1353+test "serializeTerminal vt replays the pwd without a NUL sentinel" {
1354+ const alloc = testing.allocator;
1355+ const io = testing.io;
1356+
1357+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
1358+ .cols = 80,
1359+ .rows = 24,
1360+ });
1361+ defer term.deinit(alloc);
1362+
1363+ var stream = term.vtStream();
1364+ defer stream.deinit();
1365+
1366+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1367+ stream.nextSlice("hello");
1368+
1369+ const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
1370+ defer alloc.free(output);
1371+
1372+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1373+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1374+}
1375+
12871376 fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
12881377 var term = try ghostty_vt.Terminal.init(io, alloc, .{
12891378 .cols = cols,