Forking GitHub issue
- Avoiding zombies
- Three spawn forms
- Closure semantics
- The $fork callback parameter
- Auto-close at script end
- The manager handle ($mgr)
- Open points
vibecode
{"vibecode": { "doc": "forking", "role": "spec for Caspian's forking feature — how user code spawns OS-level child processes, how the engine prevents zombie processes by default, the three spawn forms (tracked single, tracked pool, detached), the callback signature for the forked block, and the manager-handle pattern for inspecting and controlling spawned children.", "status": "committed for V1.0 — three spawn forms, closure semantics, $fork callback, auto-close-at-script-end, disabling-auto-close via %engine.auto_close_forks all settled. Open points (manager-handle details, IPC, role/resource inheritance, crash behavior, grace period) settle during implementation rather than block design. Required by UDS, shared-hash, and the broader server-with-workers pattern.", "supersedes": "all prior fork mentions in syntax/system-methods.md, cli.md, frank.md, network/http/server/sammy/index.md, packages/bryton/runner.md, packages/jasmine/index.md, built-in-classes/filesystem.md, and incidental references elsewhere", "key_concepts": ["all_forking_lives_under_percent_utils", "three_spawn_forms_single_multiple_detach", "engine_auto_closes_tracked_forks_at_script_end", "detached_processes_survive_script_end", "fork_callback_param_with_index_and_parent_pid", "standard_closure_semantics_for_the_block", "manager_handle_returned_from_every_spawn"] }}
Avoiding zombies GitHub issue
This design tries to make easy to avoid accidentally creating zombie processes. By default, every fork the user code spawns is tracked by the engine and automatically closed when the script ends. A developer who forgets to wait, reap, or kill a child doesn't pay for it with a polluted process table.
Detached processes are the explicit opt-out: code that genuinely wants a child to outlive the script uses %utils.detach, which is described separately below. Everything else gets the safety net.
Three spawn forms GitHub issue
All three live under %utils. No top-level %forks namespace.
%utils.forks.single() — one tracked fork GitHub issue
$mgr = %utils.forks.single() do($fork)
# code that runs in the child process
end
Spawns one OS-level child. The child runs the block; the parent gets a manager handle ($mgr).
%utils.forks.multiple(N) — pool of N tracked forks GitHub issue
$multi_mgr = %utils.forks.multiple(20) do($fork)
# code that runs in each child process
end
Spawns N OS-level children. Each child runs the same block independently. The block receives a $fork parameter that lets the child distinguish itself from siblings (see $fork).
Worker-pool primitive — common patterns include "spawn N workers polling a shared queue" or "split work across N children by $fork.index."
%utils.detach() — one detached fork GitHub issue
$mgr = %utils.detach() do($fork)
# code that runs in the detached process
end
Same shape as forks.single, but the child is not tracked by the engine and is not auto-closed at script end. The parent's handle ($mgr) still exists for inspection and explicit control, but the lifecycle is no longer tied to the parent script's.
Use when the child genuinely needs to outlive the parent — daemon spawning, background workers that should survive a script restart, etc.
Closure semantics GitHub issue
The block passed to any of the three spawn forms captures the parent's lexical environment at fork time. Standard closure behavior.
- The child sees a snapshot of the parent's state as it was when the fork call happened.
- Mutations on either side after the fork do not propagate — the two processes have separate memory.
- This is consistent with how
fork(2)works at the OS level: the kernel duplicates the address space; from that moment on, the two processes diverge.
No fresh-state / clean-slate mode. Spawn block always sees what was in scope.
The $fork callback parameter GitHub issue
Every spawn form passes a $fork object to the block. Same shape across all three forms.
| Field | Type | Description |
|---|---|---|
$fork.index |
integer | Which child this is in a pool. 0 for single() and detach(); 0..N-1 for multiple(N). |
$fork.parent.pid |
integer | OS process ID of the spawning parent. |
That's the entire surface for now. $fork.parent exposes nothing beyond .pid; richer parent/child communication (message passing, broadcasts back up) is deferred to a future revision.
Example GitHub issue
Four workers, each writing to its own log file and tagging entries with the parent process ID:
%utils.forks.multiple(4) do($fork)
$log = %fs.open('/var/log/worker-' + $fork.index + '.log', :append)
$log.puts 'worker ' + $fork.index + ' started; parent pid = ' + $fork.parent.pid
&do_work($fork.index)
$log.close
end
Each child gets its own $fork.index (0, 1, 2, 3), so the four log paths don't collide. All four share the same $fork.parent.pid — that's the spawning process they were forked from.
For single() and detach(), $fork.index is always 0. The block still receives $fork so the same body can be moved between forms without code changes.
The symmetric callback signature across all three forms means code that works with one form is shaped identically to code that works with another — useful for refactoring "I want to detach this" or "I want to spawn 5 of these" without restructuring the body.
Auto-close at script end GitHub issue
When the script terminates (normal exit, uncaught exception, signal, or any other path that ends the run), the engine automatically closes any tracked forks that are still alive.
- Detached forks are not affected. They keep running.
- Tracked forks (created via
%utils.forks.singleor%utils.forks.multiple) get cleaned up: - SIGTERM with a brief grace period so they can exit cleanly.
- SIGKILL if they don't exit within the grace period.
- Exit statuses reaped so nothing ends up as a zombie even briefly.
This makes the common case zombie-safe by construction. A developer who forks something and forgets to reap doesn't accidentally leave processes behind.
Disabling auto-close GitHub issue
The behavior can be turned off:
%engine.auto_close_forks = false
After this assignment, tracked forks are no longer cleaned up at script end — they survive, just like detached forks. Useful when a script's entire purpose is to spawn long-lived workers and using %utils.detach for every one would be tedious.
User-role only. Like every other %engine property, this assignment is restricted to the user role; nested libraries and other roles cannot reach %engine at all and so cannot flip this setting. The default-safe behavior cannot be silently disabled by anything other than the user's own code.
The manager handle ($mgr) GitHub issue
Every spawn form returns a manager handle. The shape isn't fully spec'd yet, but it's expected to expose at least:
| Field / method | Purpose |
|---|---|
.pid |
OS process ID of the child (for single-child handles). |
.state |
Current state — :running, :exited, :killed, etc. |
.exit_code |
Available after the child has exited. |
.kill |
Terminate the child. |
.wait |
Block the parent until the child exits. |
.alive? |
Quick boolean check. |
The handle for %utils.forks.multiple(N) manages a pool rather than a single child; whether it exposes collective methods (.kill_all, .wait_all, .alive_count), an iterable array of per-child mgrs, or both is open.
Open points GitHub issue
$multi_mgrshape. Collective-methods-only, array-of-mgrs, or both? Affects how a parent works with a pool of children.- Parent/child communication. Currently
$fork.parentexposes only.pid. No IPC primitive. Whether to add explicit.send(message)/.receivemethods, or whether to lean on the event system (parent registers listeners on$mgr; child broadcasts back via%utils.broadcast), or some other shape — TBD. - Role inheritance. What role does the child process run as? Same as parent at fork time? Configurable? Restricted in any way?
- Resource inheritance. Which engine-granted resources does the child inherit —
%net,%tmp, fs handles, open sockets, environment variables? All? None? Configurable per fork? - Behavior on child crash. Does the parent get notified (event broadcast? exception?)? Is there an auto-restart pattern, or is that left to user code?
- Grace period for SIGTERM-before-SIGKILL. Configurable, or fixed by the engine?
- Detached process introspection.
$mgrfor a detached process — does it have all the same methods (.kill,.wait, etc.) even though the engine isn't actively tracking the child? Probably yes, but worth confirming. - Multiple-detach form. Does
%utils.detach.multiple(N) do ... endexist as a parallel toforks.multiple, or is detaching always single-child? Probably the latter, but the symmetric option is available.