thespian/test/remote_lifetime_test.zig
CJ van den Berg 077d47f8b2
feat: clean up local_actors/outbound tables when senders exit
The endpoint now links to each local actor when it is first assigned an
outbound wire ID, trapping the exit to remove stale entries from the
outbound and local_actors tables.

To preserve spawn_link semantics, the endpoint stores the spawner's
instance_id() at init time. Trapped exits from the spawner are always
re-propagated regardless of whether the spawner also appears as an outbound
sender.
2026-03-12 22:21:27 +01:00

177 lines
6.6 KiB
Zig

/// Tests cross-endpoint actor lifetime and exit propagation via the wire
/// "link" protocol.
///
/// Scenario (full round-trip):
///
/// 1. Parent sends send_named to "die_test" (from_id=0, trigger mode).
/// 2. Child die_test echoes payload back — proxy_4 is created on the parent,
/// message arrives FROM proxy_4 at TestActor.
/// 3. TestActor sends {"link"} to proxy_4, establishing a Thespian link
/// between proxy_4 and TestActor AND requesting a wire "link" message.
/// 4. Wire ["link", TA_wire_id, 4] reaches child; child's StdioEndpoint
/// calls actor.link() on die_test and records the mapping in link_notify.
/// 5. TestActor sends {"die"} to proxy_4, which forwards it over the wire
/// to die_test (to_id=4).
/// 6. die_test receives from_id != 0 and exits "die_test".
/// 7. Child's StdioEndpoint receives the trapped exit from die_test, sends
/// wire ["exit", 4, "die_test"] to the parent.
/// 8. Parent endpoint dispatches to proxy_4 via proxies[4], sends
/// {"exit", "die_test"} to proxy_4.
/// 9. proxy_4 exits "die_test"; TestActor (Thespian-linked to proxy_4)
/// receives {"exit", "die_test"} and exits "success".
const std = @import("std");
const thespian = @import("thespian");
const cbor = @import("cbor");
const endpoint = @import("endpoint");
const build_options = @import("build_options");
var trace_file: ?std.fs.File = null;
var trace_buf: [4096]u8 = undefined;
var trace_file_writer: std.fs.File.Writer = undefined;
fn trace_handler(buf: thespian.message.c_buffer_type) callconv(.c) void {
if (trace_file == null) return;
cbor.toJsonWriter(buf.base[0..buf.len], &trace_file_writer.interface, .{}) catch return;
trace_file_writer.interface.writeByte('\n') catch return;
}
const Allocator = std.mem.Allocator;
const result = thespian.result;
const pid_ref = thespian.pid_ref;
const Receiver = thespian.Receiver;
const message = thespian.message;
const TestActor = struct {
allocator: Allocator,
ep: thespian.pid,
receiver: Receiver(*@This()),
state: enum { waiting_for_trigger_reply, waiting_for_proxy_exit },
const Args = struct { allocator: Allocator };
fn start(args: Args) result {
return init(args) catch |e| return thespian.exit_error(e, @errorReturnTrace());
}
fn init(args: Args) !void {
// Trap exits so {"exit", reason} arrives as a message rather than
// killing the actor; we need to inspect the exit reason.
_ = thespian.set_trap(true);
thespian.env.get().proc_set("test_receiver", thespian.self_pid().ref());
const argv = try args.allocator.dupe(u8, message.fmt(.{build_options.remote_child_endpoint_path}).buf);
const ep = try thespian.spawn_link(
args.allocator,
endpoint.Args{
.allocator = args.allocator,
.argv = argv,
.spawner = thespian.self_pid().ref(),
},
endpoint.start,
"endpoint",
);
// Trigger die_test in trigger mode (from_id=0): child echoes payload
// back so the parent can establish proxy_4.
try ep.send(.{ "send", @as(u64, 0), "die_test", .{"trigger"} });
const self = try args.allocator.create(@This());
self.* = .{
.allocator = args.allocator,
.ep = ep,
.receiver = .init(receive_fn, deinit, self),
.state = .waiting_for_trigger_reply,
};
errdefer self.deinit();
thespian.receive(&self.receiver);
}
fn deinit(self: *@This()) void {
self.ep.deinit();
self.allocator.destroy(self);
}
fn receive_fn(self: *@This(), from: pid_ref, m: message) result {
return self.receive(from, m) catch |e| return thespian.exit_error(e, @errorReturnTrace());
}
fn receive(self: *@This(), from: pid_ref, m: message) !void {
var reason: []const u8 = "";
switch (self.state) {
.waiting_for_trigger_reply => {
if (try m.match(.{"trigger"})) {
// `from` is proxy_4 (the proxy for die_test, remote_id=4).
//
// 1. Send {"link"} so proxy_4 establishes a Thespian link
// with this actor AND sends the wire "link" message to
// the child, which will monitor die_test.
try from.send(.{"link"});
//
// 2. Send {"die"} to proxy_4; it is forwarded over the wire
// to die_test (to_id=4), causing it to exit "die_test".
// Ordering is guaranteed: "link" is processed by the
// child before the die trigger because both travel through
// the same in-order pipe.
try from.send(.{"die"});
self.state = .waiting_for_proxy_exit;
} else {
return thespian.unexpected(m);
}
},
.waiting_for_proxy_exit => {
if (try m.match(.{ "exit", thespian.extract(&reason) })) {
if (std.mem.eql(u8, reason, "die_test"))
return thespian.exit("success");
// Any other exit (e.g. endpoint crash) is a failure.
return thespian.unexpected(m);
} else {
return thespian.unexpected(m);
}
},
}
}
};
test "remote: cross-process link/exit propagation via wire link protocol" {
const allocator = std.testing.allocator;
var initial_env: ?thespian.env = null;
if (std.posix.getenv("TRACE") != null) {
const f = try std.fs.cwd().createFile("remote_lifetime_trace.json", .{});
trace_file = f;
trace_file_writer = f.writer(&trace_buf);
var e = thespian.env.init();
e.on_trace(&trace_handler);
e.enable_all_channels();
initial_env = e;
}
defer if (initial_env) |e| {
trace_file_writer.interface.flush() catch {};
trace_file.?.close();
trace_file = null;
e.deinit();
};
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(
TestActor.Args{ .allocator = allocator },
TestActor.start,
"test_actor",
&exit_handler,
if (initial_env) |*e| e else null,
);
ctx.run();
if (!success) return error.TestFailed;
}