thespian/docs/remoting-poc-test.md

235 lines
6.6 KiB
Markdown

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