Files
Hannes Payer 34023c2087 [test] Add --fork-server mode to v8_unittests and testrunner
v8_unittests pays process creation, ~6,900 static GTest registrations,
ICU, snapshot and flag parsing for every single test, because the
testrunner spawns a fresh process per test.

Add a --fork-server mode on POSIX (excluding TSan, Fuchsia, iOS) that
performs this initialization once and then forks a child per
--gtest_filter request read from stdin. Each test still runs in its own
OS process. The testrunner keeps one server per worker and variant,
falls back to direct execution on any failure, and skips the server for
Clang coverage, num_fuzzer, per-subtest seeds and --command-prefix.
Opt out with --no-fork-server or V8_NO_FORK_SERVER=1.

Full-suite wall-clock time in x64.release drops from 12.1s to 5.8s.

TAG=agy
CONV=27a1690b-7f9b-495b-8ad4-dd989d7f50bd

Change-Id: I22a7985cde2713c146360030e6f4d16425ebb8bc
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8461671
Reviewed-by: Michael Achenbach <machenbach@chromium.org>
Commit-Queue: Hannes Payer <hpayer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#110460}
2026-10-08 07:06:11 -07:00

261 lines
9.4 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 the V8 project authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import os
import signal
import stat
import sys
import tempfile
import threading
import time
import unittest
# Needed because the test runner contains relative imports.
TOOLS_PATH = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.append(TOOLS_PATH)
from testrunner.local import command
from testrunner.local.pool import AbortException
FAKE_SERVER_SCRIPT = """#!{python}
import sys
import time
if len(sys.argv) < 2 or sys.argv[1] != '--fork-server':
print('DIRECT:' + ' '.join(sys.argv[1:]), end='')
sys.stderr.write('DIRECT_ERR')
sys.exit(0)
if '--fail-startup' in sys.argv:
sys.exit(1)
for line in sys.stdin:
filt = line.strip()
if filt == 'hang':
time.sleep(30)
elif filt == 'short_res':
sys.stdout.buffer.write(b'RES 0\\n')
sys.stdout.buffer.flush()
elif filt == 'garbage':
sys.stdout.buffer.write(b'NOT_A_RES\\n')
sys.stdout.buffer.flush()
elif filt == 'segv':
sys.stdout.buffer.write(b'RES -11 0 4\\nSEGV')
sys.stdout.buffer.flush()
elif filt == 'abrt':
sys.stdout.buffer.write(b'RES -6 0 4\\nABRT')
sys.stdout.buffer.flush()
else:
out = ('OUT:' + filt).encode('utf-8')
err = ('ERR:' + filt).encode('utf-8')
header = ('RES 0 %d %d\\n' % (len(out), len(err))).encode('utf-8')
sys.stdout.buffer.write(header + out + err)
sys.stdout.buffer.flush()
"""
class _DummyTestCase(object):
def __init__(self, use_fork_server=True):
self.use_fork_server = use_fork_server
@unittest.skipIf(sys.platform == 'win32', 'POSIX fork server only')
class ForkServerCommandTest(unittest.TestCase):
def setUp(self):
command.close_fork_servers()
command._FORK_SERVER_DISABLED = False
self._tmp = tempfile.NamedTemporaryFile(
mode='w', suffix='.py', delete=False)
self._tmp.write(FAKE_SERVER_SCRIPT.format(python=sys.executable))
self._tmp.close()
os.chmod(self._tmp.name, stat.S_IRWXU)
self.shell = self._tmp.name
def tearDown(self):
command.close_fork_servers()
command._FORK_SERVER_DISABLED = False
if os.path.exists(self._tmp.name):
os.unlink(self._tmp.name)
def _make_cmd(self,
test_filter='Suite.Test',
extra_args=None,
timeout=5,
use_fork_server=True,
cmd_prefix=None,
handle_sigterm=False):
args = list(extra_args or []) + [f'--gtest_filter={test_filter}']
return command.PosixCommand(
shell=self.shell,
args=args,
cmd_prefix=cmd_prefix,
timeout=timeout,
test_case=_DummyTestCase(use_fork_server=use_fork_server),
handle_sigterm=handle_sigterm,
)
def testSuccessAndReuse(self):
out1 = self._make_cmd('Suite.One').execute()
self.assertEqual(0, out1.exit_code)
self.assertFalse(out1.timed_out)
self.assertEqual('OUT:Suite.One', out1.stdout)
self.assertEqual('ERR:Suite.One', out1.stderr)
self.assertEqual(1, len(command._FORK_SERVERS))
out2 = self._make_cmd('Suite.Two').execute()
self.assertEqual(0, out2.exit_code)
self.assertEqual('OUT:Suite.Two', out2.stdout)
self.assertEqual('ERR:Suite.Two', out2.stderr)
self.assertEqual(out1.pid, out2.pid)
self.assertEqual(1, len(command._FORK_SERVERS))
def testSignalExitCodes(self):
segv = self._make_cmd('segv').execute()
self.assertEqual(-11, segv.exit_code)
self.assertTrue(segv.HasCrashed())
abrt = self._make_cmd('abrt').execute()
self.assertEqual(-6, abrt.exit_code)
self.assertFalse(abrt.HasCrashed())
def testMalformedHeaderFallsBackWithoutDisabling(self):
# First request succeeds so server.ok becomes True.
out1 = self._make_cmd('Suite.Ok').execute()
self.assertEqual('OUT:Suite.Ok', out1.stdout)
# Truncated RES header ("RES 0\n") triggers fallback to direct mode,
# evicts the broken server, and leaves fork server enabled for next test.
out2 = self._make_cmd('short_res').execute()
self.assertEqual(0, out2.exit_code)
self.assertIn('DIRECT:', out2.stdout)
self.assertFalse(command._FORK_SERVER_DISABLED)
self.assertEqual(0, len(command._FORK_SERVERS))
out3 = self._make_cmd('Suite.Recovered').execute()
self.assertEqual('OUT:Suite.Recovered', out3.stdout)
self.assertNotEqual(out1.pid, out3.pid)
def testNonResHeaderFallsBackWithoutDisabling(self):
out1 = self._make_cmd('Suite.Ok').execute()
self.assertEqual('OUT:Suite.Ok', out1.stdout)
# A line that is not a RES header at all takes the same fallback path as
# a truncated one: direct execution, eviction, fork server stays enabled.
out2 = self._make_cmd('garbage').execute()
self.assertEqual(0, out2.exit_code)
self.assertIn('DIRECT:', out2.stdout)
self.assertFalse(command._FORK_SERVER_DISABLED)
self.assertEqual(0, len(command._FORK_SERVERS))
def testStartupFailureStickyDisable(self):
out1 = self._make_cmd('Suite.One', extra_args=['--fail-startup']).execute()
self.assertIn('DIRECT:', out1.stdout)
self.assertTrue(command._FORK_SERVER_DISABLED)
self.assertEqual(0, len(command._FORK_SERVERS))
# Subsequent commands bypass fork server immediately.
out2 = self._make_cmd('Suite.Two').execute()
self.assertIn('DIRECT:', out2.stdout)
def testTimeoutEvictsAndRespawns(self):
out1 = self._make_cmd('hang', timeout=0.2).execute()
self.assertTrue(out1.timed_out)
self.assertEqual(0, len(command._FORK_SERVERS))
out2 = self._make_cmd('Suite.AfterTimeout').execute()
self.assertFalse(out2.timed_out)
self.assertEqual('OUT:Suite.AfterTimeout', out2.stdout)
def testLruCacheEviction(self):
procs = []
for i in range(command._MAX_FORK_SERVERS + 2):
out = self._make_cmd('Suite.Test', extra_args=[f'--flag={i}']).execute()
self.assertEqual('OUT:Suite.Test', out.stdout)
# Capture the Popen object now; _ForkServer.close() drops its reference.
server = command._FORK_SERVERS[next(reversed(command._FORK_SERVERS))]
self.assertEqual(out.pid, server.process.pid)
procs.append(server.process)
self.assertLessEqual(
len(command._FORK_SERVERS), command._MAX_FORK_SERVERS)
# First two servers should have been closed and reaped, the rest is alive.
for proc in procs[:2]:
self.assertIsNotNone(proc.returncode)
for proc in procs[2:]:
self.assertIsNone(proc.poll())
def testDirectModeWhenDisabledOrPrefixed(self):
out1 = self._make_cmd('Suite.One', use_fork_server=False).execute()
self.assertIn('DIRECT:', out1.stdout)
self.assertEqual(0, len(command._FORK_SERVERS))
out2 = self._make_cmd('Suite.Two', cmd_prefix=[sys.executable]).execute()
self.assertIn('DIRECT:', out2.stdout)
self.assertEqual(0, len(command._FORK_SERVERS))
def testDirectModeWithMultipleFilters(self):
# E.g. an additional --gtest_filter passed via --extra-flags. gtest's
# last-one-wins semantics are left to direct execution.
out = self._make_cmd(
'Suite.Two', extra_args=['--gtest_filter=Suite.One']).execute()
self.assertIn('DIRECT:', out.stdout)
self.assertIn('--gtest_filter=Suite.One --gtest_filter=Suite.Two',
out.stdout)
self.assertEqual(0, len(command._FORK_SERVERS))
self.assertFalse(command._FORK_SERVER_DISABLED)
def testSpawnErrorStickyDisable(self):
# A non-existent shell makes Popen raise OSError when spawning the server.
# This must not propagate but disable the fork server and fall back to
# direct execution (which reports the shell's error as a regular result).
self.shell = os.path.join(os.path.dirname(self.shell), 'does_not_exist')
out1 = self._make_cmd('Suite.One').execute()
self.assertNotEqual(0, out1.exit_code)
self.assertTrue(command._FORK_SERVER_DISABLED)
self.assertEqual(0, len(command._FORK_SERVERS))
out2 = self._make_cmd('Suite.Two').execute()
self.assertNotEqual(0, out2.exit_code)
self.assertEqual(0, len(command._FORK_SERVERS))
def testSigtermRaisesAbortException(self):
# The real signal.signal lives in the signal (or _signal) module; a no-op
# lambda installed by command.setup_testing() would leave SIGTERM at
# SIG_DFL and kill the test process.
if getattr(signal.signal, '__module__', None) not in ('signal', '_signal'):
self.skipTest('signal.signal is monkey-patched (command.setup_testing)')
# Safety net: should SIGTERM arrive before handle_sigterm installed its
# handler, it must not hit SIG_DFL and kill the test process.
previous = signal.signal(signal.SIGTERM, lambda *_: None)
self.addCleanup(signal.signal, signal.SIGTERM, previous)
cmd = self._make_cmd('hang', timeout=10, handle_sigterm=True)
main_thread_id = threading.main_thread().ident
def send_sigterm():
# Wait until the server is registered, i.e. the request is in flight.
while not command._FORK_SERVERS:
time.sleep(0.01)
time.sleep(0.05)
# pthread_kill is unaffected by pool.setup_testing() patching os.kill.
signal.pthread_kill(main_thread_id, signal.SIGTERM)
t = threading.Thread(target=send_sigterm)
t.start()
try:
with self.assertRaises(AbortException):
cmd.execute()
finally:
t.join()
self.assertEqual(0, len(command._FORK_SERVERS))
if __name__ == '__main__':
unittest.main()