From 367173d30d785c8620b04d9cfd3e59d43f8d926e Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 6 Mar 2026 20:27:10 +0100 Subject: [PATCH] WIPWIP: docs/remoting-poc-test.md --- docs/remoting-poc-test.md | 235 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/remoting-poc-test.md diff --git a/docs/remoting-poc-test.md b/docs/remoting-poc-test.md new file mode 100644 index 0000000..6642b78 --- /dev/null +++ b/docs/remoting-poc-test.md @@ -0,0 +1,235 @@ +# Remoting POC Test - Child Process Sends a Message + +## Goal + +Validate the child process transport and wire framing end-to-end with the +simplest possible test: a child process writes one framed CBOR message to +stdout and the parent receives and decodes it correctly. + +This test drives the first implementation increment of `src/remote/` without +requiring the full endpoint or proxy infrastructure. + +--- + +## Scope + +**In scope:** + +- `framing.zig` - length-prefix frame writer (child side) and reader/accumulator (parent side) +- `protocol.zig` - encoding and decoding of one wire message type (`send_named`) +- A dedicated child binary built alongside the tests +- Parent-side frame accumulation from `subprocess.zig` stdout chunks + +**Out of scope:** + +- Endpoint actor +- Proxy actors +- Link/exit propagation +- Any message type other than `send_named` + +--- + +## Child Binary + +The child is a standalone executable built from a single source file: + +`test/remote_child_send.zig` + +It does not need a Thespian context. It writes one framed CBOR message to +stdout and exits: + +1. Encode a `send_named` wire message with `protocol.encode_send_named`. +2. Write it to fd 1 (stdout) via `framing.write_frame`. +3. Flush stdout. +4. Exit with code 0. + +The message: + +``` +["send_named", 1, "test_actor", ["hello", "from_child"]] +``` + +| Field | Value | Meaning | +| --------- | ------------------------ | ------------------------------- | +| `from_id` | `1` | Opaque ID for the child process | +| `to_name` | `"test_actor"` | Well-known name on parent side | +| `payload` | `["hello","from_child"]` | The actor message body | + +Sketch: + +```zig +pub fn main() !void { + var buf: [256]u8 = undefined; + const payload = try protocol.encode_send_named(&buf, 1, "test_actor", + try cbor.writeValue(&buf, .{ "hello", "from_child" })); + try framing.write_frame(std.io.getStdOut().writer(), payload); +} +``` + +--- + +## Parent Test Actor + +The parent is a standard Thespian test actor defined in `test/remote_poc_test.zig`. +It follows the `ip_tcp_client_server.zig` pattern. + +Steps: + +1. Spawn the child binary using `thespian.subprocess` with `stdin_behavior = .Closed`. +2. Receive stdout chunks as `{tag, "stdout", bytes}` messages. +3. Feed each chunk into a `framing.Accumulator` until a complete frame is ready. +4. Decode the frame with `protocol.decode`. +5. Assert the decoded message matches the expected `send_named` envelope. +6. Wait for `{tag, "term", "exited", 0}`. +7. Exit with `"success"`. + +Message flow: + +``` +subprocess.zig --> {tag, "stdout", bytes} --> Parent actor + | + accumulator.feed(bytes) + | + complete frame available? + | + protocol.decode(frame) + | + assert send_named fields match +``` + +The child binary path is passed to the parent actor via its `Args` struct and +comes from the build options (see below). + +--- + +## New Source Files + +``` +src/remote/framing.zig + write_frame(writer, payload: []const u8) !void + Accumulator + feed(self, bytes: []const u8) ?[]u8 -- returns completed payload or null + +src/remote/protocol.zig + encode_send_named(buf, from_id: u64, to_name: []const u8, payload: []const u8) ![]u8 + decode(frame: []const u8) !WireMessage + WireMessage (tagged union) + .send_named: struct { from_id: u64, to_name: []const u8, payload: []const u8 } + -- other variants stubbed for now +``` + +`framing.Accumulator` keeps an internal buffer, accepts partial byte slices, +and returns a slice to the completed payload once the length-prefixed frame +is fully received. + +--- + +## New Test Files + +``` +test/remote_child_send.zig -- the child binary (no Thespian context) +test/remote_poc_test.zig -- the Zig test (Parent actor + test case) +``` + +Test case: + +```zig +test "remote: child process sends a message to parent" { + const allocator = std.testing.allocator; + var ctx = try thespian.context.init(allocator); + defer ctx.deinit(); + + var success = false; + var exit_handler = thespian.make_exit_handler(&success, struct { + fn handle(ok: *bool, status: []const u8) void { + ok.* = std.mem.eql(u8, status, "success"); + } + }.handle); + + _ = try ctx.spawn_link( + Parent.Args{ + .allocator = allocator, + .child_path = build_options.remote_child_path, + }, + Parent.start, + "remote_poc", + &exit_handler, + null, + ); + + ctx.run(); + + if (!success) return error.TestFailed; +} +``` + +--- + +## Build Integration + +In `build.zig`: + +1. Add the child as a standalone executable: + +```zig +const remote_child = b.addExecutable(.{ + .name = "remote_child_send", + .root_module = b.createModule(.{ + .root_source_file = b.path("test/remote_child_send.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "cbor", .module = cbor_mod }, + }, + }), +}); +// remote_child_send.zig uses src/remote/framing.zig and protocol.zig directly +// via @import relative paths; no extra linkage needed for this binary. +``` + +2. Pass the child binary path to the test suite via build options: + +```zig +options.addOptionPath("remote_child_path", remote_child.getEmittedBin()); +``` + +3. Make the test run depend on the child binary being built: + +```zig +test_run_cmd.step.dependOn(&remote_child.step); +``` + +The test imports `build_options` (already wired in) and reads +`build_options.remote_child_path` at compile time to get the path to the +child executable. + +--- + +## Success Criteria + +The test passes when: + +1. The child exits with code 0. +2. The parent decodes a `send_named` frame with `from_id = 1`, + `to_name = "test_actor"`, and `payload = ["hello", "from_child"]`. +3. No Thespian actors exit with an unexpected error. + +--- + +## What This Test Does NOT Validate + +- The endpoint actor (not yet written). +- Proxies (not yet written). +- Bidirectional communication. +- Link/exit propagation. +- Partial-frame delivery across multiple stdout chunks (worth a dedicated unit + test on `framing.Accumulator` separately). + +--- + +## Next Step After This POC + +Once this test passes, the next increment is a round-trip test: the parent +sends a message to the child and the child replies. That test drives the +parent-side `write_frame` path and the child-side `Accumulator`, completing +the symmetric framing layer before the endpoint actor is introduced.