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
1@@ -13,6 +13,7 @@ Use spec: https://common-changelog.org/
2 ### Fixed
3
4 - Clear screen when switching sessions to prevent term state corruption
5+- Stray NUL byte in the OSC 7 sequence replayed on attach
6
7 ### Changed
8
+91,
-2
1@@ -638,6 +638,24 @@ pub fn isUserInput(payload: []const u8) bool {
2 return false;
3 }
4
5+/// Emit the terminal's pwd as OSC 7.
6+///
7+/// This replaces the formatter's own `extra.pwd`, which writes
8+/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
9+/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
10+/// terminator. `getPwd()` returns the same bytes without the sentinel.
11+///
12+/// The NUL is not cosmetic: a client that records what it receives (kitty
13+/// writing its session file, for one) persists the NUL and then fails to parse
14+/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
15+fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
16+ const pwd = term.getPwd() orelse return;
17+ if (pwd.len == 0) return;
18+ writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
19+ std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
20+ };
21+}
22+
23 pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
24 var builder: std.Io.Writer.Allocating = .init(alloc);
25 defer builder.deinit();
26@@ -723,7 +741,7 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
27 .modes = true,
28 .scrolling_region = true,
29 .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
30- .pwd = true,
31+ .pwd = false, // emitted below without the sentinel the formatter includes
32 .keyboard = true,
33 .screen = .all,
34 };
35@@ -733,6 +751,8 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin
36 return null;
37 };
38
39+ writePwd(&builder.writer, term);
40+
41 // The formatter has no title extra and never emits OSC 0/1/2, so the title
42 // has to be replayed separately or an attaching client shows whatever its
43 // terminal defaults to, usually the client process name. OSC 2 does not
44@@ -785,7 +805,7 @@ pub fn serializeTerminal(
45 .modes = true,
46 .scrolling_region = true,
47 .tabstops = false,
48- .pwd = true,
49+ .pwd = false, // emitted below without the sentinel the formatter includes
50 .keyboard = true,
51 .screen = .all,
52 },
53@@ -797,6 +817,8 @@ pub fn serializeTerminal(
54 return null;
55 };
56
57+ if (format == .vt) writePwd(&builder.writer, term);
58+
59 const output = builder.writer.buffered();
60 if (output.len == 0) return null;
61
62@@ -1284,6 +1306,73 @@ test "serializeTerminalState omits the title when none is set" {
63 try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
64 }
65
66+test "serializeTerminalState replays the pwd without a NUL sentinel" {
67+ const alloc = testing.allocator;
68+ const io = testing.io;
69+
70+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
71+ .cols = 80,
72+ .rows = 24,
73+ });
74+ defer term.deinit(alloc);
75+
76+ var stream = term.vtStream();
77+ defer stream.deinit();
78+
79+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
80+ stream.nextSlice("hello");
81+
82+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
83+ defer alloc.free(output);
84+
85+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
86+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
87+}
88+
89+test "serializeTerminalState omits the pwd when none is set" {
90+ const alloc = testing.allocator;
91+ const io = testing.io;
92+
93+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
94+ .cols = 80,
95+ .rows = 24,
96+ });
97+ defer term.deinit(alloc);
98+
99+ var stream = term.vtStream();
100+ defer stream.deinit();
101+
102+ stream.nextSlice("hello");
103+
104+ const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
105+ defer alloc.free(output);
106+
107+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
108+}
109+
110+test "serializeTerminal vt replays the pwd without a NUL sentinel" {
111+ const alloc = testing.allocator;
112+ const io = testing.io;
113+
114+ var term = try ghostty_vt.Terminal.init(io, alloc, .{
115+ .cols = 80,
116+ .rows = 24,
117+ });
118+ defer term.deinit(alloc);
119+
120+ var stream = term.vtStream();
121+ defer stream.deinit();
122+
123+ stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
124+ stream.nextSlice("hello");
125+
126+ const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
127+ defer alloc.free(output);
128+
129+ try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
130+ try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
131+}
132+
133 fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
134 var term = try ghostty_vt.Terminal.init(io, alloc, .{
135 .cols = cols,