Hey. I’m building freebsd.cr, Crystal bindings for FreeBSD’s Capsicum capability mode, Casper privilege-separation framework, and related kernel security APIs. The Process.fork deprecation warning prompted me to raise a broader question about Crystal’s direction here.
Crystal already does fork safely — internally
Here is the current stdlib implementation:
# Only used by deprecated `::Process.fork`
def self.fork
{% raise("Process fork is unsupported with multithreaded mode") if flag?(:preview_mt) %}
pid, errno = lock_write do
pthread_disable_cancelstate do
block_signals do
pid = LibC.fork
{pid, Errno.value}
end
end
end
case pid
when 0
::Process.after_fork_child_callbacks.each(&.call)
nil
when -1
raise RuntimeError.from_os_error("fork", errno)
else
pid
end
end
Two things stand out:
- The stdlib already knows how to fork safely. It calls
after_fork_child_callbacksin the child, wraps the syscall inlock_write+pthread_disable_cancelstate+block_signals, and guards against multi-threaded mode with a compile-time{% raise %}. - The guard is
preview_mt, not “always unsafe.” In single-threaded mode, the stdlib considers fork safe enough to implement correctly.
The deprecation is a policy choice, not a technical impossibility. What has actually been deprecated is the general-purpose API — not the safety machinery underneath it.
What we use fork for
There are two fork patterns in the library, neither replaceable by spawn:
Casper helper (src/freebsd/casper/helper.cr:111) — pure-Crystal privilege separation
child_proc = Process.fork do
server = Server(C).new(helper_sock)
yield server # serve privileged requests over a UNIXSocket pair
end
The helper stays unsandboxed while the parent calls cap_enter(2) to enter capability mode.
The parent then delegates privileged operations (DNS, file access, network) to the helper over a
pre-opened socket pair.
pdfork(2) (src/freebsd/capsicum/process_descriptor.cr:171) — capability-mode-safe child management
pd = LibPdfork.pdfork(pointerof(fd), flags)
# child path:
Process.after_fork_child_callbacks.each(&.call) # reinitialize Crystal runtime
pdfork is a FreeBSD syscall that returns the child as a file descriptor rather than a PID. Once
the parent enters capability mode the PID namespace is gone, but the fd stays valid — so the parent can still pdkill/pdwait the child from inside the sandbox. We call after_fork_child_callbacks manually here because we bypassed Process.fork.
Both callsites follow exactly the same rules the stdlib enforces: fork before any concurrent fibers,
reinitialize the runtime immediately in the child.
Why spawn cannot substitute
cap_enter(2) is per-process. You cannot sandbox one fiber while leaving another unsandboxed.
Privilege separation requires genuine process separation. The same applies to other UNIX patterns that depend on real fork: daemonizing, prefork/unicorn worker models, and any
setuid/setgid/chroot isolation applied to a child rather than the parent.
What would help
-
Stabilize
Process.after_fork_child_callbacksas a public API. It is already the load-bearing piece — code wrapping rawLibC.forkorpdfork(2)needs it to reinitialize Crystal’s runtime correctly. If it disappears or gets renamed, the safe path breaks. -
Provide a supported escape hatch for single-threaded fork. Something like
Process.single_threaded do ... endor a compile-time flag that acknowledges “this code forks early and handles reinitialization itself.” The machinery is already there; it just needs a door that isn’t marked deprecated. -
Don’t remove
Process.forkwithout an equivalent. Without one, every legitimate UNIX privsep use case is pushed onto rawLibC.fork— an unsupported, undocumented path with no runtime reinitialization guarantee.
Crystal’s C-interop story is one of its strengths. The C ecosystem is full of security
vulnerabilities, and OS-level mitigations — least privilege, process isolation, capability
restrictions — are the best countermeasures available. It would be a shame if Crystal’s runtime
evolution made those mitigations harder to use, not easier.
Happy to share more implementation details from freebsd.cr as concrete examples.