a73x

5329b286

feat: the handoff's policy is a step table, not a shape in the driver

a73x   2026-08-29 15:55

Commit message
feat: the handoff's policy is a step table, not a shape in the driver

Which transport to try, in what order and when to give up lived inline
between the effects that perform it — a dial, a spawn, a read, a cache
write — so the only way to reach a row of it was a daemon, an ssh shim and
a blackholed UDP port. Every row now tests as a function call.

`handoff.next` is the whole policy: a phase, the outcome the last step
produced, and the next step to take. The driver is unchanged and does not
call it yet.

Each of the fifteen rows is one named test, and each was mutation-proven
against this commit: flipping the row fails the test that names it, by
name, and nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/client/handoff.zig
Old New
@@ -1,8 +1,9 @@
1 //! The ssh→QUIC handoff's shared vocabulary: the announce line 1 //! The ssh→QUIC handoff's shared vocabulary: the announce line
2 //! `mux d endpoint` prints and `mux` parses, the per-host cache file that 2 //! `mux d endpoint` prints and `mux` parses, the per-host cache file that
3 //! remembers it, and the strip that turns an ssh destination into a 3 //! remembers it, the strip that turns an ssh destination into a dialable
4 //! dialable host. Pure by design — no sockets, no processes — so the 4 //! host, and the step table `next` that orders a handoff out of them.
5 //! whole surface tests without a daemon. 5 //! Pure by design — no sockets, no processes — so the whole surface, the
6 //! policy included, tests without a daemon.
6 const std = @import("std"); 7 const std = @import("std");
7 // Only for the private-parent discipline the cache file shares with the 8 // Only for the private-parent discipline the cache file shares with the
8 // key file. No XDG resolution happens here: writeCache is handed a path. 9 // key file. No XDG resolution happens here: writeCache is handed a path.
@@ -344,6 +345,151 @@ pub fn readCache(path: []const u8) !Endpoint {
344 return ep orelse error.CacheMalformed; 345 return ep orelse error.CacheMalformed;
345 } 346 }
346 347
348 // ------------------------------------------------------------ the policy
349 // `client.Transport.openHandoff` performs what these steps name, and owns
350 // every effect. Each row below is one named test in this file.
351
352 /// One effect for the driver to perform, or the transport it ends on.
353 pub const Step = union(enum) {
354 /// Dial these coordinates: `client.Transport.openQuicEndpoint`.
355 dial_quic: Endpoint,
356 /// Run the coordination ssh: `spawnPipe` on the asking argv when the
357 /// user asked and there is one, on the reading argv otherwise.
358 spawn_ssh,
359 /// Read the announce line off that child's stdout.
360 read_announce,
361 /// Remember these coordinates under `cache_write_mu`. A write that
362 /// fails costs a cold attach and nothing else, so its outcome is
363 /// always `.done`.
364 write_cache: Endpoint,
365 /// Terminal: the transport the last dial returned. The driver kills
366 /// the coordination ssh, if it started one.
367 use_quic,
368 /// Terminal: the live ssh child IS the session. True asks for the
369 /// fallback line.
370 ///
371 /// These arms are not a leftover of a bygone network. Through
372 /// `ssh -J gate box` the announced UDP port is unreachable from here
373 /// BY CONSTRUCTION — the jump host is the only route and it carries
374 /// TCP — so the pipe is the only session such a host can ever have.
375 /// Every one of them takes this step.
376 use_pipe: bool,
377 /// Terminal: the error the driver recorded for the step that failed.
378 fail,
379 };
380
381 /// What performing a step produced. One vocabulary for all of them: which
382 /// arms a step can answer with is the table's business, and an arm no
383 /// phase expects is a driver bug rather than an input.
384 pub const Outcome = union(enum) {
385 /// `dial_quic` connected, or `spawn_ssh` forked and exec'd.
386 ok,
387 /// The abort key landed inside a dial's wait.
388 user_abort,
389 /// `dial_quic` never came up, or `spawn_ssh` could not start.
390 failed,
391 /// `read_announce`: coordinates.
392 announced: Endpoint,
393 /// `read_announce`: `endpoint none`, the remote saying it has none.
394 none,
395 /// `read_announce`: no line at all — a dead pipe, junk, an abort.
396 announce_failed,
397 /// `write_cache`, always.
398 done,
399 };
400
401 /// How far the handoff has got. The driver builds one from its target and
402 /// its cache read, then hands it to `next` and touches nothing in it.
403 pub const State = struct {
404 /// The coordinates the cache held; null when there is nothing usable
405 /// to try before ssh.
406 cached: ?Endpoint,
407 /// Whether a USER asked for this dial — `client.HandoffTarget.asked`.
408 /// Here it decides one thing: whether the fallback says so out loud.
409 asked: bool,
410 /// Whether there is a cache file to write. Distinct from `cached`: a
411 /// path holding nothing usable is a cold attach that still caches.
412 has_cache: bool,
413 /// The announce being decided on. `write_cache` sits between reading
414 /// one and choosing what to do with it, so it has to outlive that
415 /// step; the phase alone cannot carry it.
416 announced: ?Endpoint = null,
417 phase: enum { init, warm_dial, ssh, announce, cache, cold_dial } = .init,
418 };
419
420 /// The next step, given what the last one produced. `null` is the first
421 /// call, which has produced nothing yet.
422 ///
423 /// An (outcome, phase) pair no row covers is `unreachable`: the driver is
424 /// the only caller, it answers each step from a fixed set, and a pair
425 /// outside the table is a bug in the loop rather than input to judge.
426 pub fn next(s: *State, o: ?Outcome) Step {
427 const outcome = o orelse {
428 std.debug.assert(s.phase == .init);
429 if (s.cached) |ep| {
430 s.phase = .warm_dial;
431 return .{ .dial_quic = ep };
432 }
433 s.phase = .ssh;
434 return .spawn_ssh;
435 };
436 switch (s.phase) {
437 .init => unreachable,
438 .warm_dial => switch (outcome) {
439 .ok => return .use_quic,
440 .user_abort => return .fail,
441 // ssh is authoritative, so a dead cache is a question it
442 // answers rather than a failure. What the miss cost depends
443 // on how the coordinates were dead: a resolve failure and a
444 // REFUSED port are both instant, while anything SILENT
445 // (blackholed UDP, a listener holding another key) spent the
446 // whole of `deadline_ms` first.
447 .failed => {
448 s.phase = .ssh;
449 return .spawn_ssh;
450 },
451 else => unreachable,
452 },
453 .ssh => switch (outcome) {
454 .ok => {
455 s.phase = .announce;
456 return .read_announce;
457 },
458 .failed => return .fail,
459 else => unreachable,
460 },
461 .announce => switch (outcome) {
462 .none => return .{ .use_pipe = false },
463 .announce_failed => return .fail,
464 .announced => |ep| {
465 if (s.has_cache) {
466 s.announced = ep;
467 s.phase = .cache;
468 return .{ .write_cache = ep };
469 }
470 return afterAnnounce(s, ep);
471 },
472 else => unreachable,
473 },
474 .cache => switch (outcome) {
475 .done => return afterAnnounce(s, s.announced.?),
476 else => unreachable,
477 },
478 .cold_dial => switch (outcome) {
479 .ok => return .use_quic,
480 .user_abort => return .fail,
481 .failed => return .{ .use_pipe = s.asked },
482 else => unreachable,
483 },
484 }
485 }
486
487 /// What a fresh announce is worth, once it is safely cached.
488 fn afterAnnounce(s: *State, ep: Endpoint) Step {
489 s.phase = .cold_dial;
490 return .{ .dial_quic = ep };
491 }
492
347 test "announce: format → parse round-trip, with and without the newline" { 493 test "announce: format → parse round-trip, with and without the newline" {
348 var buf: [announce_max_len]u8 = undefined; 494 var buf: [announce_max_len]u8 = undefined;
349 495
@@ -670,6 +816,121 @@ test "cache: refuses a permissive file, a missing one, and `endpoint none`" {
670 try std.testing.expectError(CacheError.CacheMalformed, readCache(path)); 816 try std.testing.expectError(CacheError.CacheMalformed, readCache(path));
671 } 817 }
672 818
819 /// One step as a word. A failing row prints the step a reader can compare,
820 /// not a struct dump; the endpoint shows its port and the first byte of its
821 /// key, which is enough to tell two announces apart.
822 fn stepStr(buf: []u8, st: Step) ![]const u8 {
823 return switch (st) {
824 .dial_quic => |ep| std.fmt.bufPrint(buf, "dial_quic {d}/{x:0>2}", .{ ep.port, ep.key[0] }),
825 .spawn_ssh => std.fmt.bufPrint(buf, "spawn_ssh", .{}),
826 .read_announce => std.fmt.bufPrint(buf, "read_announce", .{}),
827 .write_cache => |ep| std.fmt.bufPrint(buf, "write_cache {d}/{x:0>2}", .{ ep.port, ep.key[0] }),
828 .use_quic => std.fmt.bufPrint(buf, "use_quic", .{}),
829 .use_pipe => |say| std.fmt.bufPrint(buf, "use_pipe({s})", .{if (say) "line" else "silent"}),
830 .fail => std.fmt.bufPrint(buf, "fail", .{}),
831 };
832 }
833
834 fn expectStep(want: []const u8, got: Step) !void {
835 var buf: [64]u8 = undefined;
836 try std.testing.expectEqualStrings(want, try stepStr(&buf, got));
837 }
838
839 // Two announces differing in BOTH halves, so a row that compares only ports
840 // and a row that compares only keys are equally wrong. One note over the
841 // pair: neither endpoint means anything without the other.
842 fn epA() Endpoint {
843 return .{ .port = 4433, .key = [_]u8{0xab} ** key_len };
844 }
845 fn epB() Endpoint {
846 return .{ .port = 5000, .key = [_]u8{0xcd} ** key_len };
847 }
848
849 test "handoff step: a usable cache is dialled before anything else runs" {
850 var s: State = .{ .cached = epA(), .asked = false, .has_cache = true };
851 try expectStep("dial_quic 4433/ab", next(&s, null));
852 }
853
854 test "handoff step: nothing cached starts at the ssh run" {
855 var s: State = .{ .cached = null, .asked = false, .has_cache = true };
856 try expectStep("spawn_ssh", next(&s, null));
857 }
858
859 test "handoff step: a warm dial that connected IS the session" {
860 var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial };
861 try expectStep("use_quic", next(&s, .ok));
862 }
863
864 test "handoff step: the abort key inside the warm dial asked to stop, not to try the next thing" {
865 var s: State = .{ .cached = epA(), .asked = true, .has_cache = true, .phase = .warm_dial };
866 try expectStep("fail", next(&s, .user_abort));
867 }
868
869 test "handoff step: a warm dial that failed hands the question to ssh, which is authoritative" {
870 var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial };
871 try expectStep("spawn_ssh", next(&s, .failed));
872 }
873
874 test "handoff step: an ssh that could not start ends the handoff" {
875 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .ssh };
876 try expectStep("fail", next(&s, .failed));
877 }
878
879 test "handoff step: the ssh that came up is read for its announce" {
880 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .ssh };
881 try expectStep("read_announce", next(&s, .ok));
882 }
883
884 test "handoff step: `endpoint none` is the ssh pipe, and says nothing about it" {
885 // Even for a user who ASKED: no coordinates were ever in play, so
886 // there is nothing to report as unreachable.
887 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
888 try expectStep("use_pipe(silent)", next(&s, .none));
889 }
890
891 test "handoff step: an announce that never arrived ends the handoff" {
892 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
893 try expectStep("fail", next(&s, .announce_failed));
894 }
895
896 test "handoff step: an announce is cached BEFORE it is dialled" {
897 // The coordinates are true whether or not UDP can carry them. A client
898 // that cached only what it reached would leave every jump-host route
899 // permanently cold.
900 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .announce };
901 try expectStep("write_cache 5000/cd", next(&s, .{ .announced = epB() }));
902 }
903
904 test "handoff step: a host with nowhere to cache dials the announce straight away" {
905 var s: State = .{ .cached = null, .asked = true, .has_cache = false, .phase = .announce };
906 try expectStep("dial_quic 5000/cd", next(&s, .{ .announced = epB() }));
907 }
908
909 test "handoff step: the announce that was just cached is the one dialled" {
910 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cache, .announced = epB() };
911 try expectStep("dial_quic 5000/cd", next(&s, .done));
912 }
913
914 test "handoff step: a cold dial that connected IS the session" {
915 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial };
916 try expectStep("use_quic", next(&s, .ok));
917 }
918
919 test "handoff step: the abort key inside the cold dial ends the handoff" {
920 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial };
921 try expectStep("fail", next(&s, .user_abort));
922 }
923
924 test "handoff step: a cold dial that failed is the ssh pipe, and only the user who asked hears why" {
925 // A reconnect re-runs this recipe forever against dropped UDP: one
926 // line per retry would scroll a live session's stderr into the
927 // alternate screen to say what the reconnecting banner already says.
928 for ([_]bool{ false, true }) |asked| {
929 var s: State = .{ .cached = null, .asked = asked, .has_cache = true, .phase = .cold_dial };
930 try expectStep(if (asked) "use_pipe(line)" else "use_pipe(silent)", next(&s, .failed));
931 }
932 }
933
673 // Forces semantic analysis of every pub decl under `zig build test`, so an 934 // Forces semantic analysis of every pub decl under `zig build test`, so an
674 // unreferenced decl must at least compile (the silent-module-loss hazard, 935 // unreferenced decl must at least compile (the silent-module-loss hazard,
675 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 936 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.