From c9bbbf0d4d66dddf919cf543bf0a04f755384787 Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Mon, 18 May 2026 13:33:48 -0400 Subject: [PATCH] fix(bg): detach --web-bg child from Windows job to prevent kill-on-close When `conductor run --web-bg` or `conductor resume --web-bg` is launched from a shell wrapper that runs commands inside a Windows job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (GitHub Actions runners, VS Code integrated terminal, JetBrains IDE terminals, GitHub Copilot CLI shell tool), the detached child inherits the parent's job and is killed within ~10 seconds when the parent exits and the job tears down. Users see a dashboard URL but the workflow never makes progress. The Popen call previously requested only CREATE_NEW_PROCESS_GROUP. This adds CREATE_BREAKAWAY_FROM_JOB so the child fully detaches from the parent's job object. Some hardened CI environments clear JOB_OBJECT_LIMIT_BREAKAWAY_OK on their job, in which case CreateProcess raises ERROR_ACCESS_DENIED. In that case, emit a visible stderr warning (so the user understands bg mode may not survive shell exit and should run from a non-job-managed shell) and retry without the breakaway flag. Other OSErrors (e.g. missing executable) propagate unchanged so the existing RuntimeError wrapper still surfaces them cleanly. Refactors the two near-identical detachment+Popen blocks in launch_background and launch_background_resume into a single _spawn_detached helper backed by _detachment_kwargs and _is_breakaway_denied. Adds module-level _CREATE_NEW_PROCESS_GROUP and _CREATE_BREAKAWAY_FROM_JOB constants resolved via getattr so this module remains importable on POSIX hosts and tests can patch sys.platform to "win32" from Linux/macOS. Tests cover: - _detachment_kwargs returns start_new_session on POSIX and OR'd creationflags on Windows. - _is_breakaway_denied narrowly classifies winerror == 5 only. - _spawn_detached happy paths (POSIX, Windows). - _spawn_detached breakaway-denied fallback emits stderr warning, retries without CREATE_BREAKAWAY_FROM_JOB, preserves stdio + env. - Non-breakaway OSError propagates without retry. - launch_background and launch_background_resume both route through _spawn_detached so the fix applies to run and resume paths alike. The existing test_subprocess_detachment_kwargs assertion is updated to expect the OR'd flag combo on Windows. Closes #195 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/conductor/cli/bg_runner.py | 168 +++++++++--- tests/test_cli/test_bg_runner.py | 355 ++++++++++++++++++++++++++ tests/test_cli/test_resume_command.py | 3 +- 3 files changed, 485 insertions(+), 41 deletions(-) create mode 100644 tests/test_cli/test_bg_runner.py diff --git a/src/conductor/cli/bg_runner.py b/src/conductor/cli/bg_runner.py index f4262fe..e4ae6fe 100644 --- a/src/conductor/cli/bg_runner.py +++ b/src/conductor/cli/bg_runner.py @@ -30,6 +30,130 @@ from pathlib import Path from typing import Any +# Windows process creation flags. Exposed via ``getattr`` with documented +# fallbacks so this module can be imported on POSIX (where these attributes do +# not exist on ``subprocess``) and so tests can patch ``sys.platform`` to +# ``"win32"`` from a Linux/macOS host. +_CREATE_NEW_PROCESS_GROUP: int = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) +_CREATE_BREAKAWAY_FROM_JOB: int = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0x01000000) + +# Win32 ERROR_ACCESS_DENIED — the error code raised when CreateProcess is +# called with ``CREATE_BREAKAWAY_FROM_JOB`` and the parent's job object has +# ``JOB_OBJECT_LIMIT_BREAKAWAY_OK`` cleared (some hardened CI environments). +_ERROR_ACCESS_DENIED = 5 + + +def _detachment_kwargs() -> dict[str, Any]: + """Return Popen kwargs that detach the child from the parent's lifecycle. + + On POSIX, ``start_new_session=True`` puts the child in its own session so + it survives the parent and any controlling terminal closing. + + On Windows, ``CREATE_NEW_PROCESS_GROUP`` gives the child its own console + process group (no shared Ctrl+C delivery) and ``CREATE_BREAKAWAY_FROM_JOB`` + detaches the child from the parent's Windows job object. The latter is + required when the parent shell runs inside a job with + ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` set (e.g. GitHub Actions runners, + VS Code integrated terminal, JetBrains IDE terminals, the GitHub Copilot + CLI shell tool): without breakaway, the bg child inherits the job and is + killed when the parent exits and the job tears down. + + Returns: + Platform-specific Popen keyword arguments. The full Popen call should + merge these with stdio + env kwargs. + """ + if sys.platform == "win32": + return {"creationflags": _CREATE_NEW_PROCESS_GROUP | _CREATE_BREAKAWAY_FROM_JOB} + return {"start_new_session": True} + + +def _is_breakaway_denied(exc: OSError) -> bool: + """Return True if a Popen ``OSError`` was caused by the parent job forbidding breakaway. + + Windows raises ``OSError`` with ``winerror == 5`` (ERROR_ACCESS_DENIED) + when ``CREATE_BREAKAWAY_FROM_JOB`` is passed but the parent's job object + has ``JOB_OBJECT_LIMIT_BREAKAWAY_OK`` cleared. + + Args: + exc: The exception raised by ``subprocess.Popen``. + + Returns: + True only for the access-denied breakaway case; other ``OSError`` + causes (e.g. ``FileNotFoundError`` for a missing executable) return + False so the original error can propagate. + """ + return getattr(exc, "winerror", None) == _ERROR_ACCESS_DENIED + + +def _spawn_detached(cmd: list[str], env: dict[str, str]) -> subprocess.Popen[Any]: + """Launch a fully-detached child process for ``--web-bg`` mode. + + Composes DEVNULL stdio + the supplied environment + the platform-specific + detachment kwargs from :func:`_detachment_kwargs`, then calls + ``subprocess.Popen``. + + On Windows, if the Popen call fails with ``ERROR_ACCESS_DENIED`` because + the parent's job object forbids breakaway, prints a visible warning to + ``sys.stderr`` and retries WITHOUT ``CREATE_BREAKAWAY_FROM_JOB``. In that + environment the child may still be killed when the parent's job closes; + the warning sets that expectation so the user does not see only the + "Dashboard: ..." line and assume success. + + Args: + cmd: The fully-resolved command-line argv to execute. + env: The environment dict to pass to the child (callers prepare this + with ``CONDUCTOR_WEB_BG`` / ``CONDUCTOR_WEB_PORT`` set). + + Returns: + The running :class:`subprocess.Popen` handle for the detached child. + + Raises: + OSError: Propagated from ``Popen`` for any failure other than the + Windows breakaway-denied case (e.g. ``FileNotFoundError`` for a + missing executable). Callers wrap this in a ``RuntimeError``. + """ + base: dict[str, Any] = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "stdin": subprocess.DEVNULL, + "env": env, + } + try: + return subprocess.Popen(cmd, **base, **_detachment_kwargs()) # noqa: S603 + except OSError as exc: + if not _is_breakaway_denied(exc): + raise + sys.stderr.write( + "warning: parent shell forbids Windows job breakaway; the " + "background workflow may not survive shell exit. Run " + "--web-bg from a non-job-managed shell (e.g. a regular " + "PowerShell window) for reliable persistence.\n" + ) + return subprocess.Popen( # noqa: S603 + cmd, + **base, + creationflags=_CREATE_NEW_PROCESS_GROUP, + ) + + +def _bg_child_env(web_port: int) -> dict[str, str]: + """Build the child-process environment for ``--web-bg`` mode. + + Copies the current environment and sets the two signals the detached + child reads to enable bg-specific behavior in the web dashboard. + + Args: + web_port: The port the child's dashboard will listen on. Recorded + in ``CONDUCTOR_WEB_PORT`` so the child can rebind if needed. + + Returns: + A new environment dict suitable for passing to ``subprocess.Popen``. + """ + env = os.environ.copy() + env["CONDUCTOR_WEB_BG"] = "1" + env["CONDUCTOR_WEB_PORT"] = str(web_port) + return env + def _find_free_port() -> int: """Find an available TCP port on localhost. @@ -213,27 +337,9 @@ def launch_background( for instr_path in cli_instructions: cmd.extend(["--instructions", instr_path]) - # Launch detached child - kwargs: dict[str, Any] = { - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - "stdin": subprocess.DEVNULL, - } - - if sys.platform != "win32": - kwargs["start_new_session"] = True - else: - # Windows: CREATE_NEW_PROCESS_GROUP for detachment - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - - # Set environment variables to signal bg mode to the child - env = os.environ.copy() - env["CONDUCTOR_WEB_BG"] = "1" - env["CONDUCTOR_WEB_PORT"] = str(web_port) - kwargs["env"] = env - + # Launch detached child with platform-appropriate detachment kwargs. try: - proc = subprocess.Popen(cmd, **kwargs) # noqa: S603 + proc = _spawn_detached(cmd, _bg_child_env(web_port)) except Exception as exc: raise RuntimeError(f"Failed to start background process: {exc}") from exc @@ -331,27 +437,9 @@ def launch_background_resume( if log_file: cmd.extend(["--log-file", str(log_file)]) - # Launch detached child - kwargs: dict[str, Any] = { - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - "stdin": subprocess.DEVNULL, - } - - if sys.platform != "win32": - kwargs["start_new_session"] = True - else: - # Windows: CREATE_NEW_PROCESS_GROUP for detachment - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - - # Set environment variables to signal bg mode to the child - env = os.environ.copy() - env["CONDUCTOR_WEB_BG"] = "1" - env["CONDUCTOR_WEB_PORT"] = str(web_port) - kwargs["env"] = env - + # Launch detached child with platform-appropriate detachment kwargs. try: - proc = subprocess.Popen(cmd, **kwargs) # noqa: S603 + proc = _spawn_detached(cmd, _bg_child_env(web_port)) except Exception as exc: raise RuntimeError(f"Failed to start background process: {exc}") from exc diff --git a/tests/test_cli/test_bg_runner.py b/tests/test_cli/test_bg_runner.py new file mode 100644 index 0000000..dbb5527 --- /dev/null +++ b/tests/test_cli/test_bg_runner.py @@ -0,0 +1,355 @@ +"""Tests for ``bg_runner`` helpers: detachment kwargs and detached spawn. + +Covers the Windows job-breakaway fix from issue #195: + +- ``_detachment_kwargs`` returns the right kwargs for POSIX vs Windows. +- ``_spawn_detached`` happy path requests breakaway on Windows. +- ``_spawn_detached`` falls back to plain ``CREATE_NEW_PROCESS_GROUP`` and + prints a stderr warning when the parent's Windows job forbids breakaway + (``OSError`` with ``winerror == 5``). +- Non-breakaway ``OSError`` (e.g. ``winerror == 2``, "file not found") + propagates from the first ``Popen`` call without a retry. +- POSIX paths never retry on ``OSError``. +- Both ``launch_background`` and ``launch_background_resume`` route their + Popen call through ``_spawn_detached`` (i.e., the Windows breakaway flag + is set in both run and resume paths). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from conductor.cli import bg_runner + + +def _make_breakaway_denied_error() -> OSError: + """Build an OSError shaped like the Windows ERROR_ACCESS_DENIED case. + + On non-Windows hosts, ``OSError(...)`` does not automatically populate + ``.winerror``, so we set it explicitly to simulate what Popen would raise + on Windows when ``CREATE_BREAKAWAY_FROM_JOB`` is denied by the parent + job's ``JOB_OBJECT_LIMIT_BREAKAWAY_OK`` flag. + """ + err = OSError(13, "Access is denied") + err.winerror = 5 # type: ignore[attr-defined] + return err + + +def _make_file_not_found_error() -> OSError: + """Build an OSError with a non-breakaway Windows error code.""" + err = FileNotFoundError(2, "The system cannot find the file specified") + err.winerror = 2 # type: ignore[attr-defined] + return err + + +# --------------------------------------------------------------------------- +# _detachment_kwargs +# --------------------------------------------------------------------------- + + +class TestDetachmentKwargs: + """Platform-specific Popen kwargs returned by ``_detachment_kwargs``.""" + + def test_posix_returns_start_new_session(self) -> None: + with patch.object(bg_runner.sys, "platform", "linux"): + kwargs = bg_runner._detachment_kwargs() + + assert kwargs == {"start_new_session": True} + + def test_macos_returns_start_new_session(self) -> None: + with patch.object(bg_runner.sys, "platform", "darwin"): + kwargs = bg_runner._detachment_kwargs() + + assert kwargs == {"start_new_session": True} + + def test_windows_sets_breakaway_and_new_process_group(self) -> None: + with patch.object(bg_runner.sys, "platform", "win32"): + kwargs = bg_runner._detachment_kwargs() + + assert "start_new_session" not in kwargs + assert "creationflags" in kwargs + flags = kwargs["creationflags"] + assert flags & bg_runner._CREATE_NEW_PROCESS_GROUP + assert flags & bg_runner._CREATE_BREAKAWAY_FROM_JOB + # Exactly the OR of the two — no stray bits. + assert flags == (bg_runner._CREATE_NEW_PROCESS_GROUP | bg_runner._CREATE_BREAKAWAY_FROM_JOB) + + +# --------------------------------------------------------------------------- +# _is_breakaway_denied +# --------------------------------------------------------------------------- + + +class TestIsBreakawayDenied: + """Narrow OSError classification for the breakaway-denied case.""" + + def test_winerror_5_is_denied(self) -> None: + assert bg_runner._is_breakaway_denied(_make_breakaway_denied_error()) is True + + def test_winerror_other_is_not_denied(self) -> None: + assert bg_runner._is_breakaway_denied(_make_file_not_found_error()) is False + + def test_missing_winerror_is_not_denied(self) -> None: + """Plain POSIX OSError (no ``winerror`` attribute) must not be misclassified.""" + assert bg_runner._is_breakaway_denied(OSError(13, "Permission denied")) is False + + +# --------------------------------------------------------------------------- +# _spawn_detached +# --------------------------------------------------------------------------- + + +class TestSpawnDetached: + """Behavior of ``_spawn_detached`` across platforms and failure modes.""" + + def test_posix_happy_path_uses_start_new_session(self) -> None: + captured: dict[str, Any] = {} + + def _fake_popen(cmd: list[str], **kwargs: Any) -> MagicMock: + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return MagicMock(pid=1234) + + with ( + patch.object(bg_runner.sys, "platform", "linux"), + patch.object(bg_runner.subprocess, "Popen", side_effect=_fake_popen) as mock_popen, + ): + proc = bg_runner._spawn_detached(["python", "-c", "pass"], {"X": "1"}) + + assert proc.pid == 1234 + mock_popen.assert_called_once() + kwargs = captured["kwargs"] + assert kwargs["start_new_session"] is True + assert "creationflags" not in kwargs + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + assert kwargs["stdin"] is subprocess.DEVNULL + assert kwargs["env"] == {"X": "1"} + + def test_windows_happy_path_includes_breakaway(self) -> None: + captured: dict[str, Any] = {} + + def _fake_popen(cmd: list[str], **kwargs: Any) -> MagicMock: + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return MagicMock(pid=4321) + + with ( + patch.object(bg_runner.sys, "platform", "win32"), + patch.object(bg_runner.subprocess, "Popen", side_effect=_fake_popen) as mock_popen, + ): + proc = bg_runner._spawn_detached(["python", "-c", "pass"], {"X": "1"}) + + assert proc.pid == 4321 + mock_popen.assert_called_once() + flags = captured["kwargs"]["creationflags"] + assert flags == (bg_runner._CREATE_NEW_PROCESS_GROUP | bg_runner._CREATE_BREAKAWAY_FROM_JOB) + + def test_windows_breakaway_denied_falls_back_and_warns( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """When the parent's job forbids breakaway, retry without the flag. + + - First Popen call requests breakaway and raises OSError(winerror=5). + - Second Popen call must NOT include CREATE_BREAKAWAY_FROM_JOB. + - A user-visible warning must be written to stderr. + """ + success_proc = MagicMock(pid=999) + popen_kwargs: list[dict[str, Any]] = [] + + def _fake_popen(cmd: list[str], **kwargs: Any) -> MagicMock: + popen_kwargs.append(kwargs) + if len(popen_kwargs) == 1: + raise _make_breakaway_denied_error() + return success_proc + + with ( + patch.object(bg_runner.sys, "platform", "win32"), + patch.object(bg_runner.subprocess, "Popen", side_effect=_fake_popen) as mock_popen, + ): + proc = bg_runner._spawn_detached(["python", "-c", "pass"], {"X": "1"}) + + assert proc is success_proc + assert mock_popen.call_count == 2 + + # First attempt requested breakaway. + first = popen_kwargs[0] + assert first["creationflags"] & bg_runner._CREATE_BREAKAWAY_FROM_JOB + # Second attempt is plain CREATE_NEW_PROCESS_GROUP, no breakaway. + second = popen_kwargs[1] + assert second["creationflags"] == bg_runner._CREATE_NEW_PROCESS_GROUP + assert not (second["creationflags"] & bg_runner._CREATE_BREAKAWAY_FROM_JOB) + # Stdio + env preserved across the retry. + assert second["stdout"] is subprocess.DEVNULL + assert second["stderr"] is subprocess.DEVNULL + assert second["stdin"] is subprocess.DEVNULL + assert second["env"] == {"X": "1"} + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + assert "breakaway" in captured.err.lower() + # Must not pollute stdout (caller prints "Dashboard: ..." there). + assert captured.out == "" + + def test_windows_non_breakaway_oserror_propagates(self) -> None: + """OSErrors other than ERROR_ACCESS_DENIED must propagate without retry.""" + not_found = _make_file_not_found_error() + with ( + patch.object(bg_runner.sys, "platform", "win32"), + patch.object(bg_runner.subprocess, "Popen", side_effect=not_found) as mock_popen, + pytest.raises(FileNotFoundError), + ): + bg_runner._spawn_detached(["nonexistent.exe"], {}) + + # Exactly one attempt — no fallback retry. + mock_popen.assert_called_once() + + def test_posix_oserror_propagates_without_retry(self) -> None: + """POSIX never has a breakaway concept; OSErrors must propagate.""" + err = OSError(13, "Permission denied") + with ( + patch.object(bg_runner.sys, "platform", "linux"), + patch.object(bg_runner.subprocess, "Popen", side_effect=err) as mock_popen, + pytest.raises(OSError, match="Permission denied"), + ): + bg_runner._spawn_detached(["python", "-c", "pass"], {}) + + mock_popen.assert_called_once() + + +# --------------------------------------------------------------------------- +# Integration: launch_background / launch_background_resume route through +# _spawn_detached so the breakaway fix applies in both run and resume paths. +# --------------------------------------------------------------------------- + + +class TestLaunchBackgroundRoutesThroughSpawnDetached: + """End-to-end: ensure both launch helpers actually call ``_spawn_detached``.""" + + def test_launch_background_calls_spawn_detached(self, tmp_path: Path) -> None: + wf_path = tmp_path / "wf.yaml" + wf_path.write_text("workflow: {name: x, entry_point: a}\nagents: []\n") + + fake_proc = MagicMock(pid=1) + fake_proc.poll.return_value = None + + with ( + patch.object(bg_runner, "_spawn_detached", return_value=fake_proc) as mock_spawn, + patch.object(bg_runner, "_wait_for_server", return_value=True), + patch("conductor.cli.pid.write_pid_file"), + ): + url = bg_runner.launch_background( + workflow_path=wf_path, + inputs={"q": "hello"}, + web_port=9301, + ) + + assert url == "http://127.0.0.1:9301" + mock_spawn.assert_called_once() + # _spawn_detached is called positionally: (cmd, env). + cmd = mock_spawn.call_args.args[0] + env = mock_spawn.call_args.args[1] + assert "--web" in cmd + assert "--web-port" in cmd + assert "9301" in cmd + assert env["CONDUCTOR_WEB_BG"] == "1" + assert env["CONDUCTOR_WEB_PORT"] == "9301" + + def test_launch_background_resume_calls_spawn_detached(self, tmp_path: Path) -> None: + wf_path = tmp_path / "wf.yaml" + wf_path.write_text("workflow: {name: x, entry_point: a}\nagents: []\n") + + fake_proc = MagicMock(pid=2) + fake_proc.poll.return_value = None + + with ( + patch.object(bg_runner, "_spawn_detached", return_value=fake_proc) as mock_spawn, + patch.object(bg_runner, "_wait_for_server", return_value=True), + patch("conductor.cli.pid.write_pid_file"), + ): + url = bg_runner.launch_background_resume( + workflow_path=wf_path, + checkpoint_path=None, + web_port=9302, + ) + + assert url == "http://127.0.0.1:9302" + mock_spawn.assert_called_once() + cmd = mock_spawn.call_args.args[0] + env = mock_spawn.call_args.args[1] + assert "resume" in cmd + assert "--web" in cmd + assert "9302" in cmd + assert env["CONDUCTOR_WEB_BG"] == "1" + assert env["CONDUCTOR_WEB_PORT"] == "9302" + + def test_launch_background_wraps_spawn_failure_in_runtimeerror(self, tmp_path: Path) -> None: + """Spawn failures are wrapped so the CLI surfaces a clean error.""" + wf_path = tmp_path / "wf.yaml" + wf_path.write_text("workflow: {name: x, entry_point: a}\nagents: []\n") + + with ( + patch.object( + bg_runner, + "_spawn_detached", + side_effect=OSError("simulated spawn failure"), + ), + pytest.raises(RuntimeError, match="Failed to start background process"), + ): + bg_runner.launch_background( + workflow_path=wf_path, + inputs={"q": "hello"}, + web_port=9303, + ) + + def test_launch_background_resume_wraps_spawn_failure_in_runtimeerror( + self, tmp_path: Path + ) -> None: + wf_path = tmp_path / "wf.yaml" + wf_path.write_text("workflow: {name: x, entry_point: a}\nagents: []\n") + + with ( + patch.object( + bg_runner, + "_spawn_detached", + side_effect=OSError("simulated spawn failure"), + ), + pytest.raises(RuntimeError, match="Failed to start background process"), + ): + bg_runner.launch_background_resume( + workflow_path=wf_path, + checkpoint_path=None, + web_port=9304, + ) + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + + +class TestCreationFlagConstants: + """Module constants must be importable on any platform. + + The real ``subprocess.CREATE_BREAKAWAY_FROM_JOB`` constant only exists on + Windows; defaulting via ``getattr`` keeps the module importable on POSIX + (where ``bg_runner`` is still imported by tests and by the launch flow's + code path that just returns ``start_new_session=True``). + """ + + def test_constants_are_ints(self) -> None: + assert isinstance(bg_runner._CREATE_NEW_PROCESS_GROUP, int) + assert isinstance(bg_runner._CREATE_BREAKAWAY_FROM_JOB, int) + + def test_constants_match_subprocess_on_windows(self) -> None: + if sys.platform != "win32": + pytest.skip("Windows-only constants check") + + assert bg_runner._CREATE_NEW_PROCESS_GROUP == subprocess.CREATE_NEW_PROCESS_GROUP + assert bg_runner._CREATE_BREAKAWAY_FROM_JOB == subprocess.CREATE_BREAKAWAY_FROM_JOB diff --git a/tests/test_cli/test_resume_command.py b/tests/test_cli/test_resume_command.py index e13d149..2d0645d 100644 --- a/tests/test_cli/test_resume_command.py +++ b/tests/test_cli/test_resume_command.py @@ -795,7 +795,8 @@ def _fake_popen(cmd: list[str], **kwargs: object) -> MagicMock: assert captured["stderr"] is _sp.DEVNULL assert captured["stdin"] is _sp.DEVNULL if _sys.platform == "win32": - assert captured["creationflags"] == _sp.CREATE_NEW_PROCESS_GROUP + expected_flags = _sp.CREATE_NEW_PROCESS_GROUP | _sp.CREATE_BREAKAWAY_FROM_JOB + assert captured["creationflags"] == expected_flags else: assert captured["start_new_session"] is True env = captured["env"]