# 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.