Skip to content

test_forkserver_sigkill: join() can return before the child sets the event #160295

Open

Description

@devdavidejesus

Bug report

Bug description:

test_forkserver_sigkill failed once during a full ./python.exe -m test -j0 run on my machine (macOS 27.0.1, Apple M3, pydebug build of main at 1705e51). The load average was around 65 at the time. It passes when run on its own.

test test.test_multiprocessing_forkserver.test_processes failed -- Traceback (most recent call last):
  File "Lib/test/_test_multiprocessing.py", line 1007, in test_forkserver_sigkill
    self.check_forkserver_death(signal.SIGKILL)
  File "Lib/test/_test_multiprocessing.py", line 997, in check_forkserver_death
    self.assertTrue(evt.is_set())
AssertionError: False is not true

(paths shortened)

This is the same failure as gh-78221 (bpo-34040). It was worked around in 2018 by shortening the child's delay relative to the parent's sleep (07888e1), but the race is still there.

check_forkserver_death() kills the forkserver right after starting the first process. After a SIGKILL, the forkserver can no longer report that child's exit status: proc.join() gets EOF on the status pipe, and Popen.poll() in Lib/multiprocessing/popen_forkserver.py sets returncode to 255 immediately. The child may still be running at that point. The test then checks evt.is_set(), which only passes if the child has set the event by then (within the 1 second sleep plus the time proc2 takes). The next line already accepts exit code 255 for the case where the forkserver was killed.

SIGINT is not affected: the forkserver ignores it, stays alive and reports the real exit code.

To reproduce it reliably, stop the first child with SIGSTOP for a few seconds after killing the forkserver:

Reproducer
import multiprocessing as mp, os, signal, threading, time
from multiprocessing.forkserver import _forkserver

def sleep_and_set(evt, delay=0.0):
    time.sleep(delay)
    evt.set()

if __name__ == '__main__':
    mp.set_start_method('forkserver')
    _forkserver.ensure_running()
    evt = mp.Event()
    proc = mp.Process(target=sleep_and_set, args=(evt, 0.5))
    proc.start()
    os.kill(_forkserver._forkserver_pid, signal.SIGKILL)
    # Simulate the child not getting CPU time for 3 seconds.
    os.kill(proc.pid, signal.SIGSTOP)
    threading.Timer(3.0, os.kill, (proc.pid, signal.SIGCONT)).start()
    time.sleep(1.0)
    proc.join()
    print(proc.exitcode, evt.is_set())  # 255 False
    print(evt.wait(60))                 # True: the child did run, later

Waiting for the event with a timeout, instead of checking it right after join(), fixes it.

The same code is in 3.13, 3.14 and 3.15.

CPython versions tested on:

CPython main branch

Operating systems tested on:

macOS, Linux

Linked PRs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions