diff --git a/launch/core/platforms/windows.py b/launch/core/platforms/windows.py index 28c146e..310f60e 100644 --- a/launch/core/platforms/windows.py +++ b/launch/core/platforms/windows.py @@ -1,310 +1,510 @@ -from __future__ import annotations - -from launch.core.platforms.base import ( - CMD_OUTPUT_PS1_BEGIN, - CMD_OUTPUT_PS1_END, - CMD_OUTPUT_METADATA_PS1_REGEX, - ANSI_ESCAPE, - TIMEOUT_EXIT_CODE, - MEM_LIMIT, - CPU_CORES, - VAR_PATTERNS -) -from launch.core.platforms.base import ( - CmdOutputMetadata, - CommandResult -) -from launch.core.platforms.linux import LinuxRuntime - -import os -from typing import Any -import queue -import uuid - -import docker -from docker.models.containers import Container - - -class WindowsRuntime(LinuxRuntime): - - def __init__( - self, - container: Container, - command_timeout: int = 30 - ): - """ - Initialize runtime with an existing Docker container. - - Args: - container (Container): Docker container instance to manage - """ - self.container = container - self.platform = "windows" - self.command_timeout=command_timeout - self.working_dir = r"C:\testbed" - self.mnt_container = r"C:\mnt_tmp" - self.mnt_host = os.path.join(os.getcwd(), "tmp") - self.sock = self.container.attach_socket( - params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1} - ) - self.output_queue: queue.Queue[bytes] = queue.Queue() - self.stopped = False - self._start_output_thread() - self._clear_initial_prompt() - self.send_command(r''' -function prompt { - if ($?) {$ec=0; $LASTEXITCODE=0} else {if ($LASTEXITCODE -ne 0) {$ec=$LASTEXITCODE} else {$ec=1}} - $u = $env:USERNAME - $h = $env:COMPUTERNAME - $wd = (Get-Location).Path - $pyCmd = Get-Command python -ErrorAction SilentlyContinue - $py = if ($pyCmd) { - if ($pyCmd.PSObject.Properties.Match('Path').Count -gt 0 -and $pyCmd.Path) { $pyCmd.Path } - elseif ($pyCmd.PSObject.Properties.Match('Source').Count -gt 0 -and $pyCmd.Source) { $pyCmd.Source } - else { '' } - } else { '' } - Write-Output "" - Write-Output "###PS1JSON###" - $obj = [ordered]@{ - exit_code = $ec - username = $u - hostname = $h - working_dir = $wd - py_interpreter_path = $py - } - $obj | ConvertTo-Json -Compress - Write-Output "###PS1END###" - "PS $wd> " -} -''') - self.preparation_commands = [] - - def send_command(self, command: str, timeout: int|None = None) -> CommandResult: - ''' - timeout: deprecated arg for backward compatibility. In minute. If not specified use self.timeout from object inittialization. - ''' - timeout = self.command_timeout * 60 if timeout is None else timeout * 60 # in seconds - - if self.stopped: - raise RuntimeError("container is stopped. Currently we have not enabled container restart after docker commit. If you need to restore the container you must launch from the new image you committed.") - - # Normalize newline semantics for interactive shells - # For PowerShell, ensure CRLF line endings - command = command.strip().replace("\r\n", "\n").replace("\n", "\r\n") - # Add extra CRLF for multi-line blocks to signal completion - command += "\r\n\r\nprompt\r\n\r\n" - - self._clear_initial_prompt() - - self._send_bytes(command.encode()) - - output, metadata = self._read_raw_output(timeout=timeout) - if metadata is not None: - return CommandResult(output=output, metadata=metadata) - - # handle timeout - # to kill the task completely, should Ctrl^C for several times - for _ in range(10): - self._send_bytes(b"\x03") - - kill_timeout = 5 - kill_output, kill_metadata = self._read_raw_output(timeout=kill_timeout) - - output = output + kill_output + "\n**Exited due to timeout**\n" - if kill_metadata is not None: - kill_metadata.exit_code = TIMEOUT_EXIT_CODE - return CommandResult(output=output, metadata=kill_metadata) - - fallback_metadata = CmdOutputMetadata( - exit_code=TIMEOUT_EXIT_CODE, - ) - - return CommandResult(output=output, metadata=fallback_metadata) - - @classmethod - def _start_container( - cls, - image_name: str, - container_id: str, - docker_timeout: int, - command_timeout: int, - ) -> WindowsRuntime: - try: - docker.from_env().ping() - except docker.errors.DockerException: - raise RuntimeError("Docker is not installed or not running.") - - _ = cls.pull_image(image_name) - client = docker.from_env(timeout=docker_timeout) # commit added layers should finish in 2 hours - container_name = f"git-launch-{container_id}-{str(uuid.uuid4())[:4]}" - info = client.version() - engine_os = (info.get("Os") or info.get("OSType") or "").lower() - # which operating system this code is running on, note windows can run linux containers, so engine_os != (container) platform - extra_hosts = {"host.docker.internal": "host-gateway"} if "linux" in engine_os else None - - os.makedirs(os.path.join(os.getcwd(), "tmp"), exist_ok=True) - shell_command = r"powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -NoExit" - working_dir = r"C:\testbed" - run_kwargs = { - "cpu_count": CPU_CORES, # cpu_quota is Linux-only - "mem_limit": MEM_LIMIT, - } - - container = client.containers.run( - image_name, - name=container_name, - command=shell_command, - stdin_open=True, - tty=True, - detach=True, - environment={ - "TERM": "xterm-mono", - }, - working_dir=working_dir, - extra_hosts=extra_hosts, - volumes={ - os.path.join(os.getcwd(), "tmp"): { - "bind": r"C:\mnt_tmp", - "mode": "rw", - } - }, - **run_kwargs, - ) - - session = cls( - container, - command_timeout=command_timeout, - ) - - return session - - @classmethod - def start_runtime_from_launch_image( - cls, - image_name: str, - instance_id: str, - command_timeout: int = 30, - ) -> WindowsRuntime: - """ - Start a Docker container session for repository testing. - - Args: - image_name (str): Base Docker image name - instance (dict): SWE-bench instance data with repo info - platform: the platform of the container, linux or windows - - Returns: - SetupRuntime: Configured runtime session ready for command execution - - Raises: - RuntimeError: If Docker is not available - """ - container_id = instance_id.replace("/", "_") - session = cls._start_container( - image_name, - container_id, - 7200, - command_timeout - ) - return session - - - - @classmethod - def start_runtime_from_base_image( - cls, - image_name: str, - instance: dict[str, Any], - command_timeout: int = 30, - ) -> WindowsRuntime: - """ - Start a Docker container session for repository testing. - - Args: - image_name (str): Base Docker image name - instance (dict): SWE-bench instance data with repo info - platform: the platform of the container, linux or windows - - Returns: - SetupRuntime: Configured runtime session ready for command execution - - Raises: - RuntimeError: If Docker is not available - """ - - # commit a new image built from scratch should require many many hours - # todo: make docker commit a separate thread / process, make it async to accelerate - container_id = instance["instance_id"].replace("/", "_") - session = cls._start_container( - image_name, - container_id, - 18000, - command_timeout - ) - - # We avoid copying due to performance issues - # session.copy_dir_to_container(str(workspace), "/workspace") - - url = f'https://github.com/{instance["repo"]}.git' - base_commit = instance["base_commit"] - git_install_cmd = r''' -# Skip if git already present -if (-not (Get-Command git.exe -ErrorAction SilentlyContinue)) { - try { - # Prefer Chocolatey (cleaner package mgmt) - if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) { - Set-ExecutionPolicy Bypass -Scope Process -Force - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - } - - choco install git.install -y --no-progress --params '"/GitOnlyOnPath /NoAutoCrlf"' - } - catch { - Write-Host "Chocolatey install failed: $($_.Exception.Message) -> falling back to Git for Windows installer" - - # Fallback: Official Git for Windows silent install - $ProgressPreference = 'SilentlyContinue' - $temp = Join-Path $env:TEMP 'git-installer.exe' - # 'latest' link maintained by Git for Windows; resolves to current amd64 EXE - $url = 'https://github.com/git-for-windows/git/releases/latest/download/Git-64-bit.exe' - try { - Invoke-WebRequest -Uri $url -OutFile $temp - } catch { - Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $temp - } - - # Silent/unattended flags per Git for Windows docs (Inno Setup): - # /VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS - # Optional components: icons, ext\reg\shellhere, assoc, assoc_sh, gitlfs, windowsterminal, scalar - Start-Process -FilePath $temp -ArgumentList ` - '/VERYSILENT','/NORESTART','/NOCANCEL','/SP-','/CLOSEAPPLICATIONS','/RESTARTAPPLICATIONS',` - '/COMPONENTS="icons,ext\reg\shellhere,assoc,assoc_sh,gitlfs,windowsterminal,scalar"' ` - -Wait - } - - # Ensure PATH is updated in this running session (Chocolatey/Git installers update registry only) - $gitCmd = 'C:\Program Files\Git\cmd' - $gitBin = 'C:\Program Files\Git\bin' - if (Test-Path $gitCmd) { $env:PATH = "$gitCmd;$gitBin;$env:PATH" } -} -''' - repo_clone_cmd = r'git config --global --add safe.directory "C:\testbed"; git init "C:\testbed"; cd "C:\testbed"; git remote add origin {url}; git fetch --depth 1 origin {base}; git reset --hard {base}'.format( - url=url, base=base_commit - ) - session.preparation_commands.extend([git_install_cmd, repo_clone_cmd]) - - # 2) Ensure Git is installed (Chocolatey if possible; fallback to official silent installer). - # - Chocolatey official install script: https://community.chocolatey.org/install.ps1 - # - git.install package params include /GitOnlyOnPath, /GitAndUnixToolsOnPath, /NoAutoCrlf, etc. - # - Git for Windows silent flags are documented by the project itself. - session.send_command(git_install_cmd) - res: CommandResult = session.send_command(repo_clone_cmd) - session.send_command("ls") - - if int(res.metadata.exit_code) != 0: - session.cleanup() - raise RuntimeError(f"Git clone/reset failed: \n{res.output}") - - return session +from __future__ import annotations + +from launch.core.platforms.base import ( + CMD_OUTPUT_PS1_BEGIN, + CMD_OUTPUT_PS1_END, + CMD_OUTPUT_METADATA_PS1_REGEX, + ANSI_ESCAPE, + TIMEOUT_EXIT_CODE, + MEM_LIMIT, + CPU_CORES, + VAR_PATTERNS +) +from launch.core.platforms.base import ( + CmdOutputMetadata, + CommandResult +) +from launch.core.platforms.linux import LinuxRuntime + +import os +import time +from typing import Any +import queue +import threading +import uuid + +import docker +from docker.models.containers import Container + + +# Bound recovery after Docker externally destroys a Windows task container +# without changing normal command-timeout semantics. +WINDOWS_EXEC_LIVENESS_POLL_SECONDS = 5.0 + + +class WindowsRuntime(LinuxRuntime): + + def __init__( + self, + container: Container, + command_timeout: int = 30 + ): + """ + Initialize runtime with an existing Docker container. + + Args: + container (Container): Docker container instance to manage + """ + self.container = container + self.platform = "windows" + self.command_timeout=command_timeout + self.working_dir = r"C:\testbed" + self.mnt_container = r"C:\mnt_tmp" + self.mnt_host = os.path.join(os.getcwd(), "tmp") + self.sock = self.container.attach_socket( + params={"stdin": 1, "stdout": 1, "stderr": 1, "stream": 1} + ) + self.output_queue: queue.Queue[bytes] = queue.Queue() + self.stopped = False + self._start_output_thread() + self._clear_initial_prompt() + self.send_command(r''' +function prompt { + if ($?) {$ec=0; $LASTEXITCODE=0} else {if ($LASTEXITCODE -ne 0) {$ec=$LASTEXITCODE} else {$ec=1}} + $u = $env:USERNAME + $h = $env:COMPUTERNAME + $wd = (Get-Location).Path + $pyCmd = Get-Command python -ErrorAction SilentlyContinue + $py = if ($pyCmd) { + if ($pyCmd.PSObject.Properties.Match('Path').Count -gt 0 -and $pyCmd.Path) { $pyCmd.Path } + elseif ($pyCmd.PSObject.Properties.Match('Source').Count -gt 0 -and $pyCmd.Source) { $pyCmd.Source } + else { '' } + } else { '' } + Write-Output "" + Write-Output "###PS1JSON###" + $obj = [ordered]@{ + exit_code = $ec + username = $u + hostname = $h + working_dir = $wd + py_interpreter_path = $py + } + $obj | ConvertTo-Json -Compress + Write-Output "###PS1END###" + "PS $wd> " +} + +''') + self.preparation_commands = [] + + def _container_liveness_diagnostic(self) -> str | None: + """Return a bounded diagnostic only for a Docker-confirmed lost container.""" + try: + self.container.reload() + state = getattr(self.container, "attrs", {}).get("State", {}) or {} + if bool(state.get("Running")): + return None + status = str(state.get("Status") or "unknown") + exit_code = state.get("ExitCode") + suffix = f" exit_code={exit_code}" if exit_code is not None else "" + return f"Windows task container is no longer running (status={status}{suffix})." + except docker.errors.NotFound: + return "Windows task container no longer exists in Docker." + except docker.errors.APIError: + return None + except Exception: + return None + + def _container_liveness_result(self, diagnostic: str) -> CommandResult: + """Return a non-timeout sentinel for a Docker-confirmed lost container.""" + self.stopped = True + return CommandResult( + output=( + "\n**Windows container execution became unavailable before Docker " + f"returned a command result: {diagnostic}**\n" + ), + metadata=CmdOutputMetadata( + exit_code=125, + username=None, + hostname=None, + working_dir=self.working_dir, + py_interpreter_path=None, + ), + ) + + def send_command(self, command: str, timeout: int|None = None) -> CommandResult: + """Execute a command through Docker's non-interactive exec API. + + Windows containers expose a ConPTY when started with ``tty=True``. The + old persistent attach path could leave long-running commands in an idle + PowerShell prompt and only produced zero-byte capture files. Direct + ``exec_run`` avoids ConPTY and returns the command output as a normal + process result while keeping the existing runtime API unchanged. + """ + timeout_seconds = self.command_timeout * 60 if timeout is None else timeout * 60 + + if self.stopped: + raise RuntimeError( + "container is stopped. Currently we have not enabled container restart after docker commit. " + "If you need to restore the container you must launch from the new image you committed." + ) + + import json + command_name = f"windows-command-{uuid.uuid4().hex}.ps1" + command_host_path = os.path.join(self.mnt_host, command_name) + command_guest_path = f"C:\\mnt_tmp\\{command_name}" + # Each exec is a fresh PowerShell process, so make the runtime working + # directory explicit rather than relying on persistent shell state. + script = ( + "Set-Location -LiteralPath 'C:\\testbed'\n" + + "$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)\n" + + "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n" + + command + + "\n" + + "$__slai_ec = if ($LASTEXITCODE -ne $null) { [int]$LASTEXITCODE } elseif ($?) { 0 } else { 1 }\n" + + "Write-Output '###PS1JSON###'\n" + + "$__slai_obj=[ordered]@{exit_code=$__slai_ec; username=$env:USERNAME; " + + "hostname=$env:COMPUTERNAME; working_dir=(Get-Location).Path; py_interpreter_path=''}\n" + + "$__slai_obj | ConvertTo-Json -Compress\n" + + "Write-Output '###PS1END###'\n" + ) + os.makedirs(self.mnt_host, exist_ok=True) + with open(command_host_path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(script) + + try: + # Docker SDK exec_run() has no timeout parameter. Run the blocking + # request in a daemon worker so this runtime's existing command + # deadline remains enforceable. On expiry, destroy the isolated + # evaluation container; that is the same cancellation boundary used + # by the interactive fallback when Ctrl-C cannot restore a prompt. + execution: dict[str, Any] = {} + completed = threading.Event() + + def run_exec() -> None: + try: + execution["result"] = self.container.exec_run( + [ + "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-File", command_guest_path, + ], + workdir=self.working_dir, + stdout=True, + stderr=True, + demux=False, + ) + except BaseException as exc: + execution["error"] = exc + finally: + completed.set() + + worker = threading.Thread( + target=run_exec, + name="windows-runtime-exec", + daemon=True, + ) + worker.start() + worker_detached = False + deadline = time.monotonic() + timeout_seconds + while not completed.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + break + completed.wait(timeout=min(remaining, WINDOWS_EXEC_LIVENESS_POLL_SECONDS)) + if completed.is_set(): + break + diagnostic = self._container_liveness_diagnostic() + if diagnostic is not None: + worker_detached = True + return self._container_liveness_result(diagnostic) + + if not completed.is_set(): + timeout_output = "\n**Exited due to timeout**\n" + try: + self.container.kill() + except Exception as exc: + timeout_output += f"Container kill after timeout failed: {exc}\n" + try: + self.container.remove(force=True) + except Exception as exc: + timeout_output += f"Container removal after timeout failed: {exc}\n" + self.stopped = True + # Keep the established timeout contract: the container was + # deliberately killed/removed, so its mounted command file may + # be removed immediately. Deferred cleanup is only for a + # container already lost externally while exec_run unwinds. + worker_detached = False + return CommandResult( + output=timeout_output, + metadata=CmdOutputMetadata(exit_code=TIMEOUT_EXIT_CODE), + ) + + if "error" in execution: + diagnostic = self._container_liveness_diagnostic() + if diagnostic is not None: + return self._container_liveness_result(diagnostic) + raise execution["error"] + result = execution["result"] + raw_output = result.output + if isinstance(raw_output, tuple): + raw_output = (raw_output[0] or b"") + (raw_output[1] or b"") + raw_bytes = raw_output or b"" + # Windows PowerShell redirection uses UTF-16LE by default. Decode + # the BOM-marked stream before stripping ANSI/control characters; + # UTF-8 replacement turns every second byte into NULs and makes the + # Go JSONL parser silently lose the test records. + if raw_bytes.startswith((b"\xff\xfe", b"\xfe\xff")): + output = raw_bytes.decode("utf-16", errors="replace") + else: + output = raw_bytes.decode("utf-8", errors="replace") + output = ANSI_ESCAPE.sub("", output).replace("\r", "") + matches = CmdOutputMetadata.matches_ps1_metadata(output) + metadata = CmdOutputMetadata.from_ps1_match(matches[-1]) if matches else None + if metadata is not None: + output = output[:matches[-1].start()] + else: + exit_code = result.exit_code + if exit_code is None: + exit_code = TIMEOUT_EXIT_CODE if timeout_seconds <= 0 else 1 + metadata = CmdOutputMetadata( + exit_code=int(exit_code), + username=None, + hostname=None, + working_dir=self.working_dir, + py_interpreter_path=None, + ) + return CommandResult(output=output, metadata=metadata) + finally: + # A blocked Docker SDK worker can outlive this method after loss or + # deadline recovery. Keep its mounted script until it unwinds. + if "worker_detached" in locals() and worker_detached: + def _remove_when_exec_finishes() -> None: + completed.wait() + try: + os.remove(command_host_path) + except OSError: + pass + + threading.Thread( + target=_remove_when_exec_finishes, + name="windows-runtime-script-cleanup", + daemon=True, + ).start() + else: + try: + os.remove(command_host_path) + except OSError: + pass + + def apply_patch(self, patch: str, verbose: bool = False) -> bool: + """Apply a unified diff using a native Windows container path.""" + output_temp = "\n\n<<<<<>>>>>\n\n" + filename = f"{uuid.uuid4()}.diff" + hostpath = os.path.join(self.mnt_host, filename) + guestpath = f"C:\\mnt_tmp\\{filename}" + os.makedirs(self.mnt_host, exist_ok=True) + with open(hostpath, "w", encoding="utf-8", newline="\n") as fh: + fh.write(patch) + try: + # LinuxRuntime.apply_patch uses /mnt_tmp, which is invalid inside a + # Windows container. Keep the same patch protocol but use the + # mounted Windows path explicitly. + res = self.send_command( + f"git apply --reject --whitespace=nowarn '{guestpath}'" + ) + if ( + int(res.metadata.exit_code) == 125 + and "Windows container execution became unavailable before Docker returned a command result:" in (res.output or "") + ): + raise RuntimeError(res.output.strip()) + ok = int(res.metadata.exit_code) == 0 + if ok: + if verbose: + print(f"git apply {guestpath} ---- Patch applied Successfully!", flush=True) + return True + if verbose: + print(output_temp.format(out=res.output), flush=True) + return False + finally: + try: + os.remove(hostpath) + except OSError: + pass + + @classmethod + def _start_container( + cls, + image_name: str, + container_id: str, + docker_timeout: int, + command_timeout: int, + ) -> WindowsRuntime: + try: + docker.from_env().ping() + except docker.errors.DockerException: + raise RuntimeError("Docker is not installed or not running.") + + _ = cls.pull_image(image_name) + client = docker.from_env(timeout=docker_timeout) # commit added layers should finish in 2 hours + container_name = f"git-launch-{container_id}-{str(uuid.uuid4())[:4]}" + info = client.version() + engine_os = (info.get("Os") or info.get("OSType") or "").lower() + # which operating system this code is running on, note windows can run linux containers, so engine_os != (container) platform + extra_hosts = {"host.docker.internal": "host-gateway"} if "linux" in engine_os else None + + os.makedirs(os.path.join(os.getcwd(), "tmp"), exist_ok=True) + shell_command = r"powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -NoExit" + working_dir = r"C:\testbed" + run_kwargs = { + "cpu_count": CPU_CORES, # cpu_quota is Linux-only + "mem_limit": MEM_LIMIT, + } + + container = client.containers.run( + image_name, + name=container_name, + command=shell_command, + stdin_open=True, + tty=True, + detach=True, + environment={ + "TERM": "xterm-mono", + }, + working_dir=working_dir, + extra_hosts=extra_hosts, + volumes={ + os.path.join(os.getcwd(), "tmp"): { + "bind": r"C:\mnt_tmp", + "mode": "rw", + } + }, + **run_kwargs, + ) + + session = cls( + container, + command_timeout=command_timeout, + ) + + return session + + @classmethod + def start_runtime_from_launch_image( + cls, + image_name: str, + instance_id: str, + command_timeout: int = 30, + ) -> WindowsRuntime: + """ + Start a Docker container session for repository testing. + + Args: + image_name (str): Base Docker image name + instance (dict): SWE-bench instance data with repo info + platform: the platform of the container, linux or windows + + Returns: + SetupRuntime: Configured runtime session ready for command execution + + Raises: + RuntimeError: If Docker is not available + """ + container_id = instance_id.replace("/", "_") + session = cls._start_container( + image_name, + container_id, + 7200, + command_timeout + ) + return session + + + + @classmethod + def start_runtime_from_base_image( + cls, + image_name: str, + instance: dict[str, Any], + command_timeout: int = 30, + ) -> WindowsRuntime: + """ + Start a Docker container session for repository testing. + + Args: + image_name (str): Base Docker image name + instance (dict): SWE-bench instance data with repo info + platform: the platform of the container, linux or windows + + Returns: + SetupRuntime: Configured runtime session ready for command execution + + Raises: + RuntimeError: If Docker is not available + """ + + # commit a new image built from scratch should require many many hours + # todo: make docker commit a separate thread / process, make it async to accelerate + container_id = instance["instance_id"].replace("/", "_") + session = cls._start_container( + image_name, + container_id, + 18000, + command_timeout + ) + + # We avoid copying due to performance issues + # session.copy_dir_to_container(str(workspace), "/workspace") + + url = f'https://github.com/{instance["repo"]}.git' + base_commit = instance["base_commit"] + git_install_cmd = r''' +# Skip if git already present +if (-not (Get-Command git.exe -ErrorAction SilentlyContinue)) { + try { + # Prefer Chocolatey (cleaner package mgmt) + if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) { + Set-ExecutionPolicy Bypass -Scope Process -Force + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } + + choco install git.install -y --no-progress --params '"/GitOnlyOnPath /NoAutoCrlf"' + } + catch { + Write-Host "Chocolatey install failed: $($_.Exception.Message) -> falling back to Git for Windows installer" + + # Fallback: Official Git for Windows silent install + $ProgressPreference = 'SilentlyContinue' + $temp = Join-Path $env:TEMP 'git-installer.exe' + # 'latest' link maintained by Git for Windows; resolves to current amd64 EXE + $url = 'https://github.com/git-for-windows/git/releases/latest/download/Git-64-bit.exe' + try { + Invoke-WebRequest -Uri $url -OutFile $temp + } catch { + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $temp + } + + # Silent/unattended flags per Git for Windows docs (Inno Setup): + # /VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS + # Optional components: icons, ext\reg\shellhere, assoc, assoc_sh, gitlfs, windowsterminal, scalar + Start-Process -FilePath $temp -ArgumentList ` + '/VERYSILENT','/NORESTART','/NOCANCEL','/SP-','/CLOSEAPPLICATIONS','/RESTARTAPPLICATIONS',` + '/COMPONENTS="icons,ext\reg\shellhere,assoc,assoc_sh,gitlfs,windowsterminal,scalar"' ` + -Wait + } + + # Ensure PATH is updated in this running session (Chocolatey/Git installers update registry only) + $gitCmd = 'C:\Program Files\Git\cmd' + $gitBin = 'C:\Program Files\Git\bin' + if (Test-Path $gitCmd) { $env:PATH = "$gitCmd;$gitBin;$env:PATH" } +} +''' + repo_clone_cmd = r'git config --global --add safe.directory "C:\testbed"; git init "C:\testbed"; cd "C:\testbed"; git remote add origin {url}; git fetch --depth 1 origin {base}; git reset --hard {base}'.format( + url=url, base=base_commit + ) + session.preparation_commands.extend([git_install_cmd, repo_clone_cmd]) + + # 2) Ensure Git is installed (Chocolatey if possible; fallback to official silent installer). + # - Chocolatey official install script: https://community.chocolatey.org/install.ps1 + # - git.install package params include /GitOnlyOnPath, /GitAndUnixToolsOnPath, /NoAutoCrlf, etc. + # - Git for Windows silent flags are documented by the project itself. + session.send_command(git_install_cmd) + res: CommandResult = session.send_command(repo_clone_cmd) + session.send_command("ls") + + if int(res.metadata.exit_code) != 0: + session.cleanup() + raise RuntimeError(f"Git clone/reset failed: \n{res.output}") + + return session \ No newline at end of file diff --git a/tests/runtime_test.py b/tests/runtime_test.py index 809ec3d..232b59a 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -1,264 +1,264 @@ -''' -Can be run with pytest -rA - -Firstly need to skip different tests for different platform: -# if os == "linux" or "wsl": -# test both LinuxRuntime and AndroidRuntime -# base images: ubuntu:26.04, cimg/android:2026.03.1 - -# if os == "windows": -# test Windows Runtime -# base images: mcr.microsoft.com/windows/server:ltsc2025 - -workflow: -instance={ - "instance_id": "laurent22_joplin-a774", - "repo": "laurent22/joplin", - "base_commit": "a774c52fc09371456846be9610680481bd37dc7a" -} --> session=SetupRuntime.from_base_image(base_image, instance=instance) --> res = session.send_command("ls README.md") --> assert res.metadata.exit_code == 0 ; assert isinstance(res.output, str) and "README.md" in res.output; --> session.commit(name=f"repolaunch_test}:{platform}_{instance_id}") --> session.cleanup() - --> session=SetupRuntime.from_launch_image(f"repolaunch_test}:{platform}_{instance_id}", instance_id=instance_id) --> res = session.send_command("ls README.md") --> assert res.metadata.exit_code == 0 ; assert isinstance(res.output, str) and "README.md" in res.output; --> assert res.apply_patch(""" -diff --git a/log.out b/log.out -new file mode 100644 -index 0000000..7a754f4 ---- /dev/null -+++ b/log.out -@@ -0,0 +1,2 @@ -+1 -+2 -\\ No newline at end of file -""", verbose=True) == True --> session.cleanup() -''' - -import os -import platform as host_platform -import warnings - -import docker -import pytest -from docker.errors import APIError, DockerException, ImageNotFound - -from launch.core.runtime import SetupRuntime -from launch.core.platforms.linux import LinuxRuntime -from launch.core.platforms.windows import WindowsRuntime -from launch.core.platforms.android import AndroidRuntime -#from launch.core.platforms.macos import MacosRuntime - - -INSTANCE = { - "instance_id": "laurent22_joplin-a774", - "repo": "laurent22/joplin", - "base_commit": "a774c52fc09371456846be9610680481bd37dc7a", -} -PATCH_CONTENT = """diff --git a/log.out b/log.out\nnew file mode 100644\nindex 0000000..7a754f4\n--- /dev/null\n+++ b/log.out\n@@ -0,0 +1,2 @@\n+1\n+2\n\\ No newline at end of file""" - - -class FakeSocket: - pass - - -class FakeContainer: - def __init__(self, sock: FakeSocket): - self.sock = sock - - def attach_socket(self, params): - return self.sock - - def stop(self): - pass - - def remove(self, force=False): - pass - - -def no_command_result(self, command, timeout=None): - return None - - -@pytest.fixture -def patch_runtime_constructor_io(monkeypatch): - monkeypatch.setattr(LinuxRuntime, "_start_output_thread", lambda self: None) - monkeypatch.setattr(LinuxRuntime, "_clear_initial_prompt", lambda self: None) - monkeypatch.setattr(LinuxRuntime, "send_command", no_command_result) - monkeypatch.setattr(WindowsRuntime, "send_command", no_command_result) - #monkeypatch.setattr(MacosRuntime, "send_command", no_command_result) - #monkeypatch.setattr(MacosRuntime, "_wait_until_shell_ready", lambda self: None) - #monkeypatch.setattr(MacosRuntime, "_mount_swap_directory", lambda self: None) - - -@pytest.mark.parametrize( - ("runtime_cls", "expected_attrs"), - [ - pytest.param( - LinuxRuntime, - { - "platform": "linux", - "working_dir": r"/testbed", - "mnt_container": r"/mnt_tmp", - }, - id="linux", - ), - pytest.param( - WindowsRuntime, - { - "platform": "windows", - "working_dir": r"C:\testbed", - "mnt_container": r"C:\mnt_tmp", - }, - id="windows", - ), - pytest.param( - AndroidRuntime, - { - "platform": "android", - "working_dir": r"/testbed", - "mnt_container": r"/mnt_tmp", - }, - id="android", - ), - #pytest.param( - # MacosRuntime, - # { - # "platform": "macos", - # "working_dir": r"/Users/user/testbed", - # "mnt_container": r"/Volumes/repolaunch_swap", - # }, - # id="macos", - #), - ], -) -def test_runtime_constructor_attributes(runtime_cls, expected_attrs, patch_runtime_constructor_io): - command_timeout = 17 - sock = FakeSocket() - runtime = runtime_cls(FakeContainer(sock), command_timeout=command_timeout) - - try: - assert isinstance(runtime.platform, str) - assert runtime.platform == expected_attrs["platform"] - - assert isinstance(runtime.command_timeout, int) - assert runtime.command_timeout == command_timeout - - assert isinstance(runtime.working_dir, str) - assert runtime.working_dir == expected_attrs["working_dir"] - - assert isinstance(runtime.mnt_host, str) - assert runtime.mnt_host == os.path.join(os.getcwd(), "tmp") - - assert isinstance(runtime.mnt_container, str) - assert runtime.mnt_container == expected_attrs["mnt_container"] - - assert isinstance(runtime.sock, FakeSocket) - assert runtime.sock is sock - - assert isinstance(runtime.stopped, bool) - assert runtime.stopped is False - - assert isinstance(runtime.preparation_commands, list) - - finally: - runtime.stopped = True - - - -def supported_integration_platforms() -> set[str]: - system = host_platform.system().lower() - if system == "windows": - return {"windows"} - if system == "linux": - platforms = {"linux", "android"} - #if os.environ.get("REPOLAUNCH_RUN_MACOS_INTEGRATION") == "1" and os.path.exists("/dev/kvm"): - # platforms.add("macos") - return platforms - return set() - - -def docker_client_or_skip(): - try: - client = docker.from_env(timeout=60) - client.ping() - return client - except DockerException as exc: - pytest.fail(f"Docker is not available: {exc}") - - -def assert_readme_visible(session) -> None: - res = session.send_command("ls README.md") - assert res.metadata.exit_code == 0 - assert isinstance(res.output, str) - assert "README.md" in res.output.replace("ls README.md", "") - - -def remove_image_if_present(client, image_ref: str) -> None: - try: - client.images.remove(image=image_ref, force=True) - except (ImageNotFound, APIError): - pass - - -@pytest.mark.integration -@pytest.mark.parametrize( - ("runtime_platform", "base_image"), - [ - pytest.param("linux", "rust:1.90", id="linux"), - pytest.param("android", "cimg/android:2026.03.1", id="android"), - pytest.param("windows", "mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022", id="windows"), - #pytest.param("macos", "sickcodes/docker-osx:auto", id="macos"), - ], -) -def test_runtime_integration_workflow(runtime_platform, base_image): - if runtime_platform not in supported_integration_platforms(): - #if runtime_platform == "macos" and host_platform.system().lower() == "linux": - # warnings.warn("macos integration test uses a big linux container with macos vm inside. Due to efficiency macos support is not tested by default. If you want to test macos behaviour, install kvm and export REPOLAUNCH_RUN_MACOS_INTEGRATION=1.") - # pytest.skip("MacosRuntime is not tested by default.") - #else: - pytest.skip(f"{runtime_platform} runtime is not supported on this host") - - client = docker_client_or_skip() - image_repo = "repolaunch_test" - image_tag = f"{runtime_platform}_{INSTANCE['instance_id']}" - launch_image = f"{image_repo}:{image_tag}" - base_session = None - launch_session = None - - try: - base_session = SetupRuntime.from_base_image( - base_image, - instance=INSTANCE, - platform=runtime_platform, - ) - assert_readme_visible(base_session) - assert len(base_session.preparation_commands) > 0 - - assert base_session.apply_patch(PATCH_CONTENT, verbose=True) is True - rm_res = base_session.send_command("rm log.out") - assert rm_res.metadata.exit_code == 0, rm_res.output - - committed_image = base_session.commit(image_name=image_repo, tag=image_tag) - assert committed_image == launch_image - - finally: - if base_session is not None: - base_session.cleanup() - - try: - launch_session = SetupRuntime.from_launch_image( - launch_image, - instance_id=INSTANCE["instance_id"], - platform=runtime_platform, - ) - assert_readme_visible(launch_session) - assert launch_session.apply_patch(PATCH_CONTENT, verbose=True) is True - finally: - if launch_session is not None: - launch_session.cleanup() - remove_image_if_present(client, launch_image) +''' +Can be run with pytest -rA + +Firstly need to skip different tests for different platform: +# if os == "linux" or "wsl": +# test both LinuxRuntime and AndroidRuntime +# base images: ubuntu:26.04, cimg/android:2026.03.1 + +# if os == "windows": +# test Windows Runtime +# base images: mcr.microsoft.com/windows/server:ltsc2025 + +workflow: +instance={ + "instance_id": "laurent22_joplin-a774", + "repo": "laurent22/joplin", + "base_commit": "a774c52fc09371456846be9610680481bd37dc7a" +} +-> session=SetupRuntime.from_base_image(base_image, instance=instance) +-> res = session.send_command("ls README.md") +-> assert res.metadata.exit_code == 0 ; assert isinstance(res.output, str) and "README.md" in res.output; +-> session.commit(name=f"repolaunch_test}:{platform}_{instance_id}") +-> session.cleanup() + +-> session=SetupRuntime.from_launch_image(f"repolaunch_test}:{platform}_{instance_id}", instance_id=instance_id) +-> res = session.send_command("ls README.md") +-> assert res.metadata.exit_code == 0 ; assert isinstance(res.output, str) and "README.md" in res.output; +-> assert res.apply_patch(""" +diff --git a/log.out b/log.out +new file mode 100644 +index 0000000..7a754f4 +--- /dev/null ++++ b/log.out +@@ -0,0 +1,2 @@ ++1 ++2 +\\ No newline at end of file +""", verbose=True) == True +-> session.cleanup() +''' + +import os +import platform as host_platform +import warnings + +import docker +import pytest +from docker.errors import APIError, DockerException, ImageNotFound + +from launch.core.runtime import SetupRuntime +from launch.core.platforms.linux import LinuxRuntime +from launch.core.platforms.windows import WindowsRuntime +from launch.core.platforms.android import AndroidRuntime +#from launch.core.platforms.macos import MacosRuntime + + +INSTANCE = { + "instance_id": "laurent22_joplin-a774", + "repo": "laurent22/joplin", + "base_commit": "a774c52fc09371456846be9610680481bd37dc7a", +} +PATCH_CONTENT = """diff --git a/log.out b/log.out\nnew file mode 100644\nindex 0000000..7a754f4\n--- /dev/null\n+++ b/log.out\n@@ -0,0 +1,2 @@\n+1\n+2\n\\ No newline at end of file""" + + +class FakeSocket: + pass + + +class FakeContainer: + def __init__(self, sock: FakeSocket): + self.sock = sock + + def attach_socket(self, params): + return self.sock + + def stop(self): + pass + + def remove(self, force=False): + pass + + +def no_command_result(self, command, timeout=None): + return None + + +@pytest.fixture +def patch_runtime_constructor_io(monkeypatch): + monkeypatch.setattr(LinuxRuntime, "_start_output_thread", lambda self: None) + monkeypatch.setattr(LinuxRuntime, "_clear_initial_prompt", lambda self: None) + monkeypatch.setattr(LinuxRuntime, "send_command", no_command_result) + monkeypatch.setattr(WindowsRuntime, "send_command", no_command_result) + #monkeypatch.setattr(MacosRuntime, "send_command", no_command_result) + #monkeypatch.setattr(MacosRuntime, "_wait_until_shell_ready", lambda self: None) + #monkeypatch.setattr(MacosRuntime, "_mount_swap_directory", lambda self: None) + + +@pytest.mark.parametrize( + ("runtime_cls", "expected_attrs"), + [ + pytest.param( + LinuxRuntime, + { + "platform": "linux", + "working_dir": r"/testbed", + "mnt_container": r"/mnt_tmp", + }, + id="linux", + ), + pytest.param( + WindowsRuntime, + { + "platform": "windows", + "working_dir": r"C:\testbed", + "mnt_container": r"C:\mnt_tmp", + }, + id="windows", + ), + pytest.param( + AndroidRuntime, + { + "platform": "android", + "working_dir": r"/testbed", + "mnt_container": r"/mnt_tmp", + }, + id="android", + ), + #pytest.param( + # MacosRuntime, + # { + # "platform": "macos", + # "working_dir": r"/Users/user/testbed", + # "mnt_container": r"/Volumes/repolaunch_swap", + # }, + # id="macos", + #), + ], +) +def test_runtime_constructor_attributes(runtime_cls, expected_attrs, patch_runtime_constructor_io): + command_timeout = 17 + sock = FakeSocket() + runtime = runtime_cls(FakeContainer(sock), command_timeout=command_timeout) + + try: + assert isinstance(runtime.platform, str) + assert runtime.platform == expected_attrs["platform"] + + assert isinstance(runtime.command_timeout, int) + assert runtime.command_timeout == command_timeout + + assert isinstance(runtime.working_dir, str) + assert runtime.working_dir == expected_attrs["working_dir"] + + assert isinstance(runtime.mnt_host, str) + assert runtime.mnt_host == os.path.join(os.getcwd(), "tmp") + + assert isinstance(runtime.mnt_container, str) + assert runtime.mnt_container == expected_attrs["mnt_container"] + + assert isinstance(runtime.sock, FakeSocket) + assert runtime.sock is sock + + assert isinstance(runtime.stopped, bool) + assert runtime.stopped is False + + assert isinstance(runtime.preparation_commands, list) + + finally: + runtime.stopped = True + + + +def supported_integration_platforms() -> set[str]: + system = host_platform.system().lower() + if system == "windows": + return {"windows"} + if system == "linux": + platforms = {"linux", "android"} + #if os.environ.get("REPOLAUNCH_RUN_MACOS_INTEGRATION") == "1" and os.path.exists("/dev/kvm"): + # platforms.add("macos") + return platforms + return set() + + +def docker_client_or_skip(): + try: + client = docker.from_env(timeout=60) + client.ping() + return client + except DockerException as exc: + pytest.fail(f"Docker is not available: {exc}") + + +def assert_readme_visible(session) -> None: + res = session.send_command("ls README.md") + assert res.metadata.exit_code == 0 + assert isinstance(res.output, str) + assert "README.md" in res.output.replace("ls README.md", "") + + +def remove_image_if_present(client, image_ref: str) -> None: + try: + client.images.remove(image=image_ref, force=True) + except (ImageNotFound, APIError): + pass + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("runtime_platform", "base_image"), + [ + pytest.param("linux", "rust:1.90", id="linux"), + pytest.param("android", "cimg/android:2026.03.1", id="android"), + pytest.param("windows", "mcr.microsoft.com/dotnet/sdk:8.0-windowsservercore-ltsc2022", id="windows"), + #pytest.param("macos", "sickcodes/docker-osx:auto", id="macos"), + ], +) +def test_runtime_integration_workflow(runtime_platform, base_image): + if runtime_platform not in supported_integration_platforms(): + #if runtime_platform == "macos" and host_platform.system().lower() == "linux": + # warnings.warn("macos integration test uses a big linux container with macos vm inside. Due to efficiency macos support is not tested by default. If you want to test macos behaviour, install kvm and export REPOLAUNCH_RUN_MACOS_INTEGRATION=1.") + # pytest.skip("MacosRuntime is not tested by default.") + #else: + pytest.skip(f"{runtime_platform} runtime is not supported on this host") + + client = docker_client_or_skip() + image_repo = "repolaunch_test" + image_tag = f"{runtime_platform}_{INSTANCE['instance_id']}" + launch_image = f"{image_repo}:{image_tag}" + base_session = None + launch_session = None + + try: + base_session = SetupRuntime.from_base_image( + base_image, + instance=INSTANCE, + platform=runtime_platform, + ) + assert_readme_visible(base_session) + assert len(base_session.preparation_commands) > 0 + + assert base_session.apply_patch(PATCH_CONTENT, verbose=True) is True + rm_res = base_session.send_command("rm log.out") + assert rm_res.metadata.exit_code == 0, rm_res.output + + committed_image = base_session.commit(image_name=image_repo, tag=image_tag) + assert committed_image == launch_image + + finally: + if base_session is not None: + base_session.cleanup() + + try: + launch_session = SetupRuntime.from_launch_image( + launch_image, + instance_id=INSTANCE["instance_id"], + platform=runtime_platform, + ) + assert_readme_visible(launch_session) + assert launch_session.apply_patch(PATCH_CONTENT, verbose=True) is True + finally: + if launch_session is not None: + launch_session.cleanup() + remove_image_if_present(client, launch_image) diff --git a/tests/windows_exec_deadline_test.py b/tests/windows_exec_deadline_test.py new file mode 100644 index 0000000..b8a4e48 --- /dev/null +++ b/tests/windows_exec_deadline_test.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from launch.core.platforms.base import TIMEOUT_EXIT_CODE +from launch.core.platforms.windows import WindowsRuntime + + +class WindowsExecDeadlineTests(unittest.TestCase): + def test_blocking_exec_run_honors_runtime_deadline_and_cleans_container(self) -> None: + class BlockingContainer: + def __init__(self) -> None: + self.entered = threading.Event() + self.release = threading.Event() + self.killed = False + self.removed = False + + def exec_run(self, *_args, **_kwargs): + self.entered.set() + self.release.wait(timeout=5) + return type("ExecResult", (), {"output": b"", "exit_code": 0})() + + def kill(self) -> None: + self.killed = True + self.release.set() + + def remove(self, force: bool = False) -> None: + self.removed = force + + runtime = object.__new__(WindowsRuntime) + runtime.container = BlockingContainer() + runtime.command_timeout = 1 + runtime.stopped = False + runtime.working_dir = r"C:\\testbed" + + with tempfile.TemporaryDirectory() as temp_dir: + runtime.mnt_host = temp_dir + started = time.monotonic() + result = runtime.send_command("Start-Sleep -Seconds 600", timeout=0) + elapsed = time.monotonic() - started + + self.assertLess(elapsed, 2) + self.assertTrue(runtime.container.entered.wait(timeout=1)) + self.assertTrue(runtime.container.killed) + self.assertTrue(runtime.container.removed) + self.assertTrue(runtime.stopped) + self.assertEqual(result.metadata.exit_code, TIMEOUT_EXIT_CODE) + self.assertIn("Exited due to timeout", result.output) + self.assertEqual(os.listdir(temp_dir), []) + + def test_completed_exec_keeps_command_result(self) -> None: + class CompletedContainer: + def exec_run(self, *_args, **_kwargs): + metadata = ( + b'###PS1JSON###\n' + b'{"exit_code":0,"username":"u","hostname":"h",' + b'"working_dir":"C:\\\\\\testbed","py_interpreter_path":""}\n' + b'###PS1END###\n' + ) + return type("ExecResult", (), {"output": b"done\n" + metadata, "exit_code": 0})() + + def stop(self) -> None: + pass + + def remove(self, force: bool = False) -> None: + pass + + runtime = object.__new__(WindowsRuntime) + runtime.container = CompletedContainer() + runtime.command_timeout = 1 + runtime.stopped = False + runtime.working_dir = r"C:\\testbed" + + with tempfile.TemporaryDirectory() as temp_dir: + runtime.mnt_host = temp_dir + result = runtime.send_command("Write-Output done", timeout=1) + + self.assertEqual(result.metadata.exit_code, 0) + self.assertIn("done", result.output) + self.assertFalse(runtime.stopped) + self.assertEqual(os.listdir(temp_dir), []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/windows_exec_liveness_test.py b/tests/windows_exec_liveness_test.py new file mode 100644 index 0000000..14813d3 --- /dev/null +++ b/tests/windows_exec_liveness_test.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import os +import tempfile +import threading +import time +import unittest + +from launch.core.platforms import windows +from launch.core.platforms.windows import WindowsRuntime + + +class _Result: + def __init__(self, output: bytes, exit_code: int) -> None: + self.output = output + self.exit_code = exit_code + + +class _StoppedWhileExecBlocksContainer: + def __init__(self) -> None: + self.release = threading.Event() + self.attrs = {"State": {"Running": True, "Status": "running"}} + self.reload_calls = 0 + + def exec_run(self, *_args, **_kwargs): + self.release.wait(timeout=2) + return _Result(b"late result", 1) + + def reload(self) -> None: + self.reload_calls += 1 + self.attrs = {"State": {"Running": False, "Status": "exited", "ExitCode": 3221225473}} + self.release.set() + + def kill(self) -> None: + raise AssertionError("lost-container recovery must not kill a stopped container") + + def remove(self, force: bool = False) -> None: + raise AssertionError("lost-container recovery must not remove a stopped container") + + +class _HealthyContainer: + def __init__(self) -> None: + self.attrs = {"State": {"Running": True, "Status": "running"}} + self.reload_calls = 0 + + def exec_run(self, *_args, **_kwargs): + return _Result( + b"hello\n###PS1JSON###\n{\"exit_code\":0,\"username\":\"u\",\"hostname\":\"h\",\"working_dir\":\"C:\\\\testbed\",\"py_interpreter_path\":\"\"}\n###PS1END###\n", + 0, + ) + + def reload(self) -> None: + self.reload_calls += 1 + + def stop(self) -> None: + pass + + def remove(self, force: bool = False) -> None: + pass + + +class WindowsExecLivenessTests(unittest.TestCase): + def setUp(self) -> None: + self._old_cwd = os.getcwd() + self._directory = tempfile.TemporaryDirectory() + os.chdir(self._directory.name) + self._old_poll = windows.WINDOWS_EXEC_LIVENESS_POLL_SECONDS + windows.WINDOWS_EXEC_LIVENESS_POLL_SECONDS = 0.01 + + def tearDown(self) -> None: + windows.WINDOWS_EXEC_LIVENESS_POLL_SECONDS = self._old_poll + os.chdir(self._old_cwd) + self._directory.cleanup() + + @staticmethod + def _runtime(container) -> WindowsRuntime: + runtime = object.__new__(WindowsRuntime) + runtime.container = container + runtime.platform = "windows" + runtime.command_timeout = 1 + runtime.working_dir = r"C:\testbed" + runtime.mnt_container = r"C:\mnt_tmp" + runtime.mnt_host = os.path.join(os.getcwd(), "tmp") + runtime.stopped = False + runtime.preparation_commands = [] + return runtime + + def test_stopped_container_unblocks_exec_waiter_before_command_timeout(self) -> None: + container = _StoppedWhileExecBlocksContainer() + runtime = self._runtime(container) + started = time.monotonic() + result = runtime.send_command("Write-Output never-completes") + elapsed = time.monotonic() - started + + self.assertLess(elapsed, 1.0) + self.assertEqual(result.metadata.exit_code, 125) + self.assertIn("Windows container execution became unavailable", result.output) + self.assertIn("status=exited exit_code=3221225473", result.output) + self.assertTrue(runtime.stopped) + self.assertGreaterEqual(container.reload_calls, 1) + + def test_patch_application_propagates_lost_container(self) -> None: + runtime = self._runtime(_StoppedWhileExecBlocksContainer()) + patch = "\n".join([ + "diff --git a/file.txt b/file.txt", + "index 1111111..2222222 100644", + "--- a/file.txt", + "+++ b/file.txt", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ]) + with self.assertRaisesRegex(RuntimeError, "Windows container execution became unavailable"): + runtime.apply_patch(patch) + self.assertTrue(runtime.stopped) + + def test_completed_exec_does_not_probe_or_change_exit_code(self) -> None: + container = _HealthyContainer() + runtime = self._runtime(container) + result = runtime.send_command("Write-Output hello") + + self.assertEqual(result.metadata.exit_code, 0) + self.assertEqual(result.output.strip(), "hello") + self.assertEqual(container.reload_calls, 0) + self.assertFalse(runtime.stopped) + + +if __name__ == "__main__": + unittest.main()