Forking GitHub issue

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

caspian
$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

caspian
$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

caspian
$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.

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:

caspian
%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.

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:

caspian
%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


© 2026 Puck.uno