/// 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; }