thespian/docs/remoting-poc-test.md

6.6 KiB

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:

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:

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:
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.
  1. Pass the child binary path to the test suite via build options:
options.addOptionPath("remote_child_path", remote_child.getEmittedBin());
  1. Make the test run depend on the child binary being built:
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.