Skip to content

Commit 077a8a2

Browse files
committed
fix(concurrency): Close race windows in cancel and completion paths
Address findings F1-F8 and Review22-F3/F4 from round 3 concurrency review: - F1: Always route EnvironmentScriptRunner.cancel() through pending-cancel path - F2+F4: Atomic terminal arbitration - claim pending cancel in _on_process_exit, move liveness check inside lock in _cancel - F3: Monotonic merge for duplicate pending cancels (min time_limit, OR failed) - F5: Snapshot action_status before publishing READY state - F6: Wrap cancel_info.json write in try/except, fallback to immediate terminate - F7: Detect self-join in shutdown(), use wait=False if called from worker thread - F8: Move callback outside lock, wrap in try/except to prevent child discard - Review22-F3: Snapshot _runner in cancel_action to avoid bare AssertionError - Review22-F4: Bind _process once in notify/terminate to avoid TOCTOU race Add test_concurrency_fixes.py with 8 unit tests covering the defensive behaviors. None of these issues reproduce in openjd-rs due to CancellationToken, tokio async, and Rust's Result<> error handling model. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent a954917 commit 077a8a2

5 files changed

Lines changed: 494 additions & 28 deletions

File tree

src/openjd/sessions/_runner_base.py

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,23 @@ def shutdown(self) -> None:
589589
"""Performs a clean shutdown on the runner. This shutsdown the internal
590590
ThreadPoolExectutor.
591591
"""
592-
self._pool.shutdown()
592+
# F7 fix: Detect self-join. If shutdown() is called from the pool's
593+
# worker thread (e.g., from _on_process_exit -> _action_callback ->
594+
# cleanup -> runner.shutdown), calling _pool.shutdown(wait=True) would
595+
# deadlock because the thread is trying to join itself. In that case,
596+
# defer to a background thread or skip the wait.
597+
import threading
598+
599+
# Check if current thread is the pool's worker thread. The pool has
600+
# max_workers=1, so if a future is running, _threads has one element.
601+
pool_threads: set[threading.Thread] = getattr(self._pool, "_threads", set())
602+
current = threading.current_thread()
603+
if current in pool_threads:
604+
# We're inside the worker thread. Use wait=False to avoid deadlock.
605+
# The pool will be garbage-collected eventually.
606+
self._pool.shutdown(wait=False)
607+
else:
608+
self._pool.shutdown()
593609

594610
def _fail_action(self, message: str) -> None:
595611
"""Fail the action through the normal failure path: surface the
@@ -936,7 +952,24 @@ def _cancel_with_resolved_method(
936952
return
937953
process = self._process
938954
if process is None or not process.has_started:
939-
self._pending_cancel = (time_limit, mark_action_failed)
955+
# F3 fix: Monotonic merge for duplicate pending cancels. If a
956+
# cancel is already pending, merge the new request: take the
957+
# minimum time_limit (tighter deadline wins, treating None as
958+
# unlimited), and OR the mark_action_failed flags (once failed,
959+
# always failed).
960+
if self._pending_cancel is not None:
961+
prev_limit, prev_failed = self._pending_cancel
962+
# Merge time limits: None means unlimited, so a defined limit beats None
963+
if time_limit is None:
964+
merged_limit = prev_limit
965+
elif prev_limit is None:
966+
merged_limit = time_limit
967+
else:
968+
merged_limit = min(time_limit, prev_limit)
969+
merged_failed = mark_action_failed or prev_failed
970+
self._pending_cancel = (merged_limit, merged_failed)
971+
else:
972+
self._pending_cancel = (time_limit, mark_action_failed)
940973
return
941974
method = self._resolved_cancel_method
942975
if method is None: # pragma: no cover - defensive
@@ -957,11 +990,18 @@ def _cancel(
957990
# pending cancel instead — an early return here rather than an
958991
# assert, so no bare AssertionError can reach the public API.
959992
return
960-
# Nothing to do if it's not running.
961-
if not self._process.is_running:
962-
return
963993

964994
with self._lock:
995+
# F4 fix: Check liveness under the lock. Without this, a completion
996+
# racing a cancel/timeout could see is_running=True outside the lock,
997+
# enter here, and then find is_running=False (or worse, still True
998+
# but the callback has already fired) — no linearization point.
999+
# Moving the check inside the lock lets _on_process_exit's clearing
1000+
# of _pending_cancel act as the arbiter: if the process exited, any
1001+
# pending cancel was already consumed there.
1002+
if not self._process.is_running:
1003+
return
1004+
9651005
self._canceled = True
9661006
self._notify_canceled_action_as_failed = mark_action_failed
9671007
now = datetime.now(timezone.utc)
@@ -1015,9 +1055,32 @@ def _cancel(
10151055
# when we'll send the SIGKILL)
10161056
grace_end_time_str = self._cancel_gracetime_end.strftime(TIME_FORMAT_STR)
10171057
notify_end = json.dumps({"NotifyEnd": grace_end_time_str})
1018-
write_file_for_user(
1019-
self._session_working_directory / "cancel_info.json", notify_end, self._user
1020-
)
1058+
try:
1059+
write_file_for_user(
1060+
self._session_working_directory / "cancel_info.json", notify_end, self._user
1061+
)
1062+
except OSError as err:
1063+
# F6 fix: If we cannot write the cancel_info.json (disk full, permission
1064+
# denied, etc.), log and fall back to immediate termination. A script
1065+
# waiting on that file would hang forever otherwise.
1066+
self._logger.warning(
1067+
f"Failed to write cancel_info.json: {err}. Falling back to immediate termination.",
1068+
extra=LogExtraInfo(
1069+
openjd_log_content=LogContent.PROCESS_CONTROL
1070+
| LogContent.EXCEPTION_INFO
1071+
),
1072+
)
1073+
try:
1074+
self._process.terminate()
1075+
except OSError as term_err: # pragma: nocover
1076+
self._logger.warning(
1077+
f"Fallback termination also failed: {term_err}",
1078+
extra=LogExtraInfo(
1079+
openjd_log_content=LogContent.PROCESS_CONTROL
1080+
| LogContent.EXCEPTION_INFO
1081+
),
1082+
)
1083+
return
10211084
self._logger.info(
10221085
f"Grace period ends at {grace_end_time_str}",
10231086
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
@@ -1046,6 +1109,13 @@ def _on_process_exit(self, future: Future) -> None:
10461109
"""This is invoked as a callback when run_future is done."""
10471110
assert self._run_future is not None
10481111
with self._lock:
1112+
# F2 fix: Claim _pending_cancel atomically before signalling completion.
1113+
# A cancel racing completion would otherwise see the process still
1114+
# "running" (in _cancel_with_resolved_method) and hand off to _cancel,
1115+
# which then finds is_running=False and no-ops. By consuming the
1116+
# pending here we prevent that lost-cancel window.
1117+
self._pending_cancel = None
1118+
10491119
if self._runtime_limit is not None:
10501120
self._runtime_limit.cancel()
10511121
self._runtime_limit = None
@@ -1062,8 +1132,20 @@ def _on_process_exit(self, future: Future) -> None:
10621132
),
10631133
)
10641134

1065-
if self._callback is not None:
1135+
# F8 fix: Invoke callback outside the lock and wrap in try/except. An
1136+
# observer exception must not prevent the child process from being
1137+
# reaped or cause resource leaks. The callback is invoked outside the
1138+
# lock since it may be slow and shouldn't block other operations.
1139+
if self._callback is not None:
1140+
try:
10661141
self._callback(ActionState(self.state.value))
1142+
except Exception as exc:
1143+
self._logger.error(
1144+
f"Exception in action callback: {exc}",
1145+
extra=LogExtraInfo(
1146+
openjd_log_content=LogContent.PROCESS_CONTROL | LogContent.EXCEPTION_INFO
1147+
),
1148+
)
10671149

10681150
def _on_notify_period_end(self) -> None:
10691151
"""This is invoked when the grace period in a NOTIFY_THEN_TERMINATE

src/openjd/sessions/_runner_env_script.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,14 @@ def _run_wrap_hook(self, hook: str, *, default_timeout: Optional[timedelta] = No
272272
def cancel(
273273
self, *, time_limit: Optional[timedelta] = None, mark_action_failed: bool = False
274274
) -> None:
275-
if self._action is None:
276-
# Nothing to do.
277-
return
278-
279-
# Cancel with the effective method resolved at launch time by
280-
# _run_action, against the action's own final scope (the script's
281-
# lets and Env.File.* symbols, plus WrappedAction.* for a wrap
282-
# hook) — openjd-rs parity.
275+
# Always route through the base class handoff, even before _action is
276+
# assigned. A cancel landing during environment-action setup (embedded-
277+
# file writes, let evaluation) must be recorded in _pending_cancel and
278+
# applied when the subprocess launches — F1 fix: the old `_action is
279+
# None` guard returned early without entering the pending-cancel path,
280+
# losing the cancel entirely during the setup window.
281+
#
282+
# _cancel_with_resolved_method handles the pre-launch case: it records
283+
# the request in _pending_cancel when the subprocess hasn't started,
284+
# and _run applies it once the child exists.
283285
self._cancel_with_resolved_method(time_limit, mark_action_failed)

src/openjd/sessions/_session.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -657,10 +657,18 @@ def cancel_action(
657657
"""
658658
if self.state != SessionState.RUNNING:
659659
raise RuntimeError("No actions are running")
660-
# For the type checker
661-
assert self._runner is not None
660+
# Review22-F3 fix: Snapshot _runner before using it. The state check
661+
# above and the _runner access below are not atomic; a completion
662+
# racing this call could set _runner = None after we pass the state
663+
# check. Snapshotting here avoids a bare AssertionError.
664+
runner = self._runner
665+
if runner is None:
666+
# Race: action completed between state check and here. No-op rather
667+
# than raise, since the caller's intent (cancel the running action)
668+
# is already satisfied.
669+
return
662670

663-
self._runner.cancel(time_limit=time_limit, mark_action_failed=mark_action_failed)
671+
runner.cancel(time_limit=time_limit, mark_action_failed=mark_action_failed)
664672

665673
def _make_env_script_runner(
666674
self,
@@ -2238,6 +2246,17 @@ def _action_callback(self, state: ActionState) -> None:
22382246
self._action_exit_code = self._runner.exit_code
22392247
self._action_state = state
22402248

2249+
# F5 fix: Snapshot action_status BEFORE publishing READY. If we set
2250+
# _state = READY first, another thread polling session.state could see
2251+
# READY but action_status would still reflect the old (stale or
2252+
# incomplete) snapshot. By snapshotting here, the callback receives the
2253+
# definitive ActionStatus that corresponds to the terminal state.
2254+
#
2255+
# Note: We snapshot unconditionally (not guarded by `if self._callback`)
2256+
# because action_status is cheap and some tests check exact callback
2257+
# invocation patterns including the __bool__ check count.
2258+
action_status = self.action_status
2259+
22412260
if state != ActionState.RUNNING:
22422261
# Decide which between-action state to enter.
22432262
if self._ending_only or self._action_state != ActionState.SUCCESS:
@@ -2247,10 +2266,7 @@ def _action_callback(self, state: ActionState) -> None:
22472266
else:
22482267
self._state = SessionState.READY
22492268

2250-
if self._callback:
2251-
action_status = self.action_status
2252-
# for the type checker
2253-
assert action_status is not None
2269+
if self._callback and action_status is not None:
22542270
self._callback(self._session_id, action_status)
22552271

22562272
def _evaluate_current_session_env_vars(

src/openjd/sessions/_subprocess.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,11 @@ def notify(self) -> None:
227227
TODO: Send the signal to every direct and transitive child of the parent
228228
process.
229229
"""
230-
if self._process is not None and self._process.poll() is None:
230+
# Review22-F4 fix: Bind _process once. Double-load is a TOCTOU race:
231+
# the None check and poll() call read _process separately, allowing
232+
# another thread to set it to None between them.
233+
proc = self._process
234+
if proc is not None and proc.poll() is None:
231235
if is_posix():
232236
self._posix_signal_subprocess(signal_name="term")
233237
else:
@@ -243,15 +247,17 @@ def terminate(self) -> None:
243247
TODO: Send the signal to every direct and transitive child of the parent
244248
process.
245249
"""
246-
if self._process is not None and self._process.poll() is None:
250+
# Review22-F4 fix: Bind _process once. See notify() for rationale.
251+
proc = self._process
252+
if proc is not None and proc.poll() is None:
247253
if is_posix():
248254
self._posix_signal_subprocess(signal_name="kill")
249255
else:
250256
self._logger.info(
251-
f"INTERRUPT: Start killing the process tree with the root pid: {self._process.pid}",
257+
f"INTERRUPT: Start killing the process tree with the root pid: {proc.pid}",
252258
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
253259
)
254-
kill_windows_process_tree(self._logger, self._process.pid, signal_subprocesses=True)
260+
kill_windows_process_tree(self._logger, proc.pid, signal_subprocesses=True)
255261

256262
def _start_subprocess(self) -> Optional[Popen]:
257263
"""Helper invoked by self.run() to start up the subprocess."""

0 commit comments

Comments
 (0)