Skip to content

Commit 6915fc4

Browse files
authored
Join effect worker threads before teardown frees the channel (#124)
* Join effect worker threads before teardown frees the channel - Store spawn/fetch/file worker handles on their slots instead of detaching, join them in reclaim and unconditionally in deinit: the old ~5s give-up abandoned workers still holding slot/queue/io pointers into memory the owner frees right after, which is how a torn-down harness segfaulted the next test inside a stale slot's child_mutex. - Spawn each child into its own POSIX process group and kill the group on cancel/teardown, so shell-wrapped commands' grandchildren cannot hold the stdout pipe open and stall the worker (and now the join) past the cancel. - Regression tests: teardown mid-stream joins and returns promptly with no running slots, and an 8-round teardown storm (immediate and cancel-racing) over recycled harness memory. * Make the ts-core e2e battery tolerant of congested CI runners - Scale the host battery's real-child wait budget 10x (20s -> 200s): the waits prove correctness and poll, so a healthy run still returns in milliseconds while a loaded runner gets the slack it needs to schedule and reap /bin/sh children. - A blown wait budget now tears the effects channel down (kill children, join workers) before surfacing TestTimedOut, so one slow test can never cascade a straggling child into the next test's harness. - The soundboard dispatch-latency test asserts the best of three attempts with unchanged budgets: scheduler contention only adds time, so real regressions still fail while parallel-suite noise no longer does. * Bound file-worker teardown: interrupt, then abandon-and-leak, never hang - Effects.deinit joined every worker unconditionally, but a file worker blocked in I/O that nothing converges (a write to a FIFO with no reader, a stalled network filesystem) made teardown hang forever behind it; file workers now get an injectable budget (file_join_deadline_ms, 15s default) with a best-effort cancel of the blocked task at the halfway mark, and past it teardown detaches the thread, warns once naming the stuck op and path, and deliberately leaks everything the worker can still reach (its context, its data buffer, the executor io) so the owner can free the channel safely. Spawn and fetch joins stay unconditional. - The blocking phase moved out of the channel: each file worker supervises its op as a cancelable Io task (mirroring fetchWorkerMain, which also supplies the platform interruption: SIG.IO signaling on POSIX, NtCancelSynchronousIoFile on Windows, via the threaded io) against a heap FileWorkerContext holding the path copy, buffer, and a commit/abandon handshake - the worker only touches the slot and queue after committing under the context mutex, so an abandoned worker that wakes later walks only leaked memory. - Regression tests: a write against a reader-less FIFO is abandoned within a tiny injected deadline (loud counter, healthy process after, the woken worker exercised under the leak invariant), the default interruption path joins the same posture with no leak, and the happy-path teardown pins the abandon counter at zero. * Move the abandonable leak into process-lifetime storage and reject uncancelable fetches - Allocate FileWorkerContext, its private data buffer, and the shared IoThreaded executor from a process-lifetime allocator (page_allocator) so an abandoned worker never walks memory that dies with the owner's arena or GPA; the happy path frees all three through the same seam (joinWorker, deinit), and a committed read publishes its bytes into the slot's channel-owned delivery buffer. - Refuse a fetch whose exchange cannot start as a cancelable task instead of running it inline: an inline exchange observes neither cancel nor the timeout and would hang deinit's unconditional fetch join, so the honest terminal is one journaled .rejected (replay reproduces it like any transport failure), with an injectable fetch_concurrent_start seam and a warn-once counter. - Pin both with tests: an arena-backed channel abandons a FIFO-stuck worker, dies, and the woken worker walks only process-lived memory (leak-checked happy path alongside); the fetch rejection seam delivers exactly one .rejected and teardown returns promptly. * Bound spawn-worker teardown: interrupt, then abandon-and-leak, never hang - Group-kill cannot guarantee spawn convergence: a descendant that leaves the child's process group (setsid, a shell's set -m background job) keeps the inherited stdout write end open, so the worker's read never sees EOF and deinit's unconditional join hung forever; spawn workers now get the file workers' full discipline — an injectable budget (spawn_join_deadline_ms, same 15s default) with a best-effort cancel of the blocked task at the halfway mark, then a detach-warn-and-leak abandon with its own abandoned_spawn_workers counter, so spawn, fetch, and file teardown all share one terminal guarantee: bounded return, and every byte a live thread can still touch stays valid forever (Windows, where the direct-handle terminate never reached descendants, is bounded by the same net; job objects remain the future strengthening). - The blocking phase's world moved out of the channel into a process-lived SpawnWorkerContext (argv/stdin copies, the published-child kill handshake, private framing/collect buffers, the stderr tail ring, drop accounting), supervised as a cancelable Io task exactly like file ops; unlike a file op a spawn DELIVERS while it blocks, so streaming lines enqueue under the context's abandon fence (produceSpawnLine re-checks the abandon with every channel touch) and the committed epilogue publishes collect payloads into the slot's channel-owned delivery buffer, preserving cancel semantics, the stale-event window, and record/replay byte-identity. - Regression tests pin the escaped-descendant shape (/bin/bash -c 'set -m; sleep 300 & echo $!' — portable setsid): the default interruption path joins it with no leak, the disabled-interruption path abandons it within a tiny injected deadline (loud counter, healthy process after, the woken worker proven to reap its zombie child through only leaked memory), the arena-lifetime test wakes an abandoned worker after the owner's allocator died, and the existing mid-stream teardown pins abandon-count zero.
1 parent 8e37536 commit 6915fc4

7 files changed

Lines changed: 1642 additions & 146 deletions

File tree

changelog.d/spawn-e2e-stability.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fix: **Spawn teardown crash window closed**: the effects channel now joins every spawn, fetch, and file worker thread that converges before its teardown returns (previously it gave up after ~5s and abandoned them), so cancelling or quitting while a real child is still streaming can no longer leave a stale worker writing into freed memory.
2+
- **Spawn cancel reaches the whole process tree**: each spawned child runs in its own process group and cancel/teardown signals the group (POSIX), so shell-wrapped commands (`sh -c "a; b"`) no longer leave orphaned grandchildren holding the stream open past the cancel.
3+
- **Every worker class tears down bounded**: spawn, fetch, and file workers now share one terminal guarantee — teardown returns within a budget and never frees memory a live thread can still touch. A file worker stuck in blocking I/O that nothing can converge (a write to a FIFO with no reader, a stalled network filesystem), or a spawn worker held hostage by a descendant that escaped the kill's process group (`setsid` daemonization, a shell's `set -m` background job) while holding the stdout pipe open, no longer hangs teardown: teardown interrupts the blocked syscall best-effort at half its budget and, past the full 15s, abandons the worker with one warning and a small deliberate leak. Everything an abandoned worker can still reach lives in process-lifetime storage, so the leak stays safe even when the app tears down the allocator behind the channel right after.
4+
- **A fetch that cannot start cancellably is rejected, never run inline**: when the executor cannot start the exchange as a cancelable task, the fetch now delivers one honest `.rejected` terminal instead of silently running an exchange that would evade `cancel`, the timeout, and teardown's join.

src/runtime/effects.zig

Lines changed: 1037 additions & 133 deletions
Large diffs are not rendered by default.

src/runtime/effects_fetch_tests.zig

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const app_manifest = @import("app_manifest");
99
const core = @import("core.zig");
1010
const ui_app_model = @import("ui_app.zig");
1111
const effects_mod = @import("effects.zig");
12+
const clock_mod = @import("clock.zig");
1213

1314
const canvas_label = "fetch-canvas";
1415

@@ -980,6 +981,47 @@ test "real fetch reports connection refused as connect_failed" {
980981
try std.testing.expectEqual(@as(usize, 0), h.app_state.model.body_len);
981982
}
982983

984+
test "a fetch whose exchange cannot start cancellably is rejected, never run inline" {
985+
var h = try Harness.create();
986+
defer h.destroy();
987+
const fx = &h.app_state.effects;
988+
// Pin the no-capacity path through the injectable seam (the real
989+
// trigger — the executor refusing `std.Io.concurrent` — needs
990+
// resource exhaustion). If a regression ran the exchange inline
991+
// instead, this loopback connect would fail fast as
992+
// `.connect_failed` and the assert below would catch the lie.
993+
fx.fetch_concurrent_start = false;
994+
995+
var url_buffer: [128]u8 = undefined;
996+
test_url = std.fmt.bufPrint(&url_buffer, "http://127.0.0.1:9/", .{}) catch unreachable;
997+
test_method = .GET;
998+
test_headers = &.{};
999+
test_payload = null;
1000+
test_timeout_ms = effects_mod.default_effect_fetch_timeout_ms;
1001+
try h.app_state.dispatch(&h.harness.runtime, 1, .start);
1002+
try waitForResponse(&h);
1003+
1004+
// Exactly one honest `.rejected` terminal — the fetch never
1005+
// started — counted through the rejection seam.
1006+
try std.testing.expectEqual(effects_mod.EffectFetchOutcome.rejected, h.app_state.model.outcome.?);
1007+
try std.testing.expectEqual(@as(usize, 1), h.app_state.model.rejected_count);
1008+
try std.testing.expectEqual(@as(u16, 0), h.app_state.model.status);
1009+
try std.testing.expectEqual(@as(usize, 0), h.app_state.model.body_len);
1010+
try std.testing.expectEqual(@as(u32, 1), fx.fetch_start_rejections.load(.acquire));
1011+
try std.testing.expectEqual(@as(usize, 0), h.app_state.effects.activeCount());
1012+
1013+
// Teardown returns promptly: no uncancelable inline exchange is
1014+
// left for the unconditional fetch join to wait on.
1015+
const start_ns = clock_mod.monotonicNanoseconds();
1016+
fx.deinit();
1017+
const elapsed_ms = (clock_mod.monotonicNanoseconds() - start_ns) / std.time.ns_per_ms;
1018+
try std.testing.expect(elapsed_ms < 10_000);
1019+
for (&fx.slots) |*slot| {
1020+
try std.testing.expect(slot.worker_thread == null);
1021+
try std.testing.expect(slot.state.load(.acquire) != .running);
1022+
}
1023+
}
1024+
9831025
test "real fetch times out against a hanging route" {
9841026
var h = try Harness.create();
9851027
defer h.destroy();

src/runtime/effects_file_tests.zig

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const app_manifest = @import("app_manifest");
1111
const core = @import("core.zig");
1212
const ui_app_model = @import("ui_app.zig");
1313
const effects_mod = @import("effects.zig");
14+
const clock_mod = @import("clock.zig");
1415

1516
const canvas_label = "file-canvas";
1617

@@ -346,6 +347,16 @@ test "real executor writes a file (creating parent dirs) and reads it back" {
346347
try h.app_state.dispatch(&h.harness.runtime, 1, .load);
347348
try waitForRealResult(&h, 4);
348349
try std.testing.expectEqualStrings("{\"n\":2}", h.app_state.model.bytesPrefix());
350+
351+
// Happy-path teardown pin: quick local ops join promptly and the
352+
// abandon safety net never fires (no leak, no detached thread).
353+
const fx = &h.app_state.effects;
354+
fx.deinit();
355+
try std.testing.expectEqual(@as(u32, 0), fx.abandoned_file_workers);
356+
for (&fx.slots) |*slot| {
357+
try std.testing.expect(slot.worker_thread == null);
358+
try std.testing.expect(slot.state.load(.acquire) != .running);
359+
}
349360
}
350361

351362
test "real executor reports missing files as not_found" {
@@ -386,6 +397,215 @@ test "real executor cuts over-bound reads with outcome truncated" {
386397
);
387398
}
388399

400+
// --------------------------------------------------- bounded teardown
401+
402+
/// Create a FIFO at `path` (POSIX-only; callers gate on the platform).
403+
/// A file write against it blocks forever inside `open(O_WRONLY)`
404+
/// while no reader exists — the uninterruptible-blocking-I/O posture
405+
/// the teardown deadline exists for.
406+
fn makeFifo(path: []const u8) !void {
407+
var command_buffer: [512]u8 = undefined;
408+
const command = try std.fmt.bufPrint(&command_buffer, "mkfifo '{s}'", .{path});
409+
const result = try std.process.run(std.testing.allocator, std.testing.io, .{
410+
.argv = &.{ "/bin/sh", "-c", command },
411+
});
412+
defer std.testing.allocator.free(result.stdout);
413+
defer std.testing.allocator.free(result.stderr);
414+
try std.testing.expect(result.term == .exited and result.term.exited == 0);
415+
}
416+
417+
test "teardown abandons a file worker stuck on a reader-less FIFO and leaks its world loudly" {
418+
if (builtin.os.tag == .windows) return error.SkipZigTest;
419+
const io = std.testing.io;
420+
var tmp = std.testing.tmpDir(.{});
421+
defer tmp.cleanup();
422+
423+
var fifo_path_buffer: [256]u8 = undefined;
424+
const fifo_path = try std.fmt.bufPrint(&fifo_path_buffer, ".zig-cache/tmp/{s}/stuck.fifo", .{tmp.sub_path[0..]});
425+
try makeFifo(fifo_path);
426+
427+
var h = try Harness.create();
428+
defer h.destroy();
429+
const fx = &h.app_state.effects;
430+
// Tiny injected budget, interruption disabled: this test pins the
431+
// SAFETY NET (abandon-and-leak). The interruption path that
432+
// normally converges first has its own test below.
433+
fx.file_join_deadline_ms = 300;
434+
fx.file_join_interrupt = false;
435+
436+
test_path = fifo_path;
437+
test_bytes = "never delivered";
438+
try h.app_state.dispatch(&h.harness.runtime, 1, .save);
439+
// Let the worker reach the blocking open. Not required for the
440+
// abandon to fire — with interruption off, any still-running
441+
// posture past the deadline is abandoned — but it exercises the
442+
// real blocked shape.
443+
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(50), .awake);
444+
445+
const start_ns = clock_mod.monotonicNanoseconds();
446+
fx.deinit();
447+
const elapsed_ms = (clock_mod.monotonicNanoseconds() - start_ns) / std.time.ns_per_ms;
448+
449+
// Teardown returned on the budget's order of magnitude (generous
450+
// bound for congested runners) instead of hanging behind the FIFO
451+
// forever...
452+
try std.testing.expect(elapsed_ms < 10_000);
453+
// ...and abandoned exactly the stuck worker, loudly through the
454+
// counter seam, leaving no joinable thread and no running slot —
455+
// the owner may free the channel's memory right now.
456+
try std.testing.expectEqual(@as(u32, 1), fx.abandoned_file_workers);
457+
for (&fx.slots) |*slot| {
458+
try std.testing.expect(slot.worker_thread == null);
459+
try std.testing.expect(slot.state.load(.acquire) != .running);
460+
}
461+
462+
// The process is healthy after the leak: a fresh channel runs a
463+
// real file round trip and its own safety net stays quiet.
464+
var h2 = try Harness.create();
465+
defer h2.destroy();
466+
var path_buffer: [256]u8 = undefined;
467+
test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/healthy.json", .{tmp.sub_path[0..]});
468+
test_bytes = "{\"alive\":true}";
469+
try h2.app_state.dispatch(&h2.harness.runtime, 1, .save);
470+
try waitForRealResult(&h2, 1);
471+
try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h2.app_state.model.last_outcome.?);
472+
try std.testing.expectEqual(@as(u32, 0), h2.app_state.effects.abandoned_file_workers);
473+
474+
// Wake the abandoned worker under the leak invariant: opening the
475+
// FIFO's read end completes its blocked open; it performs its
476+
// write against its (leaked, still-valid) context and buffer,
477+
// closes the FIFO, finds itself abandoned, and exits without
478+
// touching the torn-down channel — if the leak were not honored,
479+
// this is where a use-after-free would crash the test. Draining to
480+
// EOF keeps the read end open across the worker's write (a write
481+
// into a reader-less pipe raises SIGPIPE, and the io's no-op
482+
// handler is not installed between tests) and proves the worker
483+
// really woke: EOF only arrives once it opened and closed the
484+
// write end.
485+
var reader = try std.Io.Dir.cwd().openFile(io, fifo_path, .{});
486+
defer reader.close(io);
487+
var drain_buffer: [128]u8 = undefined;
488+
while (true) {
489+
const read_slices: [1][]u8 = .{&drain_buffer};
490+
const count = reader.readStreaming(io, &read_slices) catch break;
491+
if (count == 0) break;
492+
}
493+
}
494+
495+
test "an abandoned file worker survives the owner's allocator dying: its leak is process-lived only" {
496+
if (builtin.os.tag == .windows) return error.SkipZigTest;
497+
const io = std.testing.io;
498+
var tmp = std.testing.tmpDir(.{});
499+
defer tmp.cleanup();
500+
501+
var fifo_path_buffer: [256]u8 = undefined;
502+
const fifo_path = try std.fmt.bufPrint(&fifo_path_buffer, ".zig-cache/tmp/{s}/arena.fifo", .{tmp.sub_path[0..]});
503+
try makeFifo(fifo_path);
504+
505+
// The channel — and every caller-side allocation it makes — lives
506+
// in an arena backed by the leak-checking testing allocator and is
507+
// deinitialized right after teardown: the exact owner-lifetime
508+
// posture the abandon leak must survive. The channel struct itself
509+
// sits in the arena too, so even the worker's `self` pointer dies
510+
// with the owner.
511+
const Channel = effects_mod.Effects(FileMsg);
512+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
513+
var arena_live = true;
514+
defer if (arena_live) arena.deinit();
515+
const fx = try arena.allocator().create(Channel);
516+
fx.* = Channel.init(arena.allocator());
517+
// Tiny injected budget, interruption disabled: pin the
518+
// abandon-and-leak safety net, exactly like the loud-leak test.
519+
fx.file_join_deadline_ms = 300;
520+
fx.file_join_interrupt = false;
521+
522+
fx.writeFile(.{ .key = 1, .path = fifo_path, .bytes = "never delivered", .on_result = null });
523+
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(50), .awake);
524+
525+
fx.deinit();
526+
try std.testing.expectEqual(@as(u32, 1), fx.abandoned_file_workers);
527+
528+
// Kill the owner's allocator: everything the channel ever got from
529+
// it — including the channel struct — is gone. The abandoned
530+
// worker must not notice: all it can still reach (its context,
531+
// that context's buffer, the executor io) is `process_allocator`
532+
// storage.
533+
arena.deinit();
534+
arena_live = false;
535+
536+
// Wake the abandoned worker under that invariant: opening the
537+
// FIFO's read end completes its blocked open; it writes through
538+
// its process-lived context and buffer, finds itself abandoned,
539+
// and exits without touching the dead arena — if any of its
540+
// reachable memory were caller-allocated, this walk is where the
541+
// use-after-free would crash the test. Draining to EOF keeps the
542+
// read end open across the worker's write and proves the worker
543+
// really woke.
544+
var reader = try std.Io.Dir.cwd().openFile(io, fifo_path, .{});
545+
defer reader.close(io);
546+
var drain_buffer: [128]u8 = undefined;
547+
while (true) {
548+
const read_slices: [1][]u8 = .{&drain_buffer};
549+
const count = reader.readStreaming(io, &read_slices) catch break;
550+
if (count == 0) break;
551+
}
552+
553+
// And the happy path still frees everything through the same
554+
// seams: a fresh channel backed DIRECTLY by the testing allocator
555+
// runs a real write to completion and tears down joined — the
556+
// leak check at test end guards the caller-side allocations, and
557+
// `joinWorker`/`deinit` return the context, worker buffer, and
558+
// executor io to `process_allocator`.
559+
var healthy = Channel.init(std.testing.allocator);
560+
defer healthy.deinit();
561+
var path_buffer: [256]u8 = undefined;
562+
const healthy_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/healthy.json", .{tmp.sub_path[0..]});
563+
healthy.writeFile(.{ .key = 2, .path = healthy_path, .bytes = "{\"alive\":true}", .on_result = null });
564+
var waited_ms: usize = 0;
565+
while (waited_ms < 20_000) : (waited_ms += 10) {
566+
if (healthy.hasPending()) break;
567+
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .awake);
568+
}
569+
try std.testing.expect(healthy.hasPending());
570+
healthy.deinit();
571+
try std.testing.expectEqual(@as(u32, 0), healthy.abandoned_file_workers);
572+
}
573+
574+
test "teardown interrupts a file worker stuck on a reader-less FIFO and joins it with no leak" {
575+
if (builtin.os.tag == .windows) return error.SkipZigTest;
576+
const io = std.testing.io;
577+
var tmp = std.testing.tmpDir(.{});
578+
defer tmp.cleanup();
579+
580+
var fifo_path_buffer: [256]u8 = undefined;
581+
const fifo_path = try std.fmt.bufPrint(&fifo_path_buffer, ".zig-cache/tmp/{s}/interrupted.fifo", .{tmp.sub_path[0..]});
582+
try makeFifo(fifo_path);
583+
584+
var h = try Harness.create();
585+
defer h.destroy();
586+
const fx = &h.app_state.effects;
587+
// Interruption stays on (the default): the best-effort cancel at
588+
// the halfway mark must interrupt the blocked open and JOIN the
589+
// worker — the abandon safety net must never fire here.
590+
fx.file_join_deadline_ms = 2_000;
591+
592+
test_path = fifo_path;
593+
test_bytes = "never delivered";
594+
try h.app_state.dispatch(&h.harness.runtime, 1, .save);
595+
try std.Io.sleep(io, std.Io.Duration.fromMilliseconds(50), .awake);
596+
597+
fx.deinit();
598+
599+
// Joined, not leaked: the syscall interruption converged the
600+
// worker inside the deadline (deinit is deadline-bounded either
601+
// way, so reaching these asserts already proves it returned).
602+
try std.testing.expectEqual(@as(u32, 0), fx.abandoned_file_workers);
603+
for (&fx.slots) |*slot| {
604+
try std.testing.expect(slot.worker_thread == null);
605+
try std.testing.expect(slot.state.load(.acquire) != .running);
606+
}
607+
}
608+
389609
test "a cancel racing a finished file effect still reports one cancelled terminal" {
390610
var tmp = std.testing.tmpDir(.{});
391611
defer tmp.cleanup();

0 commit comments

Comments
 (0)