1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
|
//! THE FRONTEND SIDE of a detached session: a socket, a grid, and no core.
//!
//! THIS SIDE DOES NOT OWN A `Pardes`. That is the one thing to be clear about,
//! because the shape invites the mistake: there is no `update`, no `postEvent`
//! and no `Host` in this file. The core lives in the detached process
//! (server.zig), which is also where every `Host.VTable` call originates. A
//! frontend's whole job is two sentences long — send the input it collects, draw
//! the frames it is sent — and this module is exactly that and nothing more.
//!
//! `Client` is NOT a renderer either. It owns `grid` and `cursor`: the cells the
//! session is showing, kept current by applying frames as they arrive. Whoever
//! owns a terminal (or a window, or an ESP32-P4 panel) reads those and paints.
//! The split is deliberate — pardes already has terminal frontends, and a second
//! one living in here would be a second convention for a job that has one.
//!
//! THE ATTACH IS NOT A BLOCKING HANDSHAKE. `open` connects and writes the
//! `hello`; the `welcome` (or the `refuse`) arrives through the ordinary
//! `wait`/`next` loop like everything else. A frontend therefore has one loop
//! and one place it sleeps, instead of a startup path that can hang for two
//! seconds before its terminal is even in raw mode. `attached()` says whether
//! the session has greeted us; `refusal` says why it did not.
//!
//! WHAT ARRIVES, and what a frontend is expected to do with it. `next` hands
//! back one decoded `wire.ServerMsg` at a time:
//! * `welcome` and `frame` have already been applied to `slot`/`cols`/`rows`/
//! `grid`/`cursor` by the time you see them. They are returned so a frontend
//! knows the screen moved.
//! * `spawn`, `pty_write`, `pty_resize`, `write_file`, `write_dump`,
//! `watch_file`, `watch_theme`, `dump_themes` are the session asking this
//! frontend for the real host work it has and a daemon does not: fork a
//! shell on a real tty, put bytes on a real disk, watch a path. A frontend
//! with none of that ignores them, exactly as a null vtable method does —
//! and only ONE attached frontend is ever asked (server.zig's routing).
//! * `set_clipboard`, `open_link` and `read_clipboard` are the desktop. The
//! answer to `read_clipboard` is not a reply message: it is an ordinary
//! `Event.paste` sent back through `send`, which is the same asynchronous
//! shape `pull_read_clipboard` already has in-process.
//! * `refuse` is followed by the session closing the connection, and `quit`
//! means the session itself has ended.
//!
//! GEOMETRY. `cols`/`rows` are the SESSION's grid, which with several frontends
//! attached is the smallest common one and can be smaller than this frontend's
//! window. Tell the session about the window with `resize`; do not assume the
//! next frame will agree with it.
//!
//! BORROWED BYTES. Every slice in a returned `ServerMsg` points into this
//! client's receive buffer and is valid until the next call to `next` or
//! `wait`. A frontend that needs a path or a payload for longer copies it — the
//! same rule the core's own `Event.output` bytes have.
const std = @import("std");
const libc = std.c;
const pardes = @import("../pardes.zig");
const server = @import("server.zig");
const wire = @import("wire.zig");
const read_chunk = 16 * 1024;
pub const Error = error{
/// No `$XDG_RUNTIME_DIR` and no `$HOME`, or a name that is not one path
/// component — there is no socket path to try.
NoSessionPath,
/// Nothing is listening there: the name is wrong, or that session ended.
/// Its socket file, if it is still lying about, is unlinked by the sweep the
/// next detached session runs.
NoSession,
/// The socket is there and this frontend will not talk to it: the directory
/// or the socket is not a private one of ours (server.zig `vetted`). A
/// planted socket at a derivable path collects every keystroke typed into
/// the frontend that trusts it, so this is refused rather than reported as
/// "no session" — the two need different answers from a human.
NotPrivate,
/// The session hung up, or the connection failed under us. Every read and
/// write path funnels here: a frontend's answer to all of them is the same
/// (report and exit), so distinguishing them would be a distinction nobody
/// acts on.
Closed,
/// The session said something before it said `welcome`.
Ungreeted,
};
pub const Client = struct {
gpa: std.mem.Allocator,
fd: c_int = -1,
/// Which client slot the session gave this connection. Diagnostics only,
/// and it exists so both sides print the same number.
slot: u8 = 0,
/// Set by a `refuse`, and the reason a frontend prints before exiting.
refusal: ?wire.Refusal = null,
/// The SESSION's grid, not this frontend's window. Zero until the welcome.
cols: u16 = 0,
rows: u16 = 0,
/// `cols * rows` cells: what the session is showing right now.
grid: std.ArrayListUnmanaged(pardes.Cell) = .empty,
cursor: ?wire.Cursor = null,
in: std.ArrayListUnmanaged(u8) = .empty,
out: std.ArrayListUnmanaged(u8) = .empty,
/// Bytes of `in` belonging to the message `next` returned last. Compacted at
/// the top of the next call, which is exactly what makes that message's
/// borrowed slices valid until then and no longer.
held: usize = 0,
/// Connect to the session called `name` and say hello, telling it this
/// frontend's window. Does not wait: the greeting arrives through `next`.
pub fn open(gpa: std.mem.Allocator, name: []const u8, cols: u16, rows: u16) (Error || wire.Error)!Client {
var path_buf: [server.path_max]u8 = undefined;
const path = server.sessionPath(&path_buf, name) orelse return error.NoSessionPath;
// Both ends vet, and this is this end's half: the session refuses a
// directory or a socket anyone else can reach before it binds, and until
// this a frontend connected to whatever it found at the path it derived.
// See server.zig `vetted` for what is asked and why it is asked there.
if (!server.vetted(path)) return error.NotPrivate;
var addr: libc.sockaddr.un = .{ .path = @splat(0) };
@memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
if (fd < 0) return error.NoSession;
// CLOEXEC before anything can fork, and a frontend DOES fork: the pane
// shells it is asked to spawn are its own children, and one of them
// holding this socket would keep the session believing a frontend is
// attached long after this process left.
server.setCloexec(fd);
if (comptime server.darwin) {
// linux says MSG_NOSIGNAL per write and darwin says it once per
// socket, which is what `nosignal` being 0 on darwin MEANS — so
// this call is the whole of that platform's protection and the
// comment on `nosignal` used to say `open` made it without `open`
// making it. A session that ends mid-write must not take the
// frontend down with SIGPIPE. server.zig `accept` is the mirror.
const on: c_int = 1;
_ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int));
}
// Still blocking for the connect itself, which on AF_UNIX either lands
// in the listener's backlog immediately or is refused; there is no
// in-progress state to poll for.
if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) {
_ = libc.close(fd);
return error.NoSession;
}
server.setNonblock(fd);
var c: Client = .{ .gpa = gpa, .fd = fd };
errdefer c.deinit();
try c.send(.{ .hello = .{ .cols = cols, .rows = rows } });
return c;
}
/// Has the session greeted us? Until it has, `grid` is empty and nothing
/// has been drawn.
pub fn attached(c: *const Client) bool {
return c.cols != 0;
}
pub fn deinit(c: *Client) void {
if (c.fd >= 0) _ = libc.close(c.fd);
c.fd = -1;
c.grid.deinit(c.gpa);
c.in.deinit(c.gpa);
c.out.deinit(c.gpa);
}
/// Leave without ending the session. The `bye` is a courtesy — the session
/// handles a frontend that simply dies, and proving it does is what that
/// test is for — but it turns "the peer vanished" into "the peer left" in
/// the session's log, which is worth seven bytes.
pub fn detach(c: *Client) void {
c.send(.bye) catch {};
c.deinit();
}
/// One message on its way to the core. Everything a frontend collects goes
/// through here: keys, the mouse, pty output from the shells it forked, a
/// paste answering a `read_clipboard`.
pub fn send(c: *Client, msg: wire.ClientMsg) (Error || wire.Error)!void {
const want = wire.clientBound(msg);
c.out.ensureUnusedCapacity(c.gpa, want) catch return error.Closed;
const at = c.out.items.len;
// Encoded straight into the queue's tail rather than through a scratch
// buffer: a paste is four megabytes and copying it twice is two copies.
c.out.items.len += want;
const bytes = wire.encodeClient(c.out.items[at..], msg) catch |err| {
// AND THE QUEUE GOES BACK. `want` bytes of it are uninitialised
// right now, and leaving them there — which is what a bare `try`
// did — puts that much stack-shaped garbage on the socket at the
// next flush: the session decodes it as a message, refuses it, and
// drops a frontend whose only mistake was a message this protocol
// cannot carry (an `Event.command` past 64 KiB is `Overlong`, and a
// caller that mis-sized the queue is `NoSpace`).
c.out.items.len = at;
return err;
};
c.out.items.len = at + bytes.len;
return c.flush();
}
/// Tell the session this frontend's window changed. Not a promise about the
/// next frame: with other frontends attached the session grid is the
/// smallest common one.
pub fn resize(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
return c.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows } } });
}
/// Wait up to `timeout_ms` for the session to say something, and push
/// whatever we still owe it. Zero blocks. This is the frontend's one
/// sleeping place FOR THE SOCKET; the terminal it draws on is polled by
/// whoever owns that, which is why this takes a timeout rather than a second
/// descriptor.
pub fn wait(c: *Client, timeout_ms: u32) (Error || wire.Error)!void {
if (c.fd < 0) return error.Closed;
try c.flush();
var fds: [1]libc.pollfd = .{.{
.fd = c.fd,
.events = if (c.out.items.len != 0) poll_in | poll_out else poll_in,
.revents = 0,
}};
const timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int)));
if (libc.poll(&fds, 1, timeout) <= 0) return; // a timeout, or EINTR
if (fds[0].revents & poll_out != 0) try c.flush();
// POLLIN wins over POLLHUP: a session that wrote a `quit` and then
// closed has bytes worth reading.
if (fds[0].revents & poll_in != 0) return c.fill();
if (fds[0].revents & (poll_hup | poll_err | poll_nval) != 0) return error.Closed;
}
/// The next complete message, or null when the buffer holds only part of
/// one. `welcome` and `frame` have already been applied to this client's
/// own state; every slice in the result borrows the receive buffer until the
/// next call here or to `wait`.
pub fn next(c: *Client) (Error || wire.Error)!?wire.ServerMsg {
// Retire the message returned last, now that its borrow window is over.
if (c.held != 0) {
if (c.held == c.in.items.len) {
c.in.clearRetainingCapacity();
} else {
std.mem.copyForwards(u8, c.in.items, c.in.items[c.held..]);
c.in.items.len -= c.held;
}
c.held = 0;
}
const found = (try wire.framed(c.in.items)) orelse return null;
const msg = try wire.decodeServer(found.tag, found.payload);
c.held = found.total;
switch (msg) {
.welcome => |v| {
// Both sides check the version. This side checks it too because
// a session speaking something else may not have recognised our
// hello as one either, and a frontend must not paint a frame it
// decoded by a layout the other end does not use.
if (v.version != wire.version) return error.Ungreeted;
c.slot = v.slot;
try c.reshape(v.cols, v.rows);
},
.refuse => |why| c.refusal = why,
.frame => |f| {
// A frame before the greeting would be the session drawing for a
// connection it never accepted.
if (!c.attached()) return error.Ungreeted;
// A geometry change and a late attach are the same case on this
// side too: reshape, and require the FULL frame the session
// promises for it. Applying a diff to a grid we just cleared
// would leave every untouched cell blank.
if (f.cols != c.cols or f.rows != c.rows) {
if (f.kind != .full) return error.BadValue;
try c.reshape(f.cols, f.rows);
}
try f.apply(c.grid.items);
c.cursor = f.cursor;
},
// The session has ended. Left for the caller to act on, and the
// descriptor stays open so `deinit` is the only place that closes.
.quit => {},
else => {},
}
return msg;
}
// ---- internals --------------------------------------------------------
fn reshape(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void {
c.grid.resize(c.gpa, @as(usize, cols) * @as(usize, rows)) catch return error.Closed;
// Unpainted, which a frontend draws as the terminal's own default cell.
// The full frame that follows paints over it.
@memset(c.grid.items, .{});
c.cols = cols;
c.rows = rows;
c.cursor = null;
}
/// Take everything the kernel is holding, not one chunk of it. The server
/// deliberately reads its clients ONE chunk per round, because it is
/// dividing a loop between thirty-two of them; a frontend has exactly one
/// peer, and reading one 16 KiB slice per poll would leave it a full frame
/// behind on every large one — and the session drops a frontend whose queue
/// it cannot drain (server.zig `out_backlog`).
fn fill(c: *Client) (Error || wire.Error)!void {
var buf: [read_chunk]u8 = undefined;
while (true) {
const got = libc.read(c.fd, &buf, buf.len);
if (got == 0) return error.Closed;
if (got < 0) return switch (libc.errno(got)) {
.INTR => continue,
// Nothing more is ready; what we have is what there was.
.AGAIN => {},
else => error.Closed,
};
c.in.appendSlice(c.gpa, buf[0..@intCast(got)]) catch return error.Closed;
}
}
fn flush(c: *Client) Error!void {
if (c.fd < 0) return error.Closed;
var off: usize = 0;
while (off < c.out.items.len) {
const n = libc.send(c.fd, c.out.items.ptr + off, c.out.items.len - off, nosignal);
if (n < 0) switch (libc.errno(n)) {
.INTR => continue,
// The session is not draining us. The rest waits for POLLOUT;
// the queue is bounded in practice because a frontend's output
// is keystrokes and pty chunks, never frames.
.AGAIN => break,
else => return error.Closed,
};
if (n == 0) break;
off += @intCast(n);
}
if (off == 0) return;
if (off == c.out.items.len) return c.out.clearRetainingCapacity();
std.mem.copyForwards(u8, c.out.items, c.out.items[off..]);
c.out.items.len -= off;
}
};
// The socket primitives are server.zig's, which is the file that owns this
// transport's conventions and both ends of it — see its `setNonblock`,
// `nosignal` and `poll_*`. There was a third copy of all of them here.
const nosignal = server.nosignal;
const poll_in = server.poll_in;
const poll_out = server.poll_out;
const poll_hup = server.poll_hup;
const poll_err = server.poll_err;
const poll_nval = server.poll_nval;
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// One real core, one real unix socket, real frontends. The harness runs the
// server's vtable in the order `Pardes.pump` runs it, so what these exercise is
// the transport as the core actually drives it rather than a mock of it.
const testing = std.testing;
const host_api = @import("../host.zig");
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
/// A session on a socket of its own under `.zig-cache/tmp`, so a test never
/// collides with a real session in `$XDG_RUNTIME_DIR` and never depends on that
/// variable being set at all. It is process-wide, so it is saved and restored.
const Harness = struct {
tmp: std.testing.TmpDir,
saved: ?[:0]const u8,
saved_buf: [4096:0]u8 = undefined,
core: *pardes.Pardes,
session: server.Session,
arena: std.heap.ArenaAllocator,
name_buf: [32]u8 = undefined,
name: []const u8 = &.{},
fn init(h: *Harness, cols: u16, rows: u16) !void {
h.tmp = std.testing.tmpDir(.{});
errdefer h.tmp.cleanup();
h.saved = if (libc.getenv("XDG_RUNTIME_DIR")) |v|
try std.fmt.bufPrintSentinel(&h.saved_buf, "{s}", .{std.mem.span(v)}, 0)
else
null;
errdefer h.restoreEnv();
var dir_buf: [4096:0]u8 = undefined;
const dir = try std.fmt.bufPrintSentinel(&dir_buf, ".zig-cache/tmp/{s}", .{h.tmp.sub_path}, 0);
// `ensureSocketDir` refuses anything with a bit granted to group or
// other, which is the whole point of it; a tmpDir arrives 0755.
try testing.expectEqual(@as(c_int, 0), libc.chmod(dir, 0o700));
_ = setenv("XDG_RUNTIME_DIR", dir.ptr, 1);
h.core = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = cols, .rows = rows });
errdefer h.core.deinit();
h.arena = .init(testing.allocator);
errdefer h.arena.deinit();
h.session = .{ .gpa = testing.allocator, .core = h.core, .cols = cols, .rows = rows };
h.name = "s";
try testing.expect(h.session.listen(h.name));
// The pre-loop drain tty.zig has, for its reason: the startup spawns are
// already queued and a session must not open its socket with panes that
// have not been created.
h.core.host = h.session.host();
while (h.core.nextEffect()) |e| h.core.perform(e);
}
fn restoreEnv(h: *Harness) void {
if (h.saved) |v| {
_ = setenv("XDG_RUNTIME_DIR", v.ptr, 1);
} else _ = unsetenv("XDG_RUNTIME_DIR");
}
fn deinit(h: *Harness) void {
h.session.deinit();
h.core.deinit();
h.arena.deinit();
h.restoreEnv();
h.tmp.cleanup();
}
/// `Pardes.pump`, with the one substitution a single-threaded test needs: a
/// bounded wait, so a frontend that says nothing cannot hang the suite
/// where the real session would sleep until it spoke. The queued-input
/// drain `pump` does between the two is absent because this host has no
/// queue: it calls `update` directly (borrowed bytes), and `postEvent` is
/// for hosts whose worker threads post from off the loop.
fn pump(h: *Harness) !void {
const host = h.session.host();
host.vtable.pull_wait_input.?(host.ctx, 20);
while (h.core.nextEffect()) |e| h.core.perform(e);
if (h.core.quit) return;
_ = h.arena.reset(.retain_capacity);
const surface = try h.core.render(h.arena.allocator());
host.vtable.push_present.?(host.ctx, surface);
}
/// Pump until this client has the message we are waiting for. Bounded, so a
/// broken transport fails a test rather than hanging the suite.
fn pumpUntil(h: *Harness, c: *Client, comptime want: std.meta.Tag(wire.ServerMsg)) !wire.ServerMsg {
for (0..64) |_| {
try h.pump();
try c.wait(5);
while (try c.next()) |msg| if (std.meta.activeTag(msg) == want) return msg;
}
return error.NeverArrived;
}
/// Pump until this client is sent a frame that CHANGES something. The first
/// frame after an input is not always the one carrying it — an effect the
/// input queued (a `Look` on a directory emits a spawn) lands a frame
/// later, and the frame in between legitimately says nothing.
fn pumpUntilChange(h: *Harness, c: *Client) !wire.Frame {
for (0..64) |_| {
const msg = try h.pumpUntil(c, .frame);
if (msg.frame.nruns > 0) return msg.frame;
}
return error.NothingChanged;
}
/// Pump until this client's grid is this shape, draining everything that
/// arrives. A test waits for the STATE rather than for the n-th message
/// because a resize is announced when the session settles it, which may be
/// one empty frame after the pump that caused it.
fn pumpUntilGrid(h: *Harness, c: *Client, cols: u16, rows: u16) !void {
for (0..64) |_| {
try h.pump();
try c.wait(5);
while (try c.next()) |_| {}
if (c.cols == cols and c.rows == rows) return;
}
return error.NeverResized;
}
/// Pump until two frontends are showing the same screen, draining both on
/// every pass. One pump sends every attached frontend a frame, so a test
/// that drains only one of them is comparing two different instants — and
/// what a shared session promises is that they CONVERGE, which is what this
/// waits for.
fn pumpUntilSameScreen(h: *Harness, a: *Client, b: *Client) !void {
for (0..64) |_| {
try h.pump();
try a.wait(5);
try b.wait(5);
while (try a.next()) |_| {}
while (try b.next()) |_| {}
if (sameScreen(a.grid.items, b.grid.items)) return;
}
return error.NeverConverged;
}
/// Attach a frontend and get it greeted: `open` writes the hello into the
/// listener's backlog, one pump accepts and answers it. Nothing blocks,
/// which is the whole reason the handshake is not a blocking call.
fn attach(h: *Harness, cols: u16, rows: u16) !Client {
var c = try Client.open(testing.allocator, h.name, cols, rows);
errdefer c.deinit();
_ = try h.pumpUntil(&c, .welcome);
return c;
}
};
test "detached session: a frontend attaches, is greeted, and is sent the screen" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
var c = try h.attach(60, 16);
defer c.deinit();
try testing.expectEqual(@as(u8, 0), c.slot);
try testing.expectEqual(@as(u16, 60), c.cols);
try testing.expectEqual(@as(u16, 16), c.rows);
const frame = (try h.pumpUntil(&c, .frame)).frame;
// The first frame a frontend gets must be full: it has nothing to diff
// against.
try testing.expectEqual(wire.FrameKind.full, frame.kind);
try testing.expectEqual(@as(usize, 60 * 16), c.grid.items.len);
// ...and it must be the core's own frame, cell for cell. This is the whole
// claim of the transport.
_ = h.arena.reset(.retain_capacity);
const surface = try h.core.render(h.arena.allocator());
try expectSameScreen(surface.cells, c.grid.items);
}
test "detached session: input from a frontend reaches the core and comes back as a diff" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
var c = try h.attach(60, 16);
defer c.deinit();
_ = try h.pumpUntil(&c, .frame);
// A builtin line is the cheapest input with a guaranteed visible effect,
// and it travels the same `Event` path a keystroke does.
try c.send(.{ .event = .{ .command = "Look /" } });
const frame = try h.pumpUntilChange(&c);
// The change arrived as a DIFF: this frontend was already in sync, so
// nothing it already had was re-sent.
try testing.expectEqual(wire.FrameKind.diff, frame.kind);
_ = h.arena.reset(.retain_capacity);
try expectSameScreen((try h.core.render(h.arena.allocator())).cells, c.grid.items);
}
test "detached session: two frontends share one screen at the smallest common grid" {
var h: Harness = undefined;
try h.init(80, 24);
defer h.deinit();
var a = try h.attach(80, 24);
defer a.deinit();
_ = try h.pumpUntil(&a, .frame);
// A second, smaller frontend. tmux's rule: the session shrinks to what both
// can show, because two people looking at different screens is the point of
// a shared session lost.
var b = try h.attach(50, 12);
defer b.deinit();
try testing.expectEqual(@as(u8, 1), b.slot);
try testing.expectEqual(@as(u16, 50), b.cols);
try testing.expectEqual(@as(u16, 12), b.rows);
// Both are now on the 50x12 grid — and getting there IS the proof that the
// reshape was sent as a FULL frame: `next` refuses a diff whose geometry
// does not match the grid it holds, so a client whose shape moved can only
// have been reshaped by a full one.
try h.pumpUntilGrid(&a, 50, 12);
try h.pumpUntilGrid(&b, 50, 12);
try testing.expectEqual(@as(usize, 50 * 12), a.grid.items.len);
try expectSameScreen(a.grid.items, b.grid.items);
// ...and input from EITHER moves that one screen. Both are drained on every
// pump before they are compared: one pump sends every attached frontend a
// frame, so a test that drains only one is comparing two instants.
try b.send(.{ .event = .{ .command = "Look /" } });
_ = try h.pumpUntilChange(&a);
try h.pumpUntilSameScreen(&a, &b);
}
test "detached session: a frontend that dies takes nothing with it" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
var a = try h.attach(60, 16);
defer a.deinit();
var b = try h.attach(60, 16);
_ = try h.pumpUntil(&a, .frame);
_ = try h.pumpUntil(&b, .frame);
try testing.expect(h.session.clients[0].attached);
try testing.expect(h.session.clients[1].attached);
// Not a `bye`: the socket goes away under the session's feet, which is what
// a frontend crashing or being killed looks like from here.
b.deinit();
_ = try h.pumpUntil(&a, .frame);
try testing.expect(!h.session.clients[1].attached);
try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
// The survivor is still served and the core is still running.
try testing.expect(h.session.clients[0].attached);
try testing.expect(!h.core.quit);
try a.send(.{ .event = .{ .command = "Look /" } });
try testing.expect((try h.pumpUntilChange(&a)).nruns > 0);
// The freed slot takes the next frontend, and the screen comes with it.
var d = try h.attach(60, 16);
defer d.deinit();
try testing.expectEqual(@as(u8, 1), d.slot);
try testing.expectEqual(wire.FrameKind.full, (try h.pumpUntil(&d, .frame)).frame.kind);
try expectSameScreen(a.grid.items, d.grid.items);
}
test "detached session: a frontend speaking another protocol is refused, loudly" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
// A raw socket rather than a `Client`, because the whole point is a peer
// that does not agree with `wire.version` — and `open` would already have
// sent a perfectly good hello.
const fd = try rawConnect(&h);
defer _ = libc.close(fd);
var buf: [64]u8 = undefined;
const hello = try wire.encodeClient(&buf, .{
.hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 },
});
try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal));
// Two pumps: one to accept the connection, one to read the hello and
// answer it.
try h.pump();
try h.pump();
var got: [64]u8 = undefined;
const n = libc.read(fd, &got, got.len);
try testing.expect(n > 0);
const f = (try wire.framed(got[0..@intCast(n)])).?;
try testing.expectEqual(wire.Refusal.version, (try wire.decodeServer(f.tag, f.payload)).refuse);
// Refused means refused: the slot went back and no frame was ever sent.
for (&h.session.clients) |*slot| try testing.expect(!slot.attached);
try testing.expect(!h.core.quit);
}
test "detached session: a peer that sends garbage is dropped, not obeyed" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
var c = try h.attach(60, 16);
defer c.deinit();
_ = try h.pumpUntil(&c, .frame);
// A well-formed frame around a tag this protocol has never defined. The
// session must close the connection rather than guess at it.
const junk = [_]u8{ 0xfe, 0x00, 0x00, 0x00, 0x00 };
try testing.expectEqual(@as(isize, junk.len), libc.send(c.fd, &junk, junk.len, nosignal));
for (0..8) |_| {
try h.pump();
if (!h.session.clients[0].attached) break;
}
try testing.expect(!h.session.clients[0].attached);
// The session is untouched: a hostile frontend costs a slot, not a session.
try testing.expect(!h.core.quit);
}
test "detached session: the session outlives every frontend and keeps its grid" {
var h: Harness = undefined;
try h.init(72, 20);
defer h.deinit();
{
var c = try h.attach(40, 10);
defer c.detach();
_ = try h.pumpUntil(&c, .frame);
try testing.expectEqual(@as(u16, 40), h.session.cols);
}
// Nobody attached. The grid stays where the last frontend left it rather
// than collapsing: a detached session is one nobody is looking at, not one
// of no size.
try h.pump();
try testing.expectEqual(@as(u16, 40), h.session.cols);
try testing.expectEqual(@as(u16, 10), h.session.rows);
try testing.expect(!h.core.quit);
// ...and the next frontend takes the grid over: with one attachment the
// smallest common grid IS that frontend's, so the session follows it up to
// 90x30 rather than pinning the departed one's 40x10 forever.
var again = try h.attach(90, 30);
defer again.deinit();
try testing.expectEqual(@as(u16, 90), again.cols);
try testing.expectEqual(@as(u16, 30), again.rows);
try h.pumpUntilGrid(&again, 90, 30);
try testing.expectEqual(@as(usize, 90 * 30), again.grid.items.len);
}
test "detached session: the seam's own routing rules, per method" {
var h: Harness = undefined;
try h.init(60, 16);
defer h.deinit();
var a = try h.attach(60, 16);
defer a.deinit();
var b = try h.attach(60, 16);
defer b.deinit();
_ = try h.pumpUntil(&a, .frame);
_ = try h.pumpUntil(&b, .frame);
const host = h.session.host();
// The eight `push_` methods with one real resource behind them go to the
// PRIMARY only — the oldest surviving attachment — because two frontends
// forking a shell for one pane gives that pane two shells.
host.vtable.push_spawn.?(host.ctx, 1, "/tmp");
try expectOnly(&h, &a, &b, .spawn);
host.vtable.push_pty_write.?(host.ctx, 1, "ls\n");
try expectOnly(&h, &a, &b, .pty_write);
host.vtable.push_write_file.?(host.ctx, 1, "/tmp/x", "body");
try expectOnly(&h, &a, &b, .write_file);
// ...and the ones that are facts about the SESSION go to everybody.
host.vtable.push_set_clipboard.?(host.ctx, "yank");
try expectBoth(&h, &a, &b, .set_clipboard);
// The one pull on the wire goes to the frontend whose input caused it, and
// it is asked ONCE — two frontends answering would paste twice for one
// Ctrl-V, which is the rule host.zig states.
h.session.origin = 1;
host.vtable.pull_read_clipboard.?(host.ctx);
try expectOnly(&h, &b, &a, .read_clipboard);
h.session.origin = 0;
host.vtable.push_open_link.?(host.ctx, "https://x");
try expectOnly(&h, &a, &b, .open_link);
}
test "detached session: the client table is a refusal, not a queue" {
// A small grid on purpose: these thirty-two peers never read, and a full
// frame of 80x24 each would push them into the backlog rule that the next
// test is about. 20x5 keeps every queue in the kernel's own buffer.
var h: Harness = undefined;
try h.init(20, 5);
defer h.deinit();
var fds: [server.max_clients]c_int = @splat(-1);
defer for (fds) |fd| if (fd >= 0) {
_ = libc.close(fd);
};
var buf: [64]u8 = undefined;
const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 20, .rows = 5 } });
for (&fds) |*fd| {
fd.* = try rawConnect(&h);
try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd.*, hello.ptr, hello.len, nosignal));
}
// The listener's backlog is `max_clients` deep, so this takes a few rounds:
// `accept` drains what is there each time it is woken.
for (0..16) |_| {
try h.pump();
var attached: usize = 0;
for (&h.session.clients) |*slot| if (slot.attached) {
attached += 1;
};
if (attached == server.max_clients) break;
}
for (&h.session.clients) |*slot| try testing.expect(slot.attached);
// The thirty-third is TOLD it does not fit, and told promptly: the
// alternative — leaving it in the backlog — makes a level-triggered poll
// report the listener ready forever and spins the core.
const extra = try rawConnect(&h);
defer _ = libc.close(extra);
try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(extra, hello.ptr, hello.len, nosignal));
var got: [64]u8 = undefined;
const refusal = for (0..8) |_| {
try h.pump();
const n = libc.read(extra, &got, got.len);
if (n > 0) break got[0..@intCast(n)];
} else return error.NeverRefused;
const f = (try wire.framed(refusal)).?;
try testing.expectEqual(wire.Refusal.full, (try wire.decodeServer(f.tag, f.payload)).refuse);
// ...and the thirty-two it does serve are untouched.
for (&h.session.clients) |*slot| try testing.expect(slot.attached);
try testing.expect(!h.core.quit);
}
test "detached session: a frontend that stops reading is dropped, not waited for" {
var h: Harness = undefined;
try h.init(40, 10);
defer h.deinit();
var good = try h.attach(40, 10);
defer good.deinit();
// A peer that says hello and then never reads a byte again — a frontend
// stopped in a debugger, or one whose terminal is blocked.
const mute = try rawConnect(&h);
defer _ = libc.close(mute);
var buf: [64]u8 = undefined;
const hello = try wire.encodeClient(&buf, .{ .hello = .{ .cols = 40, .rows = 10 } });
try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(mute, hello.ptr, hello.len, nosignal));
for (0..8) |_| {
try h.pump();
if (h.session.clients[1].attached) break;
}
try testing.expect(h.session.clients[1].attached);
// Broadcast enough control traffic to pass `out_backlog`. A clipboard
// mirror is the honest vehicle: it is a real `push_` that reaches every
// frontend and carries the yank register, so this is a session yanking a
// lot rather than a synthetic poke.
const text = try testing.allocator.alloc(u8, 256 * 1024);
defer testing.allocator.free(text);
@memset(text, 'y');
const host = h.session.host();
for (0..24) |_| {
if (!h.session.clients[1].attached) break;
host.vtable.push_set_clipboard.?(host.ctx, text);
try h.pump();
try good.wait(5);
while (try good.next()) |_| {}
}
// Dropped rather than queued without bound, and rather than the core
// blocking on it.
try testing.expect(!h.session.clients[1].attached);
try testing.expectEqual(@as(c_int, -1), h.session.clients[1].fd);
// The frontend that WAS reading is still attached and still being drawn
// for, which is the whole claim: one slow peer costs its own slot.
try testing.expect(h.session.clients[0].attached);
try testing.expect(!h.core.quit);
try good.send(.{ .event = .{ .command = "Msg still here" } });
try testing.expect((try h.pumpUntilChange(&good)).nruns > 0);
}
/// A connected socket with nothing said on it yet, for the tests whose peer is
/// deliberately not a `Client`: one that speaks another protocol, thirty-two
/// that fill the table, one that never reads.
fn rawConnect(h: *Harness) !c_int {
var path_buf: [server.path_max]u8 = undefined;
const path = server.sessionPath(&path_buf, h.name).?;
var addr: libc.sockaddr.un = .{ .path = @splat(0) };
@memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]);
const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0);
if (fd < 0) return error.NoSocket;
errdefer _ = libc.close(fd);
if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.NoSession;
return fd;
}
/// `want` reached `to` and nothing reached `other`.
fn expectOnly(
h: *Harness,
to: *Client,
other: *Client,
comptime want: std.meta.Tag(wire.ServerMsg),
) !void {
_ = try h.pumpUntil(to, want);
try other.wait(5);
while (try other.next()) |msg| if (std.meta.activeTag(msg) == want) {
std.debug.print("{t} reached a frontend it was not routed to\n", .{want});
return error.Misrouted;
};
}
fn expectBoth(
h: *Harness,
a: *Client,
b: *Client,
comptime want: std.meta.Tag(wire.ServerMsg),
) !void {
_ = try h.pumpUntil(a, want);
_ = try h.pumpUntil(b, want);
}
/// Are these two grids showing the same thing? `visuallyEqual` and not
/// `std.meta.eql`, because `Cell.text` past `len` is scratch the decoder does
/// not invent — pardes.zig says in as many words that it must never
/// manufacture a difference.
fn sameScreen(want: []const pardes.Cell, have: []const pardes.Cell) bool {
if (want.len != have.len) return false;
for (want, have) |*x, *y| if (!x.visuallyEqual(y)) return false;
return true;
}
fn expectSameScreen(want: []const pardes.Cell, have: []const pardes.Cell) !void {
try testing.expectEqual(want.len, have.len);
for (want, have, 0..) |*x, *y, i| if (!x.visuallyEqual(y)) {
std.debug.print("cell {d} differs\n", .{i});
return error.CellMismatch;
};
}
|