54fd740a
dial.ask and a decoder for upgrade_reply
a73x 2026-08-31 17:06
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -251,9 +251,13 @@ const mod_table = [_]ModSpec{ | |||
| 251 | // no-arg client opens, `term` for `term.protocol`'s SessionName.parseCLI | 251 | // no-arg client opens, `term` for `term.protocol`'s SessionName.parseCLI |
| 252 | // (a bad --session is a usage error at parse, not bytes a daemon | 252 | // (a bad --session is a usage error at parse, not bytes a daemon |
| 253 | // downstream must refuse) and `sockpath` for the sun_path bound every verb | 253 | // downstream must refuse) and `sockpath` for the sun_path bound every verb |
| 254 | // checks before acting on a path. `testtmp` is the keygen round-trip's: | 254 | // checks before acting on a path. `dial` is how `mux d`'s observer verbs |
| 255 | // it needs a directory to generate into, which the daemon never touches. | 255 | // — stats, dump, endpoint, upgrade — ask their one question: the same |
| 256 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 256 | // round trip the client and the agent already reach for, rather than a |
| 257 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen | ||
| 258 | // round-trip's: it needs a directory to generate into, which the daemon | ||
| 259 | // never touches. | ||
| 260 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | ||
| 257 | }; | 261 | }; |
| 258 | 262 | ||
| 259 | /// Comptime row lookup. Every hand-written module name in this file goes | 263 | /// Comptime row lookup. Every hand-written module name in this file goes |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ const handoff = @import("client").handoff; | |||
| 17 | const sockpath = @import("sockpath"); | 17 | const sockpath = @import("sockpath"); |
| 18 | const upgrade = @import("daemon").upgrade; | 18 | const upgrade = @import("daemon").upgrade; |
| 19 | const cliflags = @import("cliflags"); | 19 | const cliflags = @import("cliflags"); |
| 20 | const dial = @import("dial"); | ||
| 20 | 21 | ||
| 21 | const usage = | 22 | const usage = |
| 22 | \\usage: | 23 | \\usage: |
| @@ -583,45 +584,6 @@ fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8 | |||
| 583 | return try srv.run(); | 584 | return try srv.run(); |
| 584 | } | 585 | } |
| 585 | 586 | ||
| 586 | /// Policy for an expected frame with an empty payload. Dump and stats accept an | ||
| 587 | /// empty result, while upgrade requires a status byte and continues waiting. | ||
| 588 | const EmptyPayloadPolicy = enum { is_the_answer, keeps_waiting }; | ||
| 589 | |||
| 590 | /// Send one request and read until a frame of type `want` arrives. Return null | ||
| 591 | /// for timeout, EOF, or other no-reply conditions; a null deadline blocks. | ||
| 592 | fn askOnce( | ||
| 593 | alloc: std.mem.Allocator, | ||
| 594 | fd: std.posix.fd_t, | ||
| 595 | req: proto.MsgType, | ||
| 596 | payload: []const u8, | ||
| 597 | want: proto.MsgType, | ||
| 598 | deadline_ms: ?u32, | ||
| 599 | empty: EmptyPayloadPolicy, | ||
| 600 | ) !?proto.Frame { | ||
| 601 | // Preserve a distinct send error because upgrade reports a request that | ||
| 602 | // could not be delivered differently from a missing reply. | ||
| 603 | proto.writeFrame(fd, req, payload) catch return error.RequestNotSent; | ||
| 604 | const deadline: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null; | ||
| 605 | while (true) { | ||
| 606 | if (deadline) |end| { | ||
| 607 | const left = end - std.time.milliTimestamp(); | ||
| 608 | if (left <= 0) return null; | ||
| 609 | var fds = [_]std.posix.pollfd{ | ||
| 610 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 611 | }; | ||
| 612 | if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null; | ||
| 613 | if (fds[0].revents == 0) continue; | ||
| 614 | } | ||
| 615 | // The frame read may extend past the poll deadline if a peer writes only | ||
| 616 | // part of a frame. Treat malformed or partial frames as errors rather | ||
| 617 | // than as an absent reply. | ||
| 618 | const frame = (try proto.readFrame(alloc, fd)) orelse return null; | ||
| 619 | if (frame.type == want and | ||
| 620 | !(empty == .keeps_waiting and frame.payload.len == 0)) return frame; | ||
| 621 | frame.deinit(alloc); | ||
| 622 | } | ||
| 623 | } | ||
| 624 | |||
| 625 | /// Perform an unbounded one-shot query. A connected daemon that stops replying | 587 | /// Perform an unbounded one-shot query. A connected daemon that stops replying |
| 626 | /// remains a visible hang rather than being reported like an absent socket. | 588 | /// remains a visible hang rather than being reported like an absent socket. |
| 627 | fn oneShotQuery( | 589 | fn oneShotQuery( |
| @@ -632,16 +594,19 @@ fn oneShotQuery( | |||
| 632 | req_payload: []const u8, | 594 | req_payload: []const u8, |
| 633 | want: proto.MsgType, | 595 | want: proto.MsgType, |
| 634 | ) !u8 { | 596 | ) !u8 { |
| 635 | const stream = std.net.connectUnixSocket(sock_path) catch { | 597 | const frame = (dial.ask(alloc, sock_path, req, req_payload, want, null) catch |e| switch (e) { |
| 636 | std.debug.print( | 598 | error.NoDaemon => { |
| 637 | "mux d {s}: nothing listening on {s} (`mux d start -d` starts one)\n", | 599 | std.debug.print( |
| 638 | .{ verb, sock_path }, | 600 | "mux d {s}: nothing listening on {s} (`mux d start -d` starts one)\n", |
| 639 | ); | 601 | .{ verb, sock_path }, |
| 640 | return 1; | 602 | ); |
| 641 | }; | 603 | return 1; |
| 642 | defer stream.close(); | 604 | }, |
| 643 | 605 | // A reply this side could not read, or a daemon that hung up over the | |
| 644 | const frame = (try askOnce(alloc, stream.handle, req, req_payload, want, null, .is_the_answer)) orelse return 1; | 606 | // request: both stay errors, so a broken daemon never reads as an |
| 607 | // absent one. | ||
| 608 | else => return e, | ||
| 609 | }) orelse return 1; | ||
| 645 | defer frame.deinit(alloc); | 610 | defer frame.deinit(alloc); |
| 646 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); | 611 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); |
| 647 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); | 612 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); |
| @@ -748,12 +713,6 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) | |||
| 748 | return 1; | 713 | return 1; |
| 749 | }; | 714 | }; |
| 750 | 715 | ||
| 751 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 752 | std.debug.print("mux d upgrade: nothing listening on {s}\n", .{sock_path}); | ||
| 753 | return 1; | ||
| 754 | }; | ||
| 755 | defer stream.close(); | ||
| 756 | |||
| 757 | var buf: [std.fs.max_path_bytes + 64]u8 = undefined; | 716 | var buf: [std.fs.max_path_bytes + 64]u8 = undefined; |
| 758 | const payload = proto.encodeUpgradeReq(&buf, .{ | 717 | const payload = proto.encodeUpgradeReq(&buf, .{ |
| 759 | .allow_same_version = allow_same, | 718 | .allow_same_version = allow_same, |
| @@ -767,28 +726,34 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) | |||
| 767 | // frame without a word: the expiry is a diagnosis, not a timeout. So is | 726 | // frame without a word: the expiry is a diagnosis, not a timeout. So is |
| 768 | // EOF — an older daemon that drops the connection over a frame it | 727 | // EOF — an older daemon that drops the connection over a frame it |
| 769 | // cannot read reaches the same conclusion silence does. | 728 | // cannot read reaches the same conclusion silence does. |
| 770 | const reply = askOnce(alloc, stream.handle, .upgrade_req, payload, .upgrade_reply, 5000, .keeps_waiting) catch |e| ask: { | 729 | const reply = dial.ask(alloc, sock_path, .upgrade_req, payload, .upgrade_reply, 5000) catch |e| ask: { |
| 771 | if (e == error.RequestNotSent) { | 730 | switch (e) { |
| 772 | std.debug.print("mux d upgrade: {s} closed before the request landed\n", .{sock_path}); | 731 | error.NoDaemon => { |
| 773 | return 1; | 732 | std.debug.print("mux d upgrade: nothing listening on {s}\n", .{sock_path}); |
| 733 | return 1; | ||
| 734 | }, | ||
| 735 | error.RequestNotSent => { | ||
| 736 | std.debug.print("mux d upgrade: {s} closed before the request landed\n", .{sock_path}); | ||
| 737 | return 1; | ||
| 738 | }, | ||
| 739 | // Treat an unreadable reply like timeout or EOF: no usable upgrade | ||
| 740 | // response was received. | ||
| 741 | else => break :ask null, | ||
| 774 | } | 742 | } |
| 775 | // Treat an unreadable reply like timeout or EOF: no usable upgrade | ||
| 776 | // response was received. | ||
| 777 | break :ask null; | ||
| 778 | }; | 743 | }; |
| 779 | // `.keeps_waiting` filters empty upgrade replies. Keep this payload bounds | 744 | // A frame with no status byte is not an answer — `parseUpgradeReply` says |
| 780 | // check and classify any remaining empty frame as no reply. | 745 | // so — and joins timeout and EOF at the "no reply" line below. |
| 781 | if (reply) |frame| skip: { | 746 | if (reply) |frame| skip: { |
| 782 | defer frame.deinit(alloc); | 747 | defer frame.deinit(alloc); |
| 783 | if (frame.payload.len == 0) break :skip; | 748 | const answer = proto.parseUpgradeReply(frame.payload) orelse break :skip; |
| 784 | if (frame.payload[0] != 0) { | 749 | if (!answer.ok) { |
| 785 | // The daemon's words, verbatim: it is the side that knows which | 750 | // The daemon's words, verbatim: it is the side that knows which |
| 786 | // check failed, and paraphrasing here would lose the versions. | 751 | // check failed, and paraphrasing here would lose the versions. |
| 787 | std.debug.print("mux d upgrade: refused: {s}\n", .{frame.payload[1..]}); | 752 | std.debug.print("mux d upgrade: refused: {s}\n", .{answer.reason}); |
| 788 | // Add compatibility context when version probing fails: v0.0.1-15 | 753 | // Add compatibility context when version probing fails: v0.0.1-15 |
| 789 | // and older expect `muxd <version>`, while this binary reports | 754 | // and older expect `muxd <version>`, while this binary reports |
| 790 | // `mux <version>`. | 755 | // `mux <version>`. |
| 791 | if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch")) | 756 | if (std.mem.eql(u8, answer.reason, "version: output mismatch")) |
| 792 | std.debug.print( | 757 | std.debug.print( |
| 793 | "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++ | 758 | "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++ |
| 794 | "candidate that prints `muxd <version>` and this one is `mux`. " ++ | 759 | "candidate that prints `muxd <version>` and this one is `mux`. " ++ |
| @@ -814,14 +779,14 @@ fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { | |||
| 814 | // A connect is insufficient because the listener descriptor survives exec | 779 | // A connect is insufficient because the listener descriptor survives exec |
| 815 | // and can queue connections throughout handover. A valid reply proves that | 780 | // and can queue connections throughout handover. A valid reply proves that |
| 816 | // the new image is accepting and processing frames. | 781 | // the new image is accepting and processing frames. |
| 817 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 818 | std.debug.print("mux d upgrade: {s} stopped answering after the exec\n", .{sock_path}); | ||
| 819 | return 1; | ||
| 820 | }; | ||
| 821 | defer stream.close(); | ||
| 822 | |||
| 823 | const deadline_ms: u32 = 5000; | 782 | const deadline_ms: u32 = 5000; |
| 824 | if (askOnce(alloc, stream.handle, .stats_req, "", .stats_reply, deadline_ms, .is_the_answer) catch return 1) |frame| { | 783 | if (dial.ask(alloc, sock_path, .stats_req, "", .stats_reply, deadline_ms) catch |e| switch (e) { |
| 784 | error.NoDaemon => { | ||
| 785 | std.debug.print("mux d upgrade: {s} stopped answering after the exec\n", .{sock_path}); | ||
| 786 | return 1; | ||
| 787 | }, | ||
| 788 | else => return 1, | ||
| 789 | }) |frame| { | ||
| 825 | frame.deinit(alloc); | 790 | frame.deinit(alloc); |
| 826 | return 0; | 791 | return 0; |
| 827 | } | 792 | } |
| @@ -1011,16 +976,13 @@ fn reportKeyRefusal(path: []const u8, err: anyerror) void { | |||
| 1011 | /// failure so old daemons that ignore the request fall back to SSH instead of | 976 | /// failure so old daemons that ignore the request fall back to SSH instead of |
| 1012 | /// hanging. | 977 | /// hanging. |
| 1013 | fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { | 978 | fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { |
| 1014 | const stream = std.net.connectUnixSocket(sock_path) catch return 0; | 979 | const frame = (dial.ask( |
| 1015 | defer stream.close(); | ||
| 1016 | const frame = (askOnce( | ||
| 1017 | alloc, | 980 | alloc, |
| 1018 | stream.handle, | 981 | sock_path, |
| 1019 | .endpoint_req, | 982 | .endpoint_req, |
| 1020 | "", | 983 | "", |
| 1021 | .endpoint_reply, | 984 | .endpoint_reply, |
| 1022 | start_deadline_ms, | 985 | start_deadline_ms, |
| 1023 | .is_the_answer, | ||
| 1024 | ) catch null) orelse return 0; | 986 | ) catch null) orelse return 0; |
| 1025 | defer frame.deinit(alloc); | 987 | defer frame.deinit(alloc); |
| 1026 | return proto.decodeEndpointReply(frame.payload) catch 0; | 988 | return proto.decodeEndpointReply(frame.payload) catch 0; |
| @@ -1721,86 +1683,6 @@ test "endpointCmd: polling an absent daemon does not start one" { | |||
| 1721 | try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size); | 1683 | try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size); |
| 1722 | } | 1684 | } |
| 1723 | 1685 | ||
| 1724 | test "askOnce: a deadline gives up on silence, and no deadline waits out a late reply" { | ||
| 1725 | const alloc = std.testing.allocator; | ||
| 1726 | |||
| 1727 | // A socket pair models the daemon and controls whether and when a reply | ||
| 1728 | // arrives. | ||
| 1729 | { | ||
| 1730 | var pair: [2]i32 = undefined; | ||
| 1731 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 1732 | defer std.posix.close(pair[0]); | ||
| 1733 | defer std.posix.close(pair[1]); | ||
| 1734 | const t0 = std.time.milliTimestamp(); | ||
| 1735 | try std.testing.expect((try askOnce(alloc, pair[0], .stats_req, "", .stats_reply, 100, .is_the_answer)) == null); | ||
| 1736 | // Verify that silence consumes the deadline before returning null. | ||
| 1737 | try std.testing.expect(std.time.milliTimestamp() - t0 >= 100); | ||
| 1738 | } | ||
| 1739 | |||
| 1740 | // Without a deadline, wait for a delayed reply. | ||
| 1741 | { | ||
| 1742 | var pair: [2]i32 = undefined; | ||
| 1743 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 1744 | defer std.posix.close(pair[0]); | ||
| 1745 | const Late = struct { | ||
| 1746 | fn run(fd: std.posix.fd_t) void { | ||
| 1747 | std.Thread.sleep(150 * std.time.ns_per_ms); | ||
| 1748 | // Ignore an unrelated frame before returning the requested type. | ||
| 1749 | proto.writeFrame(fd, .stats_req, "") catch {}; | ||
| 1750 | proto.writeFrame(fd, .stats_reply, "late") catch {}; | ||
| 1751 | std.posix.close(fd); | ||
| 1752 | } | ||
| 1753 | }; | ||
| 1754 | const th = try std.Thread.spawn(.{}, Late.run, .{pair[1]}); | ||
| 1755 | defer th.join(); | ||
| 1756 | const t0 = std.time.milliTimestamp(); | ||
| 1757 | const frame = (try askOnce(alloc, pair[0], .stats_req, "", .stats_reply, null, .is_the_answer)).?; | ||
| 1758 | defer frame.deinit(alloc); | ||
| 1759 | try std.testing.expectEqualStrings("late", frame.payload); | ||
| 1760 | try std.testing.expect(std.time.milliTimestamp() - t0 >= 150); | ||
| 1761 | } | ||
| 1762 | } | ||
| 1763 | |||
| 1764 | test "askOnce: an empty payload is the answer for stats and not for upgrade" { | ||
| 1765 | const alloc = std.testing.allocator; | ||
| 1766 | |||
| 1767 | // Exercise both policies against the same pair of reply frames. | ||
| 1768 | for ([_]EmptyPayloadPolicy{ .is_the_answer, .keeps_waiting }) |empty| { | ||
| 1769 | var pair: [2]i32 = undefined; | ||
| 1770 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 1771 | defer std.posix.close(pair[0]); | ||
| 1772 | defer std.posix.close(pair[1]); | ||
| 1773 | try proto.writeFrame(pair[1], .upgrade_reply, ""); | ||
| 1774 | try proto.writeFrame(pair[1], .upgrade_reply, &.{0}); | ||
| 1775 | |||
| 1776 | const frame = (try askOnce(alloc, pair[0], .upgrade_req, "", .upgrade_reply, 500, empty)).?; | ||
| 1777 | defer frame.deinit(alloc); | ||
| 1778 | // Upgrade requires payload[0] for its status and therefore skips the | ||
| 1779 | // empty reply; stats accepts it. | ||
| 1780 | switch (empty) { | ||
| 1781 | .is_the_answer => try std.testing.expectEqual(@as(usize, 0), frame.payload.len), | ||
| 1782 | .keeps_waiting => try std.testing.expectEqualSlices(u8, &.{0}, frame.payload), | ||
| 1783 | } | ||
| 1784 | } | ||
| 1785 | } | ||
| 1786 | |||
| 1787 | test "askOnce: a frame this side cannot read is an error, never silence" { | ||
| 1788 | // Preserve corrupt-frame errors so dump and stats distinguish a broken | ||
| 1789 | // daemon response from an absent daemon. | ||
| 1790 | var pair: [2]i32 = undefined; | ||
| 1791 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 1792 | defer std.posix.close(pair[0]); | ||
| 1793 | defer std.posix.close(pair[1]); | ||
| 1794 | var hdr: [5]u8 = undefined; | ||
| 1795 | hdr[0] = @intFromEnum(proto.MsgType.stats_reply); | ||
| 1796 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 1797 | try proto.writeAllFd(pair[1], &hdr); | ||
| 1798 | try std.testing.expectError( | ||
| 1799 | error.FrameTooLarge, | ||
| 1800 | askOnce(std.testing.allocator, pair[0], .stats_req, "", .stats_reply, null, .is_the_answer), | ||
| 1801 | ); | ||
| 1802 | } | ||
| 1803 | |||
| 1804 | test "oneShotQuery: a socket nobody serves is exit 1" { | 1686 | test "oneShotQuery: a socket nobody serves is exit 1" { |
| 1805 | const testtmp = @import("testtmp"); | 1687 | const testtmp = @import("testtmp"); |
| 1806 | var tmp = try testtmp.TmpDir.make(); | 1688 | var tmp = try testtmp.TmpDir.make(); |
src/dial.zig
| Old | New | ||
|---|---|---|---|
| @@ -46,6 +46,78 @@ pub fn dialAttachNamed(sock_path: []const u8, cols: u16, rows: u16, name: []cons | |||
| 46 | return s; | 46 | return s; |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | /// Dial, ask one question, read the one answer, hang up. The observer verbs' | ||
| 50 | /// round trip: `stats_req`, `sessions_req`, `debug_dump`, `endpoint_req`, | ||
| 51 | /// `upgrade_req` — every one of them a question asked by a caller that holds | ||
| 52 | /// no other connection to that daemon and wants none after the answer. | ||
| 53 | /// | ||
| 54 | /// Null is "no answer": the deadline ran out, or the peer closed without | ||
| 55 | /// sending one. A null `deadline_ms` waits forever, which is what a caller | ||
| 56 | /// wants when a daemon that has stopped replying should be a visible hang | ||
| 57 | /// rather than a report of an absent socket. | ||
| 58 | /// | ||
| 59 | /// Two failures are named so the caller does not have to tell them apart from | ||
| 60 | /// a connect errno set: `error.NoDaemon` is the dial, and | ||
| 61 | /// `error.RequestNotSent` is a peer that closed between the connect and the | ||
| 62 | /// write. Both mean the daemon never heard the question, and every caller has | ||
| 63 | /// its own words for that — which is why this returns the distinction instead | ||
| 64 | /// of printing it. Any other error is a reply this side could not read, and | ||
| 65 | /// stays an error precisely so a corrupt frame is never reported as silence. | ||
| 66 | pub fn ask( | ||
| 67 | alloc: std.mem.Allocator, | ||
| 68 | sock_path: []const u8, | ||
| 69 | req: proto.MsgType, | ||
| 70 | payload: []const u8, | ||
| 71 | want: proto.MsgType, | ||
| 72 | deadline_ms: ?u32, | ||
| 73 | ) !?proto.Frame { | ||
| 74 | const s = dial(sock_path) catch return error.NoDaemon; | ||
| 75 | defer s.close(); | ||
| 76 | return askOn(alloc, s.handle, req, payload, want, deadline_ms); | ||
| 77 | } | ||
| 78 | |||
| 79 | /// `ask` once the connection exists. Private on purpose: this loop DROPS every | ||
| 80 | /// frame that is not the one it was told to wait for, which is right for a | ||
| 81 | /// socket opened to ask one question and wrong for a client connection | ||
| 82 | /// carrying a snapshot and its deltas. The tests below reach it through a | ||
| 83 | /// socketpair — the one fd of this shape that no dial produced. | ||
| 84 | fn askOn( | ||
| 85 | alloc: std.mem.Allocator, | ||
| 86 | fd: std.posix.fd_t, | ||
| 87 | req: proto.MsgType, | ||
| 88 | payload: []const u8, | ||
| 89 | want: proto.MsgType, | ||
| 90 | deadline_ms: ?u32, | ||
| 91 | ) !?proto.Frame { | ||
| 92 | // A request that could not be delivered is its own answer: the caller | ||
| 93 | // reports a daemon that never heard the question differently from one | ||
| 94 | // that heard it and said nothing. | ||
| 95 | proto.writeFrame(fd, req, payload) catch return error.RequestNotSent; | ||
| 96 | const deadline: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null; | ||
| 97 | while (true) { | ||
| 98 | if (deadline) |end| { | ||
| 99 | const left = end - std.time.milliTimestamp(); | ||
| 100 | if (left <= 0) return null; | ||
| 101 | var fds = [_]std.posix.pollfd{ | ||
| 102 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 103 | }; | ||
| 104 | if ((std.posix.poll(&fds, @intCast(left)) catch return null) == 0) return null; | ||
| 105 | if (fds[0].revents == 0) continue; | ||
| 106 | } | ||
| 107 | // The frame read may extend past the poll deadline if a peer writes | ||
| 108 | // only part of a frame. A malformed or partial frame is an error | ||
| 109 | // rather than an absent reply. | ||
| 110 | const frame = (try proto.readFrame(alloc, fd)) orelse return null; | ||
| 111 | // An empty payload of the wanted type IS the answer here. What an | ||
| 112 | // empty reply means belongs to that verb's decoder — `stats_reply` | ||
| 113 | // says "nothing to report" with one, `parseUpgradeReply` calls one no | ||
| 114 | // answer at all — and a transport that guessed for them would have to | ||
| 115 | // be told the verb's policy by every caller. | ||
| 116 | if (frame.type == want) return frame; | ||
| 117 | frame.deinit(alloc); | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 49 | // A daemon of our own is the server suite's business, not this module's: | 121 | // A daemon of our own is the server suite's business, not this module's: |
| 50 | // what is worth pinning without one is that a path nobody bound fails as a | 122 | // what is worth pinning without one is that a path nobody bound fails as a |
| 51 | // dial — an error the caller can report — rather than blocking or reaching | 123 | // dial — an error the caller can report — rather than blocking or reaching |
| @@ -58,3 +130,86 @@ test "the attach helpers fail at the dial, before any frame" { | |||
| 58 | try std.testing.expectError(error.FileNotFound, dialAttach("/nonexistent-dir/mux-dial-test.sock", 80, 24)); | 130 | try std.testing.expectError(error.FileNotFound, dialAttach("/nonexistent-dir/mux-dial-test.sock", 80, 24)); |
| 59 | try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work")); | 131 | try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work")); |
| 60 | } | 132 | } |
| 133 | |||
| 134 | test "ask: a path nothing is bound at is error.NoDaemon, not a connect errno" { | ||
| 135 | try std.testing.expectError( | ||
| 136 | error.NoDaemon, | ||
| 137 | ask(std.testing.allocator, "/nonexistent-dir/mux-dial-test.sock", .stats_req, "", .stats_reply, 100), | ||
| 138 | ); | ||
| 139 | } | ||
| 140 | |||
| 141 | test "askOn: a deadline gives up on silence, and no deadline waits out a late reply" { | ||
| 142 | const alloc = std.testing.allocator; | ||
| 143 | |||
| 144 | // A socket pair models the daemon and controls whether and when a reply | ||
| 145 | // arrives. | ||
| 146 | { | ||
| 147 | var pair: [2]i32 = undefined; | ||
| 148 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 149 | defer std.posix.close(pair[0]); | ||
| 150 | defer std.posix.close(pair[1]); | ||
| 151 | const t0 = std.time.milliTimestamp(); | ||
| 152 | try std.testing.expect((try askOn(alloc, pair[0], .stats_req, "", .stats_reply, 100)) == null); | ||
| 153 | // Verify that silence consumes the deadline before returning null. | ||
| 154 | try std.testing.expect(std.time.milliTimestamp() - t0 >= 100); | ||
| 155 | } | ||
| 156 | |||
| 157 | // Without a deadline, wait for a delayed reply. | ||
| 158 | { | ||
| 159 | var pair: [2]i32 = undefined; | ||
| 160 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 161 | defer std.posix.close(pair[0]); | ||
| 162 | const Late = struct { | ||
| 163 | fn run(fd: std.posix.fd_t) void { | ||
| 164 | std.Thread.sleep(150 * std.time.ns_per_ms); | ||
| 165 | // Ignore an unrelated frame before returning the requested type. | ||
| 166 | proto.writeFrame(fd, .stats_req, "") catch {}; | ||
| 167 | proto.writeFrame(fd, .stats_reply, "late") catch {}; | ||
| 168 | std.posix.close(fd); | ||
| 169 | } | ||
| 170 | }; | ||
| 171 | const th = try std.Thread.spawn(.{}, Late.run, .{pair[1]}); | ||
| 172 | defer th.join(); | ||
| 173 | const t0 = std.time.milliTimestamp(); | ||
| 174 | const frame = (try askOn(alloc, pair[0], .stats_req, "", .stats_reply, null)).?; | ||
| 175 | defer frame.deinit(alloc); | ||
| 176 | try std.testing.expectEqualStrings("late", frame.payload); | ||
| 177 | try std.testing.expect(std.time.milliTimestamp() - t0 >= 150); | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | test "askOn: an empty payload of the wanted type is the answer, not a frame to skip" { | ||
| 182 | // The verb's decoder decides what an empty reply means. This loop used to | ||
| 183 | // carry a per-caller policy so that `upgrade_reply` could keep waiting | ||
| 184 | // through one; `protocol.parseUpgradeReply` now answers that question | ||
| 185 | // where the rest of the upgrade wire is read. | ||
| 186 | const alloc = std.testing.allocator; | ||
| 187 | var pair: [2]i32 = undefined; | ||
| 188 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 189 | defer std.posix.close(pair[0]); | ||
| 190 | defer std.posix.close(pair[1]); | ||
| 191 | try proto.writeFrame(pair[1], .upgrade_reply, ""); | ||
| 192 | try proto.writeFrame(pair[1], .upgrade_reply, &.{0}); | ||
| 193 | |||
| 194 | const frame = (try askOn(alloc, pair[0], .upgrade_req, "", .upgrade_reply, 500)).?; | ||
| 195 | defer frame.deinit(alloc); | ||
| 196 | try std.testing.expectEqual(@as(usize, 0), frame.payload.len); | ||
| 197 | try std.testing.expect(proto.parseUpgradeReply(frame.payload) == null); | ||
| 198 | } | ||
| 199 | |||
| 200 | test "askOn: a frame this side cannot read is an error, never silence" { | ||
| 201 | // Preserve corrupt-frame errors so dump and stats distinguish a broken | ||
| 202 | // daemon response from an absent daemon. | ||
| 203 | var pair: [2]i32 = undefined; | ||
| 204 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 205 | defer std.posix.close(pair[0]); | ||
| 206 | defer std.posix.close(pair[1]); | ||
| 207 | var hdr: [5]u8 = undefined; | ||
| 208 | hdr[0] = @intFromEnum(proto.MsgType.stats_reply); | ||
| 209 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 210 | try proto.writeAllFd(pair[1], &hdr); | ||
| 211 | try std.testing.expectError( | ||
| 212 | error.FrameTooLarge, | ||
| 213 | askOn(std.testing.allocator, pair[0], .stats_req, "", .stats_reply, null), | ||
| 214 | ); | ||
| 215 | } | ||
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -328,6 +328,36 @@ pub fn parseUpgradeReq(payload: []const u8) error{BadPayload}!UpgradeReq { | |||
| 328 | }; | 328 | }; |
| 329 | } | 329 | } |
| 330 | 330 | ||
| 331 | // `upgrade_reply`: the daemon's yes or no, and — when it is a no — the words | ||
| 332 | // for it. The reason is the daemon's own prose because it is the side that | ||
| 333 | // knows which check failed; the client quotes it rather than paraphrasing. | ||
| 334 | pub const UpgradeReply = struct { | ||
| 335 | ok: bool, | ||
| 336 | /// Empty when the daemon accepted, or when it refused without saying why. | ||
| 337 | reason: []const u8, | ||
| 338 | }; | ||
| 339 | |||
| 340 | /// The daemon's reply buffer. A reason longer than this is truncated rather | ||
| 341 | /// than refused: the bytes are prose for a person reading a terminal, and a | ||
| 342 | /// truncated explanation beats a refusal that never reaches them. | ||
| 343 | pub const upgrade_reply_max_len = 256; | ||
| 344 | |||
| 345 | pub fn encodeUpgradeReply(buf: []u8, reply: UpgradeReply) []const u8 { | ||
| 346 | buf[0] = if (reply.ok) 0 else 1; | ||
| 347 | const n = @min(reply.reason.len, buf.len - 1); | ||
| 348 | @memcpy(buf[1..][0..n], reply.reason[0..n]); | ||
| 349 | return buf[0 .. 1 + n]; | ||
| 350 | } | ||
| 351 | |||
| 352 | /// Null is a payload with no status byte at all — nothing this side can call | ||
| 353 | /// an answer, which the caller reports as "no reply" rather than as a refusal | ||
| 354 | /// it could quote. A daemon that predates this verb sends no frame at all and | ||
| 355 | /// reaches the same conclusion by silence. | ||
| 356 | pub fn parseUpgradeReply(payload: []const u8) ?UpgradeReply { | ||
| 357 | if (payload.len == 0) return null; | ||
| 358 | return .{ .ok = payload[0] == 0, .reason = payload[1..] }; | ||
| 359 | } | ||
| 360 | |||
| 331 | /// Screen-space rows; the grid's owner normalizes. | 361 | /// Screen-space rows; the grid's owner normalizes. |
| 332 | pub const SelectionPoint = struct { | 362 | pub const SelectionPoint = struct { |
| 333 | row: u32, | 363 | row: u32, |
| @@ -2248,6 +2278,34 @@ test "parseUpgradeReq: a relative path is BadPayload" { | |||
| 2248 | try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad)); | 2278 | try std.testing.expectError(error.BadPayload, parseUpgradeReq(bad)); |
| 2249 | } | 2279 | } |
| 2250 | 2280 | ||
| 2281 | test "encodeUpgradeReply/parseUpgradeReply: accepted, refused with words, and no status byte" { | ||
| 2282 | var buf: [upgrade_reply_max_len]u8 = undefined; | ||
| 2283 | |||
| 2284 | const ok = parseUpgradeReply(encodeUpgradeReply(&buf, .{ .ok = true, .reason = "" })) orelse | ||
| 2285 | return error.ReplyDidNotParse; | ||
| 2286 | try std.testing.expect(ok.ok); | ||
| 2287 | try std.testing.expectEqualStrings("", ok.reason); | ||
| 2288 | |||
| 2289 | const wire = encodeUpgradeReply(&buf, .{ .ok = false, .reason = "version: output mismatch" }); | ||
| 2290 | try std.testing.expectEqual(@as(u8, 1), wire[0]); | ||
| 2291 | const no = parseUpgradeReply(wire) orelse return error.ReplyDidNotParse; | ||
| 2292 | try std.testing.expect(!no.ok); | ||
| 2293 | // The client matches this string to add its rename hint, so the reason | ||
| 2294 | // must survive the round trip byte for byte. | ||
| 2295 | try std.testing.expectEqualStrings("version: output mismatch", no.reason); | ||
| 2296 | |||
| 2297 | // An empty payload is not an accepted upgrade: without a status byte | ||
| 2298 | // there is no answer to read, and reading one as "accepted" would tell a | ||
| 2299 | // user their daemon exec'd when nothing did. | ||
| 2300 | try std.testing.expect(parseUpgradeReply("") == null); | ||
| 2301 | |||
| 2302 | // A reason past the buffer is cut, never refused and never overrun. | ||
| 2303 | var small: [8]u8 = undefined; | ||
| 2304 | const cut = parseUpgradeReply(encodeUpgradeReply(&small, .{ .ok = false, .reason = "0123456789" })) orelse | ||
| 2305 | return error.ReplyDidNotParse; | ||
| 2306 | try std.testing.expectEqualStrings("0123456", cut.reason); | ||
| 2307 | } | ||
| 2308 | |||
| 2251 | test "parseUpgradeReq: an empty version is BadPayload" { | 2309 | test "parseUpgradeReq: an empty version is BadPayload" { |
| 2252 | // Manually crafted: flag ++ NUL ++ path (version is zero-length) | 2310 | // Manually crafted: flag ++ NUL ++ path (version is zero-length) |
| 2253 | const bad = &[_]u8{ 0, 0, '/', 'a' }; | 2311 | const bad = &[_]u8{ 0, 0, '/', 'a' }; |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1689,11 +1689,11 @@ pub const Server = struct { | |||
| 1689 | /// on this socket. Five sites spelled this out, each remembering the | 1689 | /// on this socket. Five sites spelled this out, each remembering the |
| 1690 | /// drop for itself. | 1690 | /// drop for itself. |
| 1691 | fn refuseUpgrade(self: *Server, i: usize, reason: []const u8) void { | 1691 | fn refuseUpgrade(self: *Server, i: usize, reason: []const u8) void { |
| 1692 | var reply: [256]u8 = undefined; | 1692 | var reply: [proto.upgrade_reply_max_len]u8 = undefined; |
| 1693 | reply[0] = 1; | 1693 | self.replyTo(.{ .observer = i }, .upgrade_reply, proto.encodeUpgradeReply(&reply, .{ |
| 1694 | const n = @min(reason.len, reply.len - 1); | 1694 | .ok = false, |
| 1695 | @memcpy(reply[1..][0..n], reason[0..n]); | 1695 | .reason = reason, |
| 1696 | self.replyTo(.{ .observer = i }, .upgrade_reply, reply[0 .. 1 + n]); | 1696 | })); |
| 1697 | self.dropObserver(i); | 1697 | self.dropObserver(i); |
| 1698 | } | 1698 | } |
| 1699 | 1699 | ||
| @@ -2195,7 +2195,11 @@ pub const Server = struct { | |||
| 2195 | std.posix.close(memfd); | 2195 | std.posix.close(memfd); |
| 2196 | return self.refuseUpgrade(i, "oom"); | 2196 | return self.refuseUpgrade(i, "oom"); |
| 2197 | }; | 2197 | }; |
| 2198 | self.replyTo(.{ .observer = i }, .upgrade_reply, &.{0}); | 2198 | var accepted: [1]u8 = undefined; |
| 2199 | self.replyTo(.{ .observer = i }, .upgrade_reply, proto.encodeUpgradeReply(&accepted, .{ | ||
| 2200 | .ok = true, | ||
| 2201 | .reason = "", | ||
| 2202 | })); | ||
| 2199 | self.dropObserver(i); | 2203 | self.dropObserver(i); |
| 2200 | self.pending_upgrade = .{ .path = path, .memfd = memfd }; | 2204 | self.pending_upgrade = .{ .path = path, .memfd = memfd }; |
| 2201 | }, | 2205 | }, |