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