235 lines
6.6 KiB
Markdown
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.
|