From 6834143de9b922584ef478377dea3dc9a21f07f7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 28 Nov 2025 20:46:02 +1300 Subject: [PATCH] Add support for `Process.fork` within an active scheduler. --- lib/async/fork_handler.rb | 31 ++++++++++++++++++++++++++ lib/async/node.rb | 3 ++- lib/async/scheduler.rb | 44 +++++++++++++++++++++++++++--------- releases.md | 4 ++++ test/process/fork.rb | 47 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 lib/async/fork_handler.rb create mode 100644 test/process/fork.rb diff --git a/lib/async/fork_handler.rb b/lib/async/fork_handler.rb new file mode 100644 index 00000000..a4ce673e --- /dev/null +++ b/lib/async/fork_handler.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +module Async + # Private module that hooks into Process._fork to handle fork events. + # + # If `Scheduler#process_fork` hook is adopted in Ruby 4, this code can be removed after Ruby < 4 is no longer supported. + module ForkHandler + def _fork(&block) + result = super + + if result.zero? + # Child process: + if Fiber.scheduler.respond_to?(:process_fork) + Fiber.scheduler.process_fork + end + end + + return result + end + end + + private_constant :ForkHandler + + # Hook into Process._fork to handle fork events automatically: + unless (Fiber.const_get(:SCHEDULER_PROCESS_FORK) rescue false) + ::Process.singleton_class.prepend(ForkHandler) + end +end diff --git a/lib/async/node.rb b/lib/async/node.rb index d64aa446..78d59cc7 100644 --- a/lib/async/node.rb +++ b/lib/async/node.rb @@ -214,7 +214,8 @@ def parent=(parent) end protected def remove_child(child) - @children.remove(child) + # In the case of a fork, the children list may be nil: + @children&.remove(child) child.set_parent(nil) end diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 47d418e6..0935da9d 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -9,6 +9,7 @@ require_relative "clock" require_relative "task" require_relative "timeout" +require_relative "fork_handler" require "io/event" @@ -146,24 +147,26 @@ def terminate # Terminate all child tasks and close the scheduler. # @public Since *Async v1*. def close - self.run_loop do - until self.terminate - self.run_once! + unless @children.nil? + self.run_loop do + until self.terminate + self.run_once! + end end end Kernel.raise "Closing scheduler with blocked operations!" if @blocked > 0 ensure # We want `@selector = nil` to be a visible side effect from this point forward, specifically in `#interrupt` and `#unblock`. If the selector is closed, then we don't want to push any fibers to it. - selector = @selector - @selector = nil - - selector&.close - - worker_pool = @worker_pool - @worker_pool = nil + if selector = @selector + @selector = nil + selector.close + end - worker_pool&.close + if worker_pool = @worker_pool + @worker_pool = nil + worker_pool.close + end consume end @@ -642,5 +645,24 @@ def timeout_after(duration, exception, message, &block) yield duration end end + + # Handle fork in the child process. This method is called automatically when `Process.fork` is invoked. + # + # The child process starts with a clean slate - no scheduler is set. Users can create a new scheduler if needed. + # + # @public Since *Async v2.35*. + def process_fork + if profiler = @profiler + @profiler = nil + profiler.stop + end + + @children = nil + @selector = nil + @timers = nil + + # Close the scheduler: + Fiber.set_scheduler(nil) + end end end diff --git a/releases.md b/releases.md index fabc8067..e7248682 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - `Process.fork` is now properly handled by the Async fiber scheduler, ensuring that the scheduler state is correctly reset in the child process after a fork. This prevents issues where the child process inherits the scheduler state from the parent, which could lead to unexpected behavior. + ## v2.34.0 ### `Kernel::Barrier` Convenience Interface diff --git a/test/process/fork.rb b/test/process/fork.rb new file mode 100644 index 00000000..4ff15f97 --- /dev/null +++ b/test/process/fork.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "sus/fixtures/async" +require "async" + +describe Process do + describe ".fork" do + it "can fork with block form" do + r, w = IO.pipe + + Async do + pid = Process.fork do + # Child process: + w.write("hello") + end + + # Parent process: + w.close + expect(r.read).to be == "hello" + ensure + Process.waitpid(pid) if pid + end + end + + it "can fork with non-block form" do + r, w = IO.pipe + + Async do + unless pid = Process.fork + # Child process: + w.write("hello") + + exit! + end + + # Parent process: + w.close + expect(r.read).to be == "hello" + ensure + Process.waitpid(pid) if pid + end + end + end +end