Skip to content

Commit d39a4af

Browse files
committed
fix: pin worker start method to fork in LMI mode
multiprocessing.Process used the interpreter default start method, which is fork on 3.13 and forkserver on 3.14+, so the worker process model differed by runtime. Pin "fork" via get_context so workers are direct children of the runtime process on every version and the parent default is left untouched for customer code.
1 parent c1ce090 commit d39a4af

3 files changed

Lines changed: 86 additions & 10 deletions

File tree

‎awslambdaric/lambda_multi_concurrent_utils.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ def run_concurrent(
6767
):
6868
cls._emit_worker_pool_event(max_concurrency)
6969

70+
# Not the interpreter default: fork on 3.13, forkserver on 3.14+.
71+
ctx = multiprocessing.get_context("fork")
7072
processes = []
7173
for _ in range(max_concurrency):
72-
p = multiprocessing.Process(
74+
p = ctx.Process(
7375
target=cls.run_single,
7476
args=(handler, api_addr, use_thread, socket_path),
7577
)

‎tests/test_concurrency.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
"""
44

55
import multiprocessing
6+
import os
7+
import sys
8+
import threading
9+
import types
610
import unittest
711
from unittest.mock import patch, MagicMock
812

@@ -52,6 +56,58 @@ def fake_bootstrap_run(handler, lambda_runtime_client):
5256
self.assertEqual(success_counter.value, 6)
5357
self.assertEqual(fail_counter.value, 2)
5458

59+
def test_workers_are_forked_children_regardless_of_default_start_method(self):
60+
"""The runner must not inherit the interpreter default start method.
61+
62+
Force the default to "forkserver" (what 3.14+ picks) and check that
63+
workers are still direct children of this process, that the parent
64+
forked while single-threaded, and that a module imported in the parent
65+
before the fork is already present in each worker (fork semantics, not
66+
a fresh interpreter).
67+
"""
68+
previous = multiprocessing.get_start_method(allow_none=True)
69+
multiprocessing.set_start_method("forkserver", force=True)
70+
try:
71+
parent_pid = os.getpid()
72+
parent_threads_at_fork = threading.active_count()
73+
marker = "_fork_pin_marker_module"
74+
sys.modules[marker] = types.ModuleType(marker)
75+
n = 3
76+
ppids = multiprocessing.Array("i", n)
77+
inherited = multiprocessing.Array("i", n)
78+
slot = multiprocessing.Value("i", 0)
79+
80+
def fake_bootstrap_run(handler, lambda_runtime_client):
81+
with slot.get_lock():
82+
i = slot.value
83+
slot.value += 1
84+
ppids[i] = os.getppid()
85+
inherited[i] = int(marker in sys.modules)
86+
87+
with patch(
88+
"awslambdaric.lambda_multi_concurrent_utils.MultiConcurrentRunner._redirect_output"
89+
), patch(
90+
"awslambdaric.lambda_multi_concurrent_utils.MultiConcurrentRunner._emit_worker_pool_event"
91+
), patch(
92+
"awslambdaric.lambda_multi_concurrent_utils.bootstrap.run",
93+
side_effect=fake_bootstrap_run,
94+
):
95+
MultiConcurrentRunner.run_concurrent(
96+
self.handler,
97+
self.addr,
98+
self.use_thread,
99+
self.socket,
100+
max_concurrency=n,
101+
)
102+
103+
self.assertEqual(multiprocessing.get_start_method(), "forkserver")
104+
self.assertEqual(parent_threads_at_fork, 1)
105+
self.assertEqual(list(ppids), [parent_pid] * n)
106+
self.assertEqual(list(inherited), [1] * n)
107+
finally:
108+
sys.modules.pop(marker, None)
109+
multiprocessing.set_start_method(previous, force=True)
110+
55111

56112
if __name__ == "__main__":
57113
unittest.main()

‎tests/test_multi_concurrent_runner.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,32 +59,50 @@ def test_run_single_creates_client_and_calls_bootstrap(
5959
mock_bootstrap.run.assert_called_once_with("h.fn", mock_client)
6060

6161
@patch.object(MultiConcurrentRunner, "_emit_worker_pool_event")
62-
@patch("multiprocessing.Process")
63-
def test_run_concurrent_spawns_and_joins(self, mock_process, mock_emit):
62+
@patch("multiprocessing.get_context")
63+
def test_run_concurrent_spawns_and_joins(self, mock_get_context, mock_emit):
6464
fake_proc = MagicMock()
65-
mock_process.return_value = fake_proc
65+
mock_ctx = MagicMock()
66+
mock_ctx.Process.return_value = fake_proc
67+
mock_get_context.return_value = mock_ctx
6668

6769
MultiConcurrentRunner.run_concurrent(
6870
"h", "a", False, "/sock", max_concurrency=3
6971
)
7072

71-
self.assertEqual(mock_process.call_count, 3)
73+
self.assertEqual(mock_ctx.Process.call_count, 3)
7274
self.assertEqual(fake_proc.start.call_count, 3)
7375
self.assertEqual(fake_proc.join.call_count, 3)
7476

75-
for call_args in mock_process.call_args_list:
77+
for call_args in mock_ctx.Process.call_args_list:
7678
target = call_args.kwargs.get("target") or call_args[1].get("target")
7779
args = call_args.kwargs.get("args") or call_args[1].get("args")
7880
self.assertEqual(target, MultiConcurrentRunner.run_single)
7981
self.assertEqual(args, ("h", "a", False, "/sock"))
8082

81-
@patch("multiprocessing.Process")
83+
@patch.object(MultiConcurrentRunner, "_emit_worker_pool_event")
84+
@patch("multiprocessing.get_context")
85+
def test_run_concurrent_pins_fork_start_method(self, mock_get_context, mock_emit):
86+
"""Workers come from an explicit "fork" context, never from the
87+
interpreter default (fork on 3.13, forkserver on 3.14+)."""
88+
mock_ctx = MagicMock()
89+
mock_get_context.return_value = mock_ctx
90+
91+
MultiConcurrentRunner.run_concurrent(
92+
"h", "a", False, "/sock", max_concurrency=2
93+
)
94+
95+
mock_get_context.assert_called_once_with("fork")
96+
self.assertEqual(mock_ctx.Process.call_count, 2)
97+
98+
@patch("multiprocessing.get_context")
8299
def test_run_concurrent_emits_worker_pool_event_once_before_spawning(
83-
self, mock_process
100+
self, mock_get_context
84101
):
85-
mock_process.return_value = MagicMock()
102+
mock_ctx = MagicMock()
103+
mock_get_context.return_value = mock_ctx
86104
order_tracker = MagicMock()
87-
order_tracker.attach_mock(mock_process, "process")
105+
order_tracker.attach_mock(mock_ctx.Process, "process")
88106

89107
with patch.object(
90108
MultiConcurrentRunner, "_emit_worker_pool_event"

0 commit comments

Comments
 (0)