Skip to content

Commit 764fe1b

Browse files
Add support for Process.fork within an active scheduler.
1 parent ea8b072 commit 764fe1b

File tree

4 files changed

+121
-12
lines changed

4 files changed

+121
-12
lines changed

lib/async/fork_handler.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
module Async
7+
# Private module that hooks into Process._fork to handle fork events.
8+
module ForkHandler
9+
def _fork(&block)
10+
if block_given?
11+
super do
12+
# Child process:
13+
if scheduler = Fiber.scheduler
14+
scheduler.process_fork if scheduler.respond_to?(:process_fork)
15+
end
16+
17+
yield
18+
end
19+
else
20+
unless pid = super
21+
# Child process:
22+
if scheduler = Fiber.scheduler
23+
scheduler.process_fork if scheduler.respond_to?(:process_fork)
24+
end
25+
end
26+
27+
return pid
28+
end
29+
end
30+
end
31+
32+
private_constant :ForkHandler
33+
34+
# Hook into Process._fork to handle fork events automatically:
35+
::Process.singleton_class.prepend(ForkHandler)
36+
end

lib/async/node.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ def parent=(parent)
214214
end
215215

216216
protected def remove_child(child)
217-
@children.remove(child)
217+
# In the case of a fork, the children list may be nil:
218+
@children&.remove(child)
218219
child.set_parent(nil)
219220
end
220221

lib/async/scheduler.rb

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_relative "clock"
1010
require_relative "task"
1111
require_relative "timeout"
12+
require_relative "fork_handler"
1213

1314
require "io/event"
1415

@@ -146,24 +147,26 @@ def terminate
146147
# Terminate all child tasks and close the scheduler.
147148
# @public Since *Async v1*.
148149
def close
149-
self.run_loop do
150-
until self.terminate
151-
self.run_once!
150+
unless @children.nil?
151+
self.run_loop do
152+
until self.terminate
153+
self.run_once!
154+
end
152155
end
153156
end
154157

155158
Kernel.raise "Closing scheduler with blocked operations!" if @blocked > 0
156159
ensure
157160
# 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.
158-
selector = @selector
159-
@selector = nil
160-
161-
selector&.close
162-
163-
worker_pool = @worker_pool
164-
@worker_pool = nil
161+
if selector = @selector
162+
@selector = nil
163+
selector.close
164+
end
165165

166-
worker_pool&.close
166+
if worker_pool = @worker_pool
167+
@worker_pool = nil
168+
worker_pool.close
169+
end
167170

168171
consume
169172
end
@@ -642,5 +645,27 @@ def timeout_after(duration, exception, message, &block)
642645
yield duration
643646
end
644647
end
648+
649+
# Handle fork in the child process. This method is called automatically when Process.fork is invoked.
650+
#
651+
# This method:
652+
# - Terminates all tasks forcefully (without raising exceptions)
653+
# - Closes the selector (kernel state doesn't survive fork)
654+
# - Resets scheduler state
655+
# - Closes worker pool if present
656+
# - Unsets the scheduler from Fiber.scheduler
657+
#
658+
# The child process starts with a clean slate - no scheduler is set.
659+
# Users can create a new scheduler if needed.
660+
#
661+
# @public Since *Async v2*.
662+
def process_fork
663+
@children = nil
664+
@selector = nil
665+
@timers = nil
666+
667+
# Close the scheduler:
668+
Fiber.set_scheduler(nil)
669+
end
645670
end
646671
end

test/process/fork.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "sus/fixtures/async"
7+
require "async"
8+
9+
describe Process do
10+
describe ".fork" do
11+
it "can fork with block form" do
12+
r, w = IO.pipe
13+
14+
Async do
15+
pid = Process.fork do
16+
# Child process:
17+
w.write("hello")
18+
end
19+
20+
# Parent process:
21+
w.close
22+
expect(r.read).to be == "hello"
23+
ensure
24+
Process.waitpid(pid) if pid
25+
end
26+
end
27+
28+
it "can fork with non-block form" do
29+
r, w = IO.pipe
30+
31+
Async do
32+
unless pid = Process.fork
33+
# Child process:
34+
w.write("hello")
35+
36+
exit!
37+
end
38+
39+
# Parent process:
40+
w.close
41+
expect(r.read).to be == "hello"
42+
ensure
43+
Process.waitpid(pid) if pid
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)