an experimental irc client
1const std = @import("std");
2const builtin = @import("builtin");
3const comlink = @import("comlink.zig");
4const vaxis = @import("vaxis");
5const zeit = @import("zeit");
6const ziglua = @import("ziglua");
7const Scrollbar = @import("Scrollbar.zig");
8const main = @import("main.zig");
9const format = @import("format.zig");
10
11const irc = comlink.irc;
12const lua = comlink.lua;
13const mem = std.mem;
14const vxfw = vaxis.vxfw;
15
16const assert = std.debug.assert;
17
18const Allocator = std.mem.Allocator;
19const Base64Encoder = std.base64.standard.Encoder;
20const Bind = comlink.Bind;
21const Completer = comlink.Completer;
22const Event = comlink.Event;
23const Lua = ziglua.Lua;
24const TextInput = vaxis.widgets.TextInput;
25const WriteRequest = comlink.WriteRequest;
26
27const log = std.log.scoped(.app);
28
29const State = struct {
30 buffers: struct {
31 count: usize = 0,
32 width: u16 = 16,
33 } = .{},
34 paste: struct {
35 pasting: bool = false,
36 has_newline: bool = false,
37
38 fn showDialog(self: @This()) bool {
39 return !self.pasting and self.has_newline;
40 }
41 } = .{},
42};
43
44pub const App = struct {
45 config: comlink.Config,
46 explicit_join: bool,
47 alloc: std.mem.Allocator,
48 /// System certificate bundle
49 bundle: std.crypto.Certificate.Bundle,
50 /// List of all configured clients
51 clients: std.ArrayList(*irc.Client),
52 /// if we have already called deinit
53 deinited: bool,
54 /// Process environment
55 env: std.process.EnvMap,
56 /// Local timezone
57 tz: zeit.TimeZone,
58
59 state: State,
60
61 completer: ?Completer,
62
63 binds: std.ArrayList(Bind),
64
65 paste_buffer: std.ArrayList(u8),
66
67 lua: *Lua,
68
69 write_queue: comlink.WriteQueue,
70 write_thread: std.Thread,
71
72 view: vxfw.SplitView,
73 buffer_list: vxfw.ListView,
74 unicode: *const vaxis.Unicode,
75
76 title_buf: [128]u8,
77
78 // Only valid during an event handler
79 ctx: ?*vxfw.EventContext,
80 last_height: u16,
81
82 /// Whether the application has focus or not
83 has_focus: bool,
84
85 fg: ?[3]u8,
86 bg: ?[3]u8,
87 yellow: ?[3]u8,
88
89 const default_rhs: vxfw.Text = .{ .text = "TODO: update this text" };
90
91 /// initialize vaxis, lua state
92 pub fn init(self: *App, gpa: std.mem.Allocator, unicode: *const vaxis.Unicode) !void {
93 self.* = .{
94 .alloc = gpa,
95 .config = .{},
96 .state = .{},
97 .clients = std.ArrayList(*irc.Client).init(gpa),
98 .env = try std.process.getEnvMap(gpa),
99 .binds = try std.ArrayList(Bind).initCapacity(gpa, 16),
100 .paste_buffer = std.ArrayList(u8).init(gpa),
101 .tz = try zeit.local(gpa, null),
102 .lua = undefined,
103 .write_queue = .{},
104 .write_thread = undefined,
105 .view = .{
106 .width = self.state.buffers.width,
107 .lhs = self.buffer_list.widget(),
108 .rhs = default_rhs.widget(),
109 },
110 .explicit_join = false,
111 .bundle = .{},
112 .deinited = false,
113 .completer = null,
114 .buffer_list = .{
115 .children = .{
116 .builder = .{
117 .userdata = self,
118 .buildFn = App.bufferBuilderFn,
119 },
120 },
121 .draw_cursor = false,
122 },
123 .unicode = unicode,
124 .title_buf = undefined,
125 .ctx = null,
126 .last_height = 0,
127 .has_focus = true,
128 .fg = null,
129 .bg = null,
130 .yellow = null,
131 };
132
133 self.lua = try Lua.init(self.alloc);
134 self.write_thread = try std.Thread.spawn(.{}, writeLoop, .{ self.alloc, &self.write_queue });
135
136 try lua.init(self);
137
138 try self.binds.append(.{
139 .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } },
140 .command = .quit,
141 });
142 try self.binds.append(.{
143 .key = .{ .codepoint = vaxis.Key.up, .mods = .{ .alt = true } },
144 .command = .@"prev-channel",
145 });
146 try self.binds.append(.{
147 .key = .{ .codepoint = vaxis.Key.down, .mods = .{ .alt = true } },
148 .command = .@"next-channel",
149 });
150 try self.binds.append(.{
151 .key = .{ .codepoint = 'l', .mods = .{ .ctrl = true } },
152 .command = .redraw,
153 });
154
155 // Get our system tls certs
156 try self.bundle.rescan(gpa);
157 }
158
159 /// close the application. This closes the TUI, disconnects clients, and cleans
160 /// up all resources
161 pub fn deinit(self: *App) void {
162 if (self.deinited) return;
163 self.deinited = true;
164 // Push a join command to the write thread
165 self.write_queue.push(.join);
166
167 // clean up clients
168 {
169 // Loop first to close connections. This will help us close faster by getting the
170 // threads exited
171 for (self.clients.items) |client| {
172 client.close();
173 }
174 for (self.clients.items) |client| {
175 client.deinit();
176 self.alloc.destroy(client);
177 }
178 self.clients.deinit();
179 }
180
181 self.bundle.deinit(self.alloc);
182
183 if (self.completer) |*completer| completer.deinit();
184 self.binds.deinit();
185 self.paste_buffer.deinit();
186 self.tz.deinit();
187
188 // Join the write thread
189 self.write_thread.join();
190 self.env.deinit();
191 self.lua.deinit();
192 }
193
194 pub fn widget(self: *App) vxfw.Widget {
195 return .{
196 .userdata = self,
197 .captureHandler = App.typeErasedCaptureHandler,
198 .eventHandler = App.typeErasedEventHandler,
199 .drawFn = App.typeErasedDrawFn,
200 };
201 }
202
203 fn typeErasedCaptureHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
204 const self: *App = @ptrCast(@alignCast(ptr));
205 // Rewrite the ctx pointer every frame. We don't actually need to do this with the current
206 // vxfw runtime, because the context pointer is always valid. But for safe keeping, we will
207 // do it this way.
208 //
209 // In general, this is bad practice. But we need to be able to access this from lua
210 // callbacks
211 self.ctx = ctx;
212 switch (event) {
213 .color_scheme => {
214 // On a color scheme event, we request the colors again
215 try ctx.queryColor(.fg);
216 try ctx.queryColor(.bg);
217 try ctx.queryColor(.{ .index = 3 });
218 },
219 .color_report => |color| {
220 switch (color.kind) {
221 .fg => self.fg = color.value,
222 .bg => self.bg = color.value,
223 .index => |index| {
224 switch (index) {
225 3 => self.yellow = color.value,
226 else => {},
227 }
228 },
229 .cursor => {},
230 }
231 if (self.fg != null and self.bg != null) {
232 for (self.clients.items) |client| {
233 client.text_field.style.bg = self.blendBg(10);
234 for (client.channels.items) |channel| {
235 channel.text_field.style.bg = self.blendBg(10);
236 }
237 }
238 }
239 ctx.redraw = true;
240 },
241 .key_press => |key| {
242 if (self.state.paste.pasting) {
243 ctx.consume_event = true;
244 // Always ignore enter key
245 if (key.codepoint == vaxis.Key.enter) return;
246 if (key.text) |text| {
247 try self.paste_buffer.appendSlice(text);
248 }
249 return;
250 }
251 if (key.matches('c', .{ .ctrl = true })) {
252 ctx.quit = true;
253 }
254 for (self.binds.items) |bind| {
255 if (key.matches(bind.key.codepoint, bind.key.mods)) {
256 switch (bind.command) {
257 .quit => ctx.quit = true,
258 .@"next-channel" => self.nextChannel(),
259 .@"prev-channel" => self.prevChannel(),
260 .redraw => try ctx.queueRefresh(),
261 .lua_function => |ref| try lua.execFn(self.lua, ref),
262 else => {},
263 }
264 return ctx.consumeAndRedraw();
265 }
266 }
267 },
268 .paste_start => self.state.paste.pasting = true,
269 .paste_end => {
270 self.state.paste.pasting = false;
271 if (std.mem.indexOfScalar(u8, self.paste_buffer.items, '\n')) |_| {
272 log.debug("paste had line ending", .{});
273 return;
274 }
275 defer self.paste_buffer.clearRetainingCapacity();
276 if (self.selectedBuffer()) |buffer| {
277 switch (buffer) {
278 .client => {},
279 .channel => |channel| {
280 try channel.text_field.insertSliceAtCursor(self.paste_buffer.items);
281 return ctx.consumeAndRedraw();
282 },
283 }
284 }
285 },
286 .focus_out => self.has_focus = false,
287
288 .focus_in => {
289 if (self.config.markread_on_focus) {
290 if (self.selectedBuffer()) |buffer| {
291 switch (buffer) {
292 .client => {},
293 .channel => |channel| {
294 channel.last_read_indicator = channel.last_read;
295 },
296 }
297 }
298 }
299 self.has_focus = true;
300 ctx.redraw = true;
301 },
302
303 else => {},
304 }
305 }
306
307 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
308 const self: *App = @ptrCast(@alignCast(ptr));
309 self.ctx = ctx;
310 switch (event) {
311 .init => {
312 const title = try std.fmt.bufPrint(&self.title_buf, "comlink", .{});
313 try ctx.setTitle(title);
314 try ctx.tick(8, self.widget());
315 try ctx.queryColor(.fg);
316 try ctx.queryColor(.bg);
317 try ctx.queryColor(.{ .index = 3 });
318 if (self.clients.items.len > 0) {
319 try ctx.requestFocus(self.clients.items[0].text_field.widget());
320 }
321 },
322 .tick => {
323 for (self.clients.items) |client| {
324 if (client.status.load(.unordered) == .disconnected and
325 client.retry_delay_s == 0)
326 {
327 ctx.redraw = true;
328 try irc.Client.retryTickHandler(client, ctx, .tick);
329 }
330 client.drainFifo(ctx);
331 client.checkTypingStatus(ctx);
332 }
333 try ctx.tick(8, self.widget());
334 },
335 else => {},
336 }
337 }
338
339 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
340 const self: *App = @ptrCast(@alignCast(ptr));
341 const max = ctx.max.size();
342 self.last_height = max.height;
343 if (self.selectedBuffer()) |buffer| {
344 switch (buffer) {
345 .client => |client| self.view.rhs = client.view(),
346 .channel => |channel| self.view.rhs = channel.view.widget(),
347 }
348 } else self.view.rhs = default_rhs.widget();
349
350 var children = std.ArrayList(vxfw.SubSurface).init(ctx.arena);
351
352 // UI is a tree of splits
353 // │ │ │ │
354 // │ │ │ │
355 // │ buffers │ buffer content │ members │
356 // │ │ │ │
357 // │ │ │ │
358 // │ │ │ │
359 // │ │ │ │
360
361 const sub: vxfw.SubSurface = .{
362 .origin = .{ .col = 0, .row = 0 },
363 .surface = try self.view.widget().draw(ctx),
364 };
365 try children.append(sub);
366
367 for (self.clients.items) |client| {
368 if (client.list_modal.is_shown) {
369 const padding: u16 = 8;
370 const modal_ctx = ctx.withConstraints(ctx.min, .{
371 .width = max.width -| padding * 2,
372 .height = max.height -| padding,
373 });
374 const border: vxfw.Border = .{ .child = client.list_modal.widget() };
375 try children.append(.{
376 .origin = .{ .row = padding / 2, .col = padding },
377 .surface = try border.draw(modal_ctx),
378 });
379 break;
380 }
381 }
382
383 return .{
384 .size = ctx.max.size(),
385 .widget = self.widget(),
386 .buffer = &.{},
387 .children = children.items,
388 };
389 }
390
391 fn bufferBuilderFn(ptr: *const anyopaque, idx: usize, cursor: usize) ?vxfw.Widget {
392 const self: *const App = @ptrCast(@alignCast(ptr));
393 var i: usize = 0;
394 for (self.clients.items) |client| {
395 if (i == idx) return client.nameWidget(i == cursor);
396 i += 1;
397 for (client.channels.items) |channel| {
398 if (i == idx) return channel.nameWidget(i == cursor);
399 i += 1;
400 }
401 }
402 return null;
403 }
404
405 pub fn connect(self: *App, cfg: irc.Client.Config) !void {
406 const client = try self.alloc.create(irc.Client);
407 try client.init(self.alloc, self, &self.write_queue, cfg);
408 try self.clients.append(client);
409 }
410
411 pub fn nextChannel(self: *App) void {
412 if (self.ctx) |ctx| {
413 self.buffer_list.nextItem(ctx);
414 if (self.selectedBuffer()) |buffer| {
415 switch (buffer) {
416 .client => |client| {
417 ctx.requestFocus(client.text_field.widget()) catch {};
418 },
419 .channel => |channel| {
420 ctx.requestFocus(channel.text_field.widget()) catch {};
421 },
422 }
423 }
424 }
425 }
426
427 pub fn prevChannel(self: *App) void {
428 if (self.ctx) |ctx| {
429 self.buffer_list.prevItem(ctx);
430 if (self.selectedBuffer()) |buffer| {
431 switch (buffer) {
432 .client => |client| {
433 ctx.requestFocus(client.text_field.widget()) catch {};
434 },
435 .channel => |channel| {
436 ctx.requestFocus(channel.text_field.widget()) catch {};
437 },
438 }
439 }
440 }
441 }
442
443 pub fn selectChannelName(self: *App, cl: *irc.Client, name: []const u8) void {
444 var i: usize = 0;
445 for (self.clients.items) |client| {
446 i += 1;
447 for (client.channels.items) |channel| {
448 if (cl == client) {
449 if (std.mem.eql(u8, name, channel.name)) {
450 self.selectBuffer(.{ .channel = channel });
451 }
452 }
453 i += 1;
454 }
455 }
456 }
457
458 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be
459 /// interpreted as percentage of fg to blend into bg
460 pub fn blendBg(self: *App, amt: u8) vaxis.Color {
461 const bg = self.bg orelse return .{ .index = 8 };
462 const fg = self.fg orelse return .{ .index = 8 };
463 // Clamp to (0,100)
464 if (amt == 0) return .{ .rgb = bg };
465 if (amt >= 100) return .{ .rgb = fg };
466
467 const fg_r: u16 = std.math.mulWide(u8, fg[0], amt);
468 const fg_g: u16 = std.math.mulWide(u8, fg[1], amt);
469 const fg_b: u16 = std.math.mulWide(u8, fg[2], amt);
470
471 const bg_multiplier: u8 = 100 - amt;
472 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier);
473 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier);
474 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier);
475
476 return .{
477 .rgb = .{
478 @intCast((fg_r + bg_r) / 100),
479 @intCast((fg_g + bg_g) / 100),
480 @intCast((fg_b + bg_b) / 100),
481 },
482 };
483 }
484
485 /// Blend fg and bg, otherwise return index 8. amt will be clamped to [0,100]. amt will be
486 /// interpreted as percentage of fg to blend into bg
487 pub fn blendYellow(self: *App, amt: u8) vaxis.Color {
488 const bg = self.bg orelse return .{ .index = 3 };
489 const yellow = self.yellow orelse return .{ .index = 3 };
490 // Clamp to (0,100)
491 if (amt == 0) return .{ .rgb = bg };
492 if (amt >= 100) return .{ .rgb = yellow };
493
494 const yellow_r: u16 = std.math.mulWide(u8, yellow[0], amt);
495 const yellow_g: u16 = std.math.mulWide(u8, yellow[1], amt);
496 const yellow_b: u16 = std.math.mulWide(u8, yellow[2], amt);
497
498 const bg_multiplier: u8 = 100 - amt;
499 const bg_r: u16 = std.math.mulWide(u8, bg[0], bg_multiplier);
500 const bg_g: u16 = std.math.mulWide(u8, bg[1], bg_multiplier);
501 const bg_b: u16 = std.math.mulWide(u8, bg[2], bg_multiplier);
502
503 return .{
504 .rgb = .{
505 @intCast((yellow_r + bg_r) / 100),
506 @intCast((yellow_g + bg_g) / 100),
507 @intCast((yellow_b + bg_b) / 100),
508 },
509 };
510 }
511
512 /// handle a command
513 pub fn handleCommand(self: *App, buffer: irc.Buffer, cmd: []const u8) !void {
514 const lua_state = self.lua;
515 const command: comlink.Command = blk: {
516 const start: u1 = if (cmd[0] == '/') 1 else 0;
517 const end = mem.indexOfScalar(u8, cmd, ' ') orelse cmd.len;
518 if (comlink.Command.fromString(cmd[start..end])) |internal|
519 break :blk internal;
520 if (comlink.Command.user_commands.get(cmd[start..end])) |ref| {
521 const str = if (end == cmd.len) "" else std.mem.trim(u8, cmd[end..], " ");
522 return lua.execUserCommand(lua_state, str, ref);
523 }
524 return error.UnknownCommand;
525 };
526 var buf: [1024]u8 = undefined;
527 const client: *irc.Client = switch (buffer) {
528 .client => |client| client,
529 .channel => |channel| channel.client,
530 };
531 const channel: ?*irc.Channel = switch (buffer) {
532 .client => null,
533 .channel => |channel| channel,
534 };
535 switch (command) {
536 .quote => {
537 const start = mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
538 const msg = try std.fmt.bufPrint(
539 &buf,
540 "{s}\r\n",
541 .{cmd[start + 1 ..]},
542 );
543 return client.queueWrite(msg);
544 },
545 .join => {
546 const start = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
547 const chan_name = cmd[start + 1 ..];
548 for (client.channels.items) |chan| {
549 if (std.mem.eql(u8, chan.name, chan_name)) {
550 client.app.selectBuffer(.{ .channel = chan });
551 return;
552 }
553 }
554 const msg = try std.fmt.bufPrint(
555 &buf,
556 "JOIN {s}\r\n",
557 .{
558 chan_name,
559 },
560 );
561
562 // Check
563 // Ensure buffer exists
564 self.explicit_join = true;
565 return client.queueWrite(msg);
566 },
567 .list => {
568 client.list_modal.expecting_response = true;
569 return client.queueWrite("LIST\r\n");
570 },
571 .me => {
572 if (channel == null) return error.InvalidCommand;
573 const msg = try std.fmt.bufPrint(
574 &buf,
575 "PRIVMSG {s} :\x01ACTION {s}\x01\r\n",
576 .{
577 channel.?.name,
578 cmd[4..],
579 },
580 );
581 return client.queueWrite(msg);
582 },
583 .msg => {
584 //syntax: /msg <nick> <msg>
585 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
586 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse return error.InvalidCommand;
587 const msg = try std.fmt.bufPrint(
588 &buf,
589 "PRIVMSG {s} :{s}\r\n",
590 .{
591 cmd[s + 1 .. e],
592 cmd[e + 1 ..],
593 },
594 );
595 return client.queueWrite(msg);
596 },
597 .query => {
598 const s = std.mem.indexOfScalar(u8, cmd, ' ') orelse return error.InvalidCommand;
599 const e = std.mem.indexOfScalarPos(u8, cmd, s + 1, ' ') orelse cmd.len;
600 if (cmd[s + 1] == '#') return error.InvalidCommand;
601
602 const ch = try client.getOrCreateChannel(cmd[s + 1 .. e]);
603 try client.requestHistory(.after, ch);
604 self.selectChannelName(client, ch.name);
605 //handle sending the message
606 if (cmd.len - e > 1) {
607 const msg = try std.fmt.bufPrint(
608 &buf,
609 "PRIVMSG {s} :{s}\r\n",
610 .{
611 cmd[s + 1 .. e],
612 cmd[e + 1 ..],
613 },
614 );
615 return client.queueWrite(msg);
616 }
617 },
618 .names => {
619 if (channel == null) return error.InvalidCommand;
620 const msg = try std.fmt.bufPrint(&buf, "NAMES {s}\r\n", .{channel.?.name});
621 return client.queueWrite(msg);
622 },
623 .@"next-channel" => self.nextChannel(),
624 .@"prev-channel" => self.prevChannel(),
625 .quit => {
626 if (self.ctx) |ctx| ctx.quit = true;
627 },
628 .who => {
629 if (channel == null) return error.InvalidCommand;
630 const msg = try std.fmt.bufPrint(
631 &buf,
632 "WHO {s}\r\n",
633 .{
634 channel.?.name,
635 },
636 );
637 return client.queueWrite(msg);
638 },
639 .part, .close => {
640 if (channel == null) return error.InvalidCommand;
641 var it = std.mem.tokenizeScalar(u8, cmd, ' ');
642
643 // Skip command
644 _ = it.next();
645 const target = it.next() orelse channel.?.name;
646
647 if (target[0] != '#') {
648 for (client.channels.items, 0..) |search, i| {
649 if (!mem.eql(u8, search.name, target)) continue;
650 client.app.prevChannel();
651 var chan = client.channels.orderedRemove(i);
652 chan.deinit(self.alloc);
653 self.alloc.destroy(chan);
654 break;
655 }
656 } else {
657 const msg = try std.fmt.bufPrint(
658 &buf,
659 "PART {s}\r\n",
660 .{
661 target,
662 },
663 );
664 return client.queueWrite(msg);
665 }
666 },
667 .redraw => {},
668 // .redraw => self.vx.queueRefresh(),
669 .version => {
670 if (channel == null) return error.InvalidCommand;
671 const msg = try std.fmt.bufPrint(
672 &buf,
673 "NOTICE {s} :\x01VERSION comlink {s}\x01\r\n",
674 .{
675 channel.?.name,
676 main.version,
677 },
678 );
679 return client.queueWrite(msg);
680 },
681 .lua_function => {}, // we don't handle these from the text-input
682 }
683 }
684
685 pub fn selectedBuffer(self: *App) ?irc.Buffer {
686 var i: usize = 0;
687 for (self.clients.items) |client| {
688 if (i == self.buffer_list.cursor) return .{ .client = client };
689 i += 1;
690 for (client.channels.items) |channel| {
691 if (i == self.buffer_list.cursor) return .{ .channel = channel };
692 i += 1;
693 }
694 }
695 return null;
696 }
697
698 pub fn selectBuffer(self: *App, buffer: irc.Buffer) void {
699 var i: u32 = 0;
700 switch (buffer) {
701 .client => |target| {
702 for (self.clients.items) |client| {
703 if (client == target) {
704 if (self.ctx) |ctx| {
705 ctx.requestFocus(client.text_field.widget()) catch {};
706 }
707 self.buffer_list.cursor = i;
708 self.buffer_list.ensureScroll();
709 return;
710 }
711 i += 1;
712 for (client.channels.items) |_| i += 1;
713 }
714 },
715 .channel => |target| {
716 for (self.clients.items) |client| {
717 i += 1;
718 for (client.channels.items) |channel| {
719 if (channel == target) {
720 self.buffer_list.cursor = i;
721 self.buffer_list.ensureScroll();
722 channel.doSelect();
723 if (self.ctx) |ctx| {
724 ctx.requestFocus(channel.text_field.widget()) catch {};
725 }
726 return;
727 }
728 i += 1;
729 }
730 }
731 },
732 }
733 }
734};
735
736/// this loop is run in a separate thread and handles writes to all clients.
737/// Message content is deallocated when the write request is completed
738fn writeLoop(alloc: std.mem.Allocator, queue: *comlink.WriteQueue) !void {
739 log.debug("starting write thread", .{});
740 while (true) {
741 const req = queue.pop();
742 switch (req) {
743 .write => |w| {
744 try w.client.write(w.msg);
745 alloc.free(w.msg);
746 },
747 .join => {
748 while (queue.tryPop()) |r| {
749 switch (r) {
750 .write => |w| alloc.free(w.msg),
751 else => {},
752 }
753 }
754 return;
755 },
756 }
757 }
758}