Skip to content

Commit ed37b61

Browse files
committed
test: Run the subprocess-racing tests serially, and quiesce between them
A loaded 108-second suite run failed 14 tests at once -- every cancel/terminate test in the suite -- while the product was behaving correctly. All 14 pass serially. One log line makes the mechanism plain: Canceling subprocess 69379 via termination method Log from test 0 ... Log from test 19 # child ran the full 20s, exit 0 These tests start a real child, cancel or time it out, and assert on the outcome. The assertions are right, but they assume the child and the runtime get scheduled promptly. Under `-n auto` with twelve workers each also sleeping on a child, that assumption fails. The failure is indistinguishable from a real cancel regression, which is the expensive part: it teaches you to re-run rather than to read. Adds a `serial_process` mark (a `pytest.mark.xdist_group`) in conftest, applied to the tests that race the wall clock. Every such test lands on one xdist worker, so they run serially with respect to each other while the other ~800 tests still run in parallel. Requires `--dist=loadgroup`, added to addopts; under the default `--dist=load` the marker is accepted, ignored, and reported nowhere. Adds an autouse `_quiesce_after_process_test` fixture, keyed on that mark, which cancels stray `threading.Timer`s and waits briefly for the thread count to settle. Serialising only helps if the tests also stop overlapping in the background: a `ScriptRunnerBase` leaves a timer running for a whole unexpired timeout or grace period, plus a pool worker, and a test that finishes early by cancelling its child hands both to whatever runs next. Scoping, after measuring rather than assuming. Marking the two big classes wholesale put ~60 process tests on one worker and made it the critical path: the suite went 40s -> 94s. The mark is therefore on the 13 specific tests that actually flaked, not on `TestScriptRunnerBase` or `TestLoggingSubprocessSameUser` entire. The quiesce budget is 1s, not 5s: some tests legitimately leave a daemon stdout-reader thread that never exits, and a generous budget was being spent in full on every one of them -- measured at 5s of teardown for a single test. Also removes the suite's slowest test. `test_run_action_default_timeout`'s no-timeout case ran a 20-second child to completion, at 21.3s the slowest test by a factor of three and the entire critical path. Split into test_run_action_timeout_terminates_the_action (unchanged intent) and test_run_action_without_timeout_runs_to_completion, which uses a child that exits after half a second -- what is being asserted is that no timer cut the action short, and a child that exits on its own shows that just as well. Adds test_conftest_serial_process.py so this mechanism cannot regress silently. Note its first version asserted `getoption("dist") == "loadgroup"` and failed: inside an xdist worker that option is `"no"`, because a worker runs its share serially and only the controller distributes. It now reads `addopts`. No product code changes. Verified: three consecutive full runs, 855 passed / 0 failed, in 44.2s / 41.5s / 40.7s -- the pre-change baseline was 40s with intermittent 14-failure runs. ruff, mypy and black clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 572ae79 commit ed37b61

9 files changed

Lines changed: 223 additions & 27 deletions

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ addopts = [
155155
"--cov-report=xml:build/coverage/coverage.xml",
156156
"--cov-report=term-missing",
157157
"--numprocesses=auto",
158+
# loadgroup, not the default load: it honours @pytest.mark.xdist_group, which
159+
# the tests that race a real subprocess against the wall clock rely on to run
160+
# serially with respect to each other (see conftest.serial_process). Behaves
161+
# exactly like `load` for every test that is not in a group.
162+
"--dist=loadgroup",
158163
"--timeout=30",
159164
"--log-format=%(asctime)s.%(msecs)03d %(levelname)s %(filename)20s:%(lineno)-3s %(message)s",
160165
"--log-date-format=%H:%M:%S"

test/openjd/sessions_v0/conftest.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import random
55
import string
6+
import time
67
import uuid
78
from logging import INFO, getLogger
89
from logging.handlers import QueueHandler
@@ -66,6 +67,79 @@ def pytest_collection_modifyitems(config, items):
6667
config.option.markexpr = mark_expr
6768

6869

70+
SERIAL_PROCESS_GROUP = "serial_process"
71+
72+
serial_process = pytest.mark.xdist_group(SERIAL_PROCESS_GROUP)
73+
"""Mark for tests that race a real subprocess against wall-clock expectations.
74+
75+
Applied to a class or a test, it pins every such test onto ONE xdist worker, so
76+
they run serially with respect to each other instead of competing for CPU with
77+
eleven siblings that are each also sleeping on a child process.
78+
79+
Why this is needed: these tests start a child, cancel or time it out, and assert
80+
on the outcome. The assertions are correct, but they assume the child and the
81+
runtime get scheduled reasonably promptly. Under `-n auto` on a loaded host that
82+
assumption fails, and the whole cancel/terminate family goes red together while
83+
the product is behaving correctly -- observed as 14 simultaneous failures in a
84+
108-second run that all passed serially. The failure mode is indistinguishable
85+
from a real cancel regression, which is the expensive part: it trains you to
86+
re-run rather than to read.
87+
88+
Requires `--dist=loadgroup` (set in pyproject.toml). With plain `--dist=load` the
89+
marker is silently ignored -- see `test_conftest_serial_process.py`, which fails
90+
if that ever regresses.
91+
92+
This is not a substitute for fixing genuinely flaky assertions. Where a test
93+
asserted something it had no business asserting -- a +/-1 second window on a
94+
child's output, say -- that assertion was removed rather than protected by this.
95+
"""
96+
97+
98+
@pytest.fixture(autouse=True)
99+
def _quiesce_after_process_test(request: pytest.FixtureRequest) -> Generator[None, None, None]:
100+
"""Between serial-process tests, wait for the timers and threads the previous
101+
one created to actually go away.
102+
103+
A `ScriptRunnerBase` leaves a `threading.Timer` running for the whole of an
104+
unexpired timeout or cancel grace period, and a `ThreadPoolExecutor` worker
105+
behind it. A test that finishes early -- because it cancelled its child -- can
106+
therefore hand a live 30-second timer and a busy thread to whatever runs next.
107+
Serialising the tests only helps if they also stop overlapping in that way.
108+
109+
Cancels stray timers, then waits briefly for the thread count to settle. Does
110+
not assert: a leftover thread is not necessarily this test's fault, and turning
111+
that into a failure here would report it against the wrong test. It is logged
112+
so it is visible when it matters.
113+
"""
114+
marker = request.node.get_closest_marker("xdist_group")
115+
if marker is None or SERIAL_PROCESS_GROUP not in marker.args:
116+
yield
117+
return
118+
119+
import threading
120+
121+
before = threading.active_count()
122+
yield
123+
124+
for thread in threading.enumerate():
125+
if isinstance(thread, threading.Timer) and thread.is_alive():
126+
thread.cancel()
127+
128+
# Deliberately short. Some tests legitimately leave a daemon stdout-reader
129+
# thread behind that never exits (documented in LoggingSubprocess), so a
130+
# generous budget here is spent in full on every one of them -- measured at 5s
131+
# of pure teardown for a single test. One second is enough for a cancelled
132+
# timer and a pool worker to wind down, which is what this is for.
133+
deadline = time.monotonic() + 1.0
134+
while threading.active_count() > before and time.monotonic() < deadline:
135+
time.sleep(0.02)
136+
if threading.active_count() > before:
137+
print(
138+
f"\n[quiesce] {request.node.name} left "
139+
f"{threading.active_count() - before} extra thread(s) running"
140+
)
141+
142+
69143
def nonexistent_group_name() -> str:
70144
"""A group name that cannot resolve on any host.
71145

test/openjd/sessions_v0/test_concurrency_fixes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from openjd.sessions._subprocess import LoggingSubprocess
3232

3333
from .conftest import build_logger
34+
from .conftest import serial_process
3435

3536

3637
@pytest.mark.usefixtures("message_queue", "queue_handler")
@@ -299,6 +300,7 @@ def bad_callback(state: ActionState) -> None:
299300

300301
@pytest.mark.usefixtures("message_queue", "queue_handler")
301302
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-specific signal handling")
303+
@serial_process
302304
class TestReview22F4DoubleLoadFix:
303305
"""Review22-F4: notify/terminate must bind _process once and pass to helpers."""
304306

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
"""The `serial_process` mechanism must keep working, silently-failing being its
4+
whole risk.
5+
6+
`@pytest.mark.xdist_group` is only honoured under `--dist=loadgroup`. Under the
7+
default `--dist=load` it is accepted, ignored, and reported nowhere -- so the
8+
process-heavy tests would quietly go back to competing with eleven siblings and
9+
the cancel/terminate family would start failing together again on loaded hosts,
10+
looking exactly like a product regression.
11+
12+
These tests are cheap and exist so that a change to `addopts` fails here, next to
13+
an explanation, rather than as fourteen mystery failures somewhere else.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import pytest
19+
20+
from .conftest import SERIAL_PROCESS_GROUP, serial_process
21+
22+
23+
def test_loadgroup_distribution_is_configured(pytestconfig: pytest.Config) -> None:
24+
"""Pins the `--dist=loadgroup` setting the marker depends on.
25+
26+
Read from `addopts` rather than from `getoption("dist")`: inside an xdist
27+
worker the resolved `dist` option is `"no"`, because a worker runs its own
28+
share serially and only the controller distributes. Asserting on the resolved
29+
option therefore fails on the workers and passes nowhere useful -- which is
30+
exactly what the first version of this test did.
31+
"""
32+
# GIVEN / WHEN
33+
addopts = " ".join(pytestconfig.getini("addopts"))
34+
35+
# THEN
36+
assert "--dist=loadgroup" in addopts, (
37+
"xdist_group markers are silently ignored unless --dist=loadgroup; the "
38+
"serial_process tests would go back to running in parallel with each other"
39+
)
40+
41+
42+
def test_serial_process_is_an_xdist_group_marker() -> None:
43+
"""The mark must be the one xdist looks for, with the expected group name.
44+
45+
A rename or a typo would leave a decorator that reads as if it does something
46+
and does nothing at all.
47+
"""
48+
# GIVEN / WHEN
49+
mark = serial_process.mark
50+
51+
# THEN
52+
assert mark.name == "xdist_group"
53+
assert mark.args == (SERIAL_PROCESS_GROUP,)
54+
55+
56+
@serial_process
57+
class TestMarkerReachesTheTest:
58+
def test_the_marker_is_visible_on_a_decorated_test(
59+
self, request: pytest.FixtureRequest
60+
) -> None:
61+
"""A class-level mark must actually reach the test item.
62+
63+
This is what the quiesce fixture in conftest keys off, so if the mark stops
64+
propagating the cleanup silently stops running too.
65+
"""
66+
# GIVEN / WHEN
67+
marker = request.node.get_closest_marker("xdist_group")
68+
69+
# THEN
70+
assert marker is not None
71+
assert SERIAL_PROCESS_GROUP in marker.args

test/openjd/sessions_v0/test_runner_base.py

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from openjd.sessions._tempdir import TempDir
4040

4141
from .conftest import (
42+
serial_process,
4243
build_logger,
4344
collect_queue_messages,
4445
has_posix_target_user,
@@ -609,6 +610,7 @@ def test_cannot_run_twice(self, tmp_path: Path, python_exe: str) -> None:
609610
with pytest.raises(RuntimeError):
610611
runner._run([python_exe, "-c", "print('hello')"])
611612

613+
@serial_process
612614
@pytest.mark.usefixtures("message_queue", "queue_handler")
613615
def test_run_action(
614616
self,
@@ -714,37 +716,29 @@ def test_run_action_effective_timeout(
714716
expected = timedelta(seconds=expected_seconds) if expected_seconds is not None else None
715717
assert captured[0] == expected
716718

719+
@serial_process
717720
@pytest.mark.usefixtures("message_queue", "queue_handler")
718-
@pytest.mark.parametrize(
719-
argnames=("action_timeout_seconds", "expected_state"),
720-
argvalues=(
721-
pytest.param(2, ScriptRunnerState.TIMEOUT, id="timeout-terminates-the-action"),
722-
pytest.param(None, ScriptRunnerState.SUCCESS, id="no-timeout-runs-to-completion"),
723-
),
724-
)
725-
def test_run_action_timeout_is_enforced(
721+
def test_run_action_timeout_terminates_the_action(
726722
self,
727723
tmp_path: Path,
728724
message_queue: SimpleQueue,
729725
queue_handler: QueueHandler,
730-
action_timeout_seconds: Optional[int],
731-
expected_state: ScriptRunnerState,
732726
python_exe: str,
733727
) -> None:
734-
"""A declared timeout really does terminate the action, and no timeout
735-
really does let it finish.
736-
737-
The end-to-end half of the coverage above, kept deliberately loose: it
738-
asserts only the terminal state and that the child did or did not reach its
739-
last line. It says nothing about *when* the timeout landed, because the
740-
exact second the child reaches depends on how promptly the host scheduled
741-
it -- which is what made the previous version of this test flaky.
728+
"""A declared timeout really does terminate the action.
729+
730+
The end-to-end half of the coverage above, kept deliberately loose: the
731+
terminal state, and that the child did not reach its last line. It says
732+
nothing about *when* the timeout landed, because which second the child
733+
reaches depends on how promptly the host scheduled it -- that assumption is
734+
what made the previous version of this test flaky.
742735
"""
743-
# GIVEN: a child that prints one line a second for 20 seconds
736+
# GIVEN: a child that prints one line a second for 20 seconds, and a
737+
# timeout that will cut it short
744738
action = Action_2023_09(
745739
command=CommandString_2023_09("{{Task.PythonInterpreter}}"),
746740
args=[ArgString_2023_09("{{Task.ScriptFile}}")],
747-
timeout=action_timeout_seconds,
741+
timeout=2,
748742
)
749743
python_app_loc = (Path(__file__).parent / "support_files" / "app_20s_run.py").resolve()
750744
symtab = SymbolTable(
@@ -761,13 +755,48 @@ def test_run_action_timeout_is_enforced(
761755
time.sleep(0.2)
762756

763757
# THEN
764-
assert runner.state == expected_state
765-
messages = collect_queue_messages(message_queue)
766-
if expected_state is ScriptRunnerState.TIMEOUT:
767-
# It was cut short. Which line it got to is a scheduling detail.
768-
assert "Log from test 19" not in messages
769-
else:
770-
assert "Log from test 19" in messages
758+
assert runner.state == ScriptRunnerState.TIMEOUT
759+
assert "Log from test 19" not in collect_queue_messages(message_queue)
760+
761+
@pytest.mark.usefixtures("message_queue", "queue_handler")
762+
def test_run_action_without_timeout_runs_to_completion(
763+
self,
764+
tmp_path: Path,
765+
message_queue: SimpleQueue,
766+
queue_handler: QueueHandler,
767+
python_exe: str,
768+
) -> None:
769+
"""No timeout means no time limit is imposed: the action finishes on its
770+
own terms.
771+
772+
Uses a child that exits after a fraction of a second rather than the
773+
20-second one. Running a 20-second child to completion made this the single
774+
slowest test in the suite by a factor of three, and it was the whole
775+
critical path under `-n auto` -- while proving nothing that a short child
776+
does not. What is being asserted is that no timer cut the action short, and
777+
a child that exits on its own shows that either way.
778+
"""
779+
# GIVEN: a short-lived child, and no timeout on the action or the caller
780+
action = Action_2023_09(
781+
command=CommandString_2023_09("{{Task.PythonInterpreter}}"),
782+
args=[
783+
ArgString_2023_09("-c"),
784+
ArgString_2023_09("import time; time.sleep(0.5); print('finished')"),
785+
],
786+
timeout=None,
787+
)
788+
symtab = SymbolTable(source={"Task.PythonInterpreter": python_exe})
789+
logger = build_logger(queue_handler)
790+
with TerminatingRunner(logger=logger, session_working_directory=tmp_path) as runner:
791+
# WHEN
792+
runner._run_action(action, symtab, default_timeout=None)
793+
while runner.state == ScriptRunnerState.RUNNING:
794+
time.sleep(0.05)
795+
796+
# THEN: it ran to its own end, and its last output arrived.
797+
assert runner.state == ScriptRunnerState.SUCCESS
798+
assert runner.exit_code == 0
799+
assert "finished" in collect_queue_messages(message_queue)
771800

772801
@pytest.mark.usefixtures("message_queue", "queue_handler")
773802
@pytest.mark.parametrize(
@@ -875,6 +904,7 @@ def test_run_action_bad_formatstring(
875904
messages = collect_queue_messages(message_queue)
876905
assert any(m.startswith("openjd_fail") for m in messages)
877906

907+
@serial_process
878908
@pytest.mark.usefixtures("message_queue", "queue_handler")
879909
def test_cancel_terminate(
880910
self,
@@ -910,6 +940,7 @@ def test_cancel_terminate(
910940
# Didn't get to the end of the application run
911941
assert "Log from test 9" not in messages
912942

943+
@serial_process
913944
@pytest.mark.usefixtures("message_queue", "queue_handler")
914945
@pytest.mark.xfail(not is_posix(), reason="Signals not yet implemented for non-posix")
915946
def test_run_with_time_limit(
@@ -942,6 +973,7 @@ def test_run_with_time_limit(
942973
# Didn't get to the end of the application run
943974
assert "Log from test 9" not in messages
944975

976+
@serial_process
945977
@pytest.mark.usefixtures("message_queue", "queue_handler")
946978
def test_cancel_notify(
947979
self,
@@ -1105,6 +1137,7 @@ def test_cancel_notify_direct_signal_with_cap_kill(
11051137
cap_kill_was_effective == cap_kill_effective_after_cancel
11061138
), "CAP_KILL added/removed from effetive set and persisted after cancelation"
11071139

1140+
@serial_process
11081141
@pytest.mark.usefixtures("message_queue", "queue_handler")
11091142
def test_cancel_double_cancel_notify(
11101143
self,

test/openjd/sessions_v0/test_session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from openjd.sessions._windows_permission_helper import WindowsPermissionHelper
6767

6868
from .conftest import (
69+
serial_process,
6970
has_posix_target_user,
7071
has_windows_user,
7172
WIN_SET_TEST_ENV_VARS_MESSAGE,
@@ -954,6 +955,7 @@ def test_run_task_with_variables(
954955
)
955956

956957

958+
@serial_process
957959
class TestSessionCancel:
958960
"""Test that cancelation will cancel the currently running Script."""
959961

test/openjd/sessions_v0/test_session_run_subprocess.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
from openjd.sessions._session_user import PosixSessionUser, WindowsSessionUser
2222

2323
from .conftest import (
24+
serial_process,
2425
has_posix_target_user,
2526
has_windows_user,
2627
WIN_SET_TEST_ENV_VARS_MESSAGE,
2728
POSIX_SET_TARGET_USER_ENV_VARS_MESSAGE,
2829
)
2930

3031

32+
@serial_process
3133
class TestRunSubprocess:
3234
"""Tests for the Session.run_subprocess method."""
3335

0 commit comments

Comments
 (0)