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.zigstdout 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:
- Encode a
send_namedwire message withprotocol.encode_send_named. - Write it to fd 1 (stdout) via
framing.write_frame. - Flush stdout.
- 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:
- Spawn the child binary using
thespian.subprocesswithstdin_behavior = .Closed. - Receive stdout chunks as
{tag, "stdout", bytes}messages. - Feed each chunk into a
framing.Accumulatoruntil a complete frame is ready. - Decode the frame with
protocol.decode. - Assert the decoded message matches the expected
send_namedenvelope. - Wait for
{tag, "term", "exited", 0}. - 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:
- 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.
- Pass the child binary path to the test suite via build options:
options.addOptionPath("remote_child_path", remote_child.getEmittedBin());
- 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:
- The child exits with code 0.
- The parent decodes a
send_namedframe withfrom_id = 1,to_name = "test_actor", andpayload = ["hello", "from_child"]. - 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.Accumulatorseparately).
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.