Skip to content

Commit a954917

Browse files
committed
fix: Serialize the pending-cancel handoff against action launch
Addresses the review comment on the previous head: the _pending_cancel handoff was unsynchronized, so the lost-cancel window it was meant to close was still open. Two defects, both real: - The reader in _run and the writer in _cancel_with_resolved_method both touched _pending_cancel outside self._lock, so a cancel could be recorded after _run had already consumed (and found nothing). - The writer keyed on whether the LoggingSubprocess object existed, not on whether it had started. In the window where the object exists but the pool thread is still inside _start_subprocess, the canceller handed off to _cancel, which returns early because the process is not running -- while _run had already passed its consume point. Dropped by both sides. Now the decide-and-record step happens under the lock and keys on has_started, and _run consumes under the lock; _cancel is called outside it, since it takes the lock itself. The regression test reproduces the exact interleaving by blocking inside _start_subprocess so the window is held open deterministically. Verified against a mutant keyed on object existence: it ends SUCCESS after the full 30 second sleep instead of CANCELED, i.e. the cancel is silently dropped. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 27cf66b commit a954917

2 files changed

Lines changed: 107 additions & 17 deletions

File tree

src/openjd/sessions/_runner_base.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -722,13 +722,16 @@ def _run(self, args: Sequence[str], time_limit: Optional[timedelta] = None) -> N
722722
# before we check self.state
723723
self._process.wait_until_started()
724724

725-
# A cancel that landed during setup, before there was a subprocess to
726-
# signal, is applied now rather than dropped. See _pending_cancel.
727-
pending = self._pending_cancel
728-
if pending is not None:
725+
# A cancel that landed during setup, before there was a running
726+
# subprocess to signal, is applied now rather than dropped. Read under
727+
# the lock so the handoff is serialized against the writer in
728+
# _cancel_with_resolved_method; _cancel takes the lock itself, so it is
729+
# called outside.
730+
with self._lock:
731+
pending = self._pending_cancel
729732
self._pending_cancel = None
730-
if self._resolved_cancel_method is not None:
731-
self._cancel(self._resolved_cancel_method, *pending)
733+
if pending is not None and self._resolved_cancel_method is not None:
734+
self._cancel(self._resolved_cancel_method, *pending)
732735

733736
if self.state == ScriptRunnerState.RUNNING and self._callback is not None:
734737
# Let the caller know that the process is running.
@@ -913,22 +916,34 @@ def _cancel_with_resolved_method(
913916
the effective cancel method that :meth:`_run_action` resolved at
914917
launch time.
915918
916-
A cancel that arrives before the subprocess exists is remembered and
917-
applied by :meth:`_run` as soon as it does (see :attr:`_pending_cancel`)
918-
— `cancel()` is called from another thread, so "no subprocess yet" is a
919+
A cancel that arrives before the subprocess is running is remembered and
920+
applied by :meth:`_run` as soon as it starts (see :attr:`_pending_cancel`)
921+
— `cancel()` is called from another thread, so "not running yet" is a
919922
race, not a no-op. Only when no action will ever be launched (setup
920-
failed before resolution) is there genuinely nothing to cancel.
923+
failed, or there was no action to run) is there genuinely nothing to
924+
cancel.
921925
"""
922-
if self._process is None:
923-
if self.state == ScriptRunnerState.FAILED:
924-
# Setup already failed; no subprocess is coming.
926+
with self._lock:
927+
# Decide-and-record must be atomic with respect to _run creating and
928+
# starting the subprocess, otherwise a cancel can land between the
929+
# two and be dropped by both sides: this method would see a process
930+
# and hand off to _cancel, which has nothing to signal yet, while
931+
# _run has already passed the point where it consumes a pending
932+
# cancel. Keyed on has_started rather than on the object existing,
933+
# for the same reason.
934+
if self._state_override is not None:
935+
# Terminal before launch: setup failed, or there was no action.
925936
return
926-
self._pending_cancel = (time_limit, mark_action_failed)
937+
process = self._process
938+
if process is None or not process.has_started:
939+
self._pending_cancel = (time_limit, mark_action_failed)
940+
return
941+
method = self._resolved_cancel_method
942+
if method is None: # pragma: no cover - defensive
927943
return
928944
# Note: If the given time_limit is less than that in the method, then the time_limit will be what's used.
929-
if self._resolved_cancel_method is None: # pragma: no cover - defensive
930-
return
931-
self._cancel(self._resolved_cancel_method, time_limit, mark_action_failed)
945+
# Called outside the lock: _cancel takes it itself.
946+
self._cancel(method, time_limit, mark_action_failed)
932947

933948
def _cancel(
934949
self,

test/openjd/sessions_v0/test_runner_step_script.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22

3+
import threading
34
import time
45
from datetime import timedelta
56
from logging.handlers import QueueHandler
@@ -42,6 +43,7 @@
4243
TerminateCancelMethod,
4344
)
4445
from openjd.sessions._runner_step_script import StepScriptRunner
46+
from openjd.sessions._subprocess import LoggingSubprocess
4547
from openjd.sessions._tempdir import TempDir
4648
from openjd.sessions._os_checker import is_posix, is_windows
4749

@@ -424,3 +426,76 @@ def test_cancel_with_no_subprocess_does_not_raise(
424426

425427
# WHEN / THEN: the low-level cancel is a quiet no-op, not an AssertionError
426428
runner._cancel(TerminateCancelMethod())
429+
430+
431+
class TestCancelRacingLaunchIsSerialized:
432+
"""The pending-cancel handoff must be atomic with respect to launch.
433+
434+
`cancel()` runs on another thread. Without serialization there is an
435+
interleaving where the canceller sees a subprocess object and hands off to
436+
`_cancel`, which has nothing to signal because the process has not started
437+
yet, while `_run` has already passed the point where it consumes a pending
438+
cancel -- so the cancel is dropped by both sides and the action runs to
439+
completion.
440+
"""
441+
442+
@pytest.mark.skipif(not is_posix(), reason="signal delivery is posix-only here")
443+
@pytest.mark.timeout(120)
444+
def test_cancel_during_launch_is_not_dropped(self, tmp_path: Path, python_exe: str) -> None:
445+
# GIVEN: a long-running action, and a canceller that fires while the
446+
# subprocess object exists but has NOT started -- held open by blocking
447+
# inside _start_subprocess, which runs on the pool thread after _run has
448+
# already assigned self._process.
449+
script = StepScript_2023_09(
450+
actions=StepActions_2023_09(
451+
onRun=Action_2023_09(
452+
command=CommandString_2023_09(python_exe),
453+
args=[
454+
ArgString_2023_09("-c"),
455+
ArgString_2023_09("import time; time.sleep(30)"),
456+
],
457+
)
458+
)
459+
)
460+
runner = StepScriptRunner(
461+
logger=MagicMock(),
462+
session_working_directory=tmp_path,
463+
script=script,
464+
symtab=SymbolTable(),
465+
session_files_directory=tmp_path,
466+
)
467+
in_window = threading.Event()
468+
canceller_done = threading.Event()
469+
real_start = LoggingSubprocess._start_subprocess
470+
471+
def _start_after_cancel(subproc): # type: ignore[no-untyped-def]
472+
in_window.set()
473+
# Hold the window: _has_started is not set until this returns.
474+
canceller_done.wait(timeout=30)
475+
return real_start(subproc)
476+
477+
def _cancel_in_window() -> None:
478+
in_window.wait(timeout=30)
479+
assert runner._process is not None
480+
assert runner._process.has_started is False
481+
runner.cancel()
482+
canceller_done.set()
483+
484+
canceller = threading.Thread(target=_cancel_in_window, daemon=True)
485+
486+
# WHEN
487+
with patch.object(LoggingSubprocess, "_start_subprocess", _start_after_cancel):
488+
canceller.start()
489+
runner.run()
490+
canceller.join(timeout=30)
491+
492+
# THEN: the cancel was applied, so the 30 second sleep did not run to
493+
# completion. Unsynchronized, it is dropped by both sides and the runner
494+
# ends in SUCCESS after 30 seconds.
495+
deadline = time.time() + 60
496+
while runner.state in (ScriptRunnerState.RUNNING, ScriptRunnerState.CANCELING):
497+
if time.time() > deadline: # pragma: no cover - timing guard
498+
pytest.fail(f"runner never settled; state={runner.state}")
499+
time.sleep(0.05)
500+
assert runner.state == ScriptRunnerState.CANCELED
501+
assert runner._pending_cancel is None

0 commit comments

Comments
 (0)