Merge pull request #1946 from ghostty-org/tmuxcc

Tmux Control Mode Parser (ONLY the parser)
This commit is contained in:
Mitchell Hashimoto
2024-07-12 14:50:09 -07:00
committed by GitHub
5 changed files with 603 additions and 54 deletions

View File

@ -800,7 +800,31 @@ test "csi: too many params" {
}
}
test "dcs" {
test "dcs: XTGETTCAP" {
var p = init();
_ = p.next(0x1B);
for ("P+") |c| {
const a = p.next(c);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
}
{
const a = p.next('q');
try testing.expect(p.state == .dcs_passthrough);
try testing.expect(a[0] == null);
try testing.expect(a[1] == null);
try testing.expect(a[2].? == .dcs_hook);
const hook = a[2].?.dcs_hook;
try testing.expectEqualSlices(u8, &[_]u8{'+'}, hook.intermediates);
try testing.expectEqualSlices(u16, &[_]u16{}, hook.params);
try testing.expectEqual('q', hook.final);
}
}
test "dcs: params" {
var p = init();
_ = p.next(0x1B);
for ("P1000") |c| {

View File

@ -21,33 +21,67 @@ pub const Handler = struct {
self.discard();
}
pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void {
pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) ?Command {
assert(self.state == .inactive);
self.state = if (tryHook(alloc, dcs)) |state_| state: {
if (state_) |state| break :state state else {
log.info("unknown DCS hook: {}", .{dcs});
break :state .{ .ignore = {} };
}
} else |err| state: {
log.info(
"error initializing DCS hook, will ignore hook err={}",
.{err},
);
break :state .{ .ignore = {} };
// Initialize our state to ignore in case of error
self.state = .{ .ignore = {} };
// Try to parse the hook.
const hk_ = self.tryHook(alloc, dcs) catch |err| {
log.info("error initializing DCS hook, will ignore hook err={}", .{err});
return null;
};
const hk = hk_ orelse {
log.info("unknown DCS hook: {}", .{dcs});
return null;
};
self.state = hk.state;
return hk.command;
}
fn tryHook(alloc: Allocator, dcs: DCS) !?State {
const Hook = struct {
state: State,
command: ?Command = null,
};
fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook {
return switch (dcs.intermediates.len) {
0 => switch (dcs.final) {
// Tmux control mode
'p' => tmux: {
// Tmux control mode must start with ESC P 1000 p
if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null;
break :tmux .{
.state = .{
.tmux = .{
.max_bytes = self.max_bytes,
.buffer = try std.ArrayList(u8).initCapacity(
alloc,
128, // Arbitrary choice to limit initial reallocs
),
},
},
.command = .{ .tmux = .{ .enter = {} } },
};
},
else => null,
},
1 => switch (dcs.intermediates[0]) {
'+' => switch (dcs.final) {
// XTGETTCAP
// https://github.com/mitchellh/ghostty/issues/517
'q' => .{
.xtgettcap = try std.ArrayList(u8).initCapacity(
alloc,
128, // Arbitrary choice
),
.state = .{
.xtgettcap = try std.ArrayList(u8).initCapacity(
alloc,
128, // Arbitrary choice
),
},
},
else => null,
@ -55,9 +89,9 @@ pub const Handler = struct {
'$' => switch (dcs.final) {
// DECRQSS
'q' => .{
'q' => .{ .state = .{
.decrqss = .{},
},
} },
else => null,
},
@ -69,21 +103,28 @@ pub const Handler = struct {
};
}
pub fn put(self: *Handler, byte: u8) void {
self.tryPut(byte) catch |err| {
/// Put a byte into the DCS handler. This will return a command
/// if a command needs to be executed.
pub fn put(self: *Handler, byte: u8) ?Command {
return self.tryPut(byte) catch |err| {
// On error we just discard our state and ignore the rest
log.info("error putting byte into DCS handler err={}", .{err});
self.discard();
self.state = .{ .ignore = {} };
return null;
};
}
fn tryPut(self: *Handler, byte: u8) !void {
fn tryPut(self: *Handler, byte: u8) !?Command {
switch (self.state) {
.inactive,
.ignore,
=> {},
.tmux => |*tmux| return .{
.tmux = (try tmux.put(byte)) orelse return null,
},
.xtgettcap => |*list| {
if (list.items.len >= self.max_bytes) {
return error.OutOfMemory;
@ -101,15 +142,26 @@ pub const Handler = struct {
buffer.len += 1;
},
}
return null;
}
pub fn unhook(self: *Handler) ?Command {
// Note: we do NOT call deinit here on purpose because some commands
// transfer memory ownership. If state needs cleanup, the switch
// prong below should handle it.
defer self.state = .{ .inactive = {} };
return switch (self.state) {
.inactive,
.ignore,
=> null,
.tmux => tmux: {
self.state.deinit();
break :tmux .{ .tmux = .{ .exit = {} } };
},
.xtgettcap => |list| .{ .xtgettcap = .{ .data = list } },
.decrqss => |buffer| .{ .decrqss = switch (buffer.len) {
@ -133,16 +185,7 @@ pub const Handler = struct {
}
fn discard(self: *Handler) void {
switch (self.state) {
.inactive,
.ignore,
=> {},
.xtgettcap => |*list| list.deinit(),
.decrqss => {},
}
self.state.deinit();
self.state = .{ .inactive = {} };
}
};
@ -154,12 +197,14 @@ pub const Command = union(enum) {
/// DECRQSS
decrqss: DECRQSS,
/// Tmux control mode
tmux: terminal.tmux.Notification,
pub fn deinit(self: Command) void {
switch (self) {
.xtgettcap => |*v| {
v.data.deinit();
},
.xtgettcap => |*v| v.data.deinit(),
.decrqss => {},
.tmux => {},
}
}
@ -193,6 +238,12 @@ pub const Command = union(enum) {
decstbm,
decslrm,
};
/// Tmux control mode
pub const Tmux = union(enum) {
enter: void,
exit: void,
};
};
const State = union(enum) {
@ -211,6 +262,21 @@ const State = union(enum) {
data: [2]u8 = undefined,
len: u2 = 0,
},
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
tmux: terminal.tmux.Client,
pub fn deinit(self: *State) void {
switch (self.*) {
.inactive,
.ignore,
=> {},
.xtgettcap => |*v| v.deinit(),
.decrqss => {},
.tmux => |*v| v.deinit(),
}
}
};
test "unknown DCS command" {
@ -219,7 +285,7 @@ test "unknown DCS command" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .final = 'A' });
try testing.expect(h.hook(alloc, .{ .final = 'A' }) == null);
try testing.expect(h.state == .ignore);
try testing.expect(h.unhook() == null);
try testing.expect(h.state == .inactive);
@ -231,8 +297,8 @@ test "XTGETTCAP command" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
for ("536D756C78") |byte| h.put(byte);
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
for ("536D756C78") |byte| _ = h.put(byte);
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .xtgettcap);
@ -246,8 +312,8 @@ test "XTGETTCAP command multiple keys" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
for ("536D756C78;536D756C78") |byte| h.put(byte);
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
for ("536D756C78;536D756C78") |byte| _ = h.put(byte);
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .xtgettcap);
@ -262,8 +328,8 @@ test "XTGETTCAP command invalid data" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .intermediates = "+", .final = 'q' });
for ("who;536D756C78") |byte| h.put(byte);
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
for ("who;536D756C78") |byte| _ = h.put(byte);
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .xtgettcap);
@ -278,8 +344,8 @@ test "DECRQSS command" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .intermediates = "$", .final = 'q' });
h.put('m');
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
_ = h.put('m');
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .decrqss);
@ -292,8 +358,8 @@ test "DECRQSS invalid command" {
var h: Handler = .{};
defer h.deinit();
h.hook(alloc, .{ .intermediates = "$", .final = 'q' });
h.put('z');
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
_ = h.put('z');
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .decrqss);
@ -301,9 +367,31 @@ test "DECRQSS invalid command" {
h.discard();
h.hook(alloc, .{ .intermediates = "$", .final = 'q' });
h.put('"');
h.put(' ');
h.put('q');
try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null);
_ = h.put('"');
_ = h.put(' ');
_ = h.put('q');
try testing.expect(h.unhook() == null);
}
test "tmux enter and implicit exit" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
defer h.deinit();
{
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
defer cmd.deinit();
try testing.expect(cmd == .tmux);
try testing.expect(cmd.tmux == .enter);
}
{
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .tmux);
try testing.expect(cmd.tmux == .exit);
}
}

View File

@ -20,6 +20,7 @@ pub const modes = @import("modes.zig");
pub const page = @import("page.zig");
pub const parse_table = @import("parse_table.zig");
pub const size = @import("size.zig");
pub const tmux = @import("tmux.zig");
pub const x11_color = @import("x11_color.zig");
pub const Charset = charsets.Charset;

423
src/terminal/tmux.zig Normal file
View File

@ -0,0 +1,423 @@
//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
const std = @import("std");
const assert = std.debug.assert;
const oni = @import("oniguruma");
const log = std.log.scoped(.terminal_tmux);
/// A tmux control mode client. It is expected that the caller establishes
/// the connection in some way (i.e. detects the opening DCS sequence). This
/// just works on a byte stream.
pub const Client = struct {
/// Current state of the client.
state: State = .idle,
/// The buffer used to store in-progress notifications, output, etc.
buffer: std.ArrayList(u8),
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
max_bytes: usize = 1024 * 1024,
const State = enum {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Client) void {
self.buffer.deinit();
}
// Handle a byte of input.
pub fn put(self: *Client, byte: u8) !?Notification {
if (self.buffer.items.len >= self.max_bytes) {
self.broken();
return error.OutOfMemory;
}
switch (self.state) {
// Drop because we're in a broken state.
.broken => return null,
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
.idle => if (byte != '%') {
self.broken();
return .{ .exit = {} };
} else {
self.buffer.clearRetainingCapacity();
self.state = .notification;
},
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
.notification => if (byte == '\n') {
// We have a complete notification, parse it.
return try self.parseNotification();
},
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
.block => if (byte == '\n') {
const idx = if (std.mem.lastIndexOfScalar(
u8,
self.buffer.items,
'\n',
)) |v| v + 1 else 0;
const line = self.buffer.items[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, self.buffer.items[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
}
// Didn't end the block, continue accumulating.
},
}
try self.buffer.append(byte);
return null;
}
fn parseNotification(self: *Client) !?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.items;
if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
break :line line;
};
const cmd = cmd: {
const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
break :cmd line[0..idx];
};
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
if (std.mem.eql(u8, cmd, "%begin")) {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed. In the future, we should probably validate
// this.
// TODO(tmuxcc): do this before merge?
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = .block;
self.buffer.clearRetainingCapacity();
return null;
} else if (std.mem.eql(u8, cmd, "%output")) cmd: {
var re = try oni.Regex.init(
"^%output %([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .output = .{ .pane_id = id, .data = data } };
} else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: {
var re = try oni.Regex.init(
"^%session-changed \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .session_changed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: {
if (!std.mem.eql(u8, line, "%sessions-changed")) {
log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line });
break :cmd;
}
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .sessions_changed = {} };
} else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
var re = try oni.Regex.init(
"^%window-add @([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_add = .{ .id = id } };
} else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: {
var re = try oni.Regex.init(
"^%window-renamed @([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .window_renamed = .{ .id = id, .name = name } };
} else {
// Unknown notification, log it and return to idle state.
log.warn("unknown tmux control mode notification={s}", .{cmd});
}
// Unknown command. Clear the buffer and return to idle state.
self.buffer.clearRetainingCapacity();
self.state = .idle;
return null;
}
// Mark the tmux state as broken.
fn broken(self: *Client) void {
self.state = .broken;
self.buffer.clearAndFree();
}
};
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1).
pub const Notification = union(enum) {
enter: void,
exit: void,
block_end: []const u8,
block_err: []const u8,
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
session_changed: struct {
id: usize,
name: []const u8,
},
sessions_changed: void,
window_add: struct {
id: usize,
},
window_renamed: struct {
id: usize,
name: []const u8,
},
};
test "tmux begin/end empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("", n.block_end);
}
test "tmux begin/error empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("", n.block_err);
}
test "tmux begin/end data" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualStrings("foo bar baz", n.output.data);
}
test "tmux session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux sessions-changed carriage return" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux window-add" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_add);
try testing.expectEqual(14, n.window_add.id);
}
test "tmux window-renamed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) };
defer c.deinit();
for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_renamed);
try testing.expectEqual(42, n.window_renamed.id);
try testing.expectEqualStrings("bar", n.window_renamed.name);
}

View File

@ -1860,19 +1860,31 @@ const StreamHandler = struct {
}
pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void {
self.dcs.hook(self.alloc, dcs);
var cmd = self.dcs.hook(self.alloc, dcs) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
pub fn dcsPut(self: *StreamHandler, byte: u8) !void {
self.dcs.put(byte);
var cmd = self.dcs.put(byte) orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
pub fn dcsUnhook(self: *StreamHandler) !void {
var cmd = self.dcs.unhook() orelse return;
defer cmd.deinit();
try self.dcsCommand(&cmd);
}
fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void {
// log.warn("DCS command: {}", .{cmd});
switch (cmd) {
switch (cmd.*) {
.tmux => |tmux| {
// TODO: process it
log.warn("tmux control mode event unimplemented cmd={}", .{tmux});
},
.xtgettcap => |*gettcap| {
const map = comptime terminfo.ghostty.xtgettcapMap();
while (gettcap.next()) |key| {
@ -1880,6 +1892,7 @@ const StreamHandler = struct {
self.messageWriter(.{ .write_stable = response });
}
},
.decrqss => |decrqss| {
var response: [128]u8 = undefined;
var stream = std.io.fixedBufferStream(&response);