5329b286
feat: the handoff's policy is a step table, not a shape in the driver
a73x 2026-08-29 15:55
Commit message
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. |