Skip to content

Commit 7baa0ac

Browse files
committed
fix(robotcode): make the wrapper work on Windows
The wrapper re-run used os.execvp to replace the process, which only has POSIX semantics; on Windows os.exec* spawns a new process and exits the caller, so the exit code and stdout/stderr don't propagate. Keep execvp on POSIX (clean, no extra process) and on Windows spawn the command, wait, and forward its exit code. The subprocess tests, and their wrapper scripts, use spawn-and-wait too, so they no longer skip on Windows. Also document how running through a wrapper differs on Linux/macOS vs. Windows, and why.
1 parent 1ab077e commit 7baa0ac

3 files changed

Lines changed: 33 additions & 32 deletions

File tree

docs/03_reference/wrapper.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ As noted above, a `wrapper` in your selected profile already applies in the edit
6666

6767
This wraps tests you run or debug from the Test Explorer and takes precedence over the profile's `wrapper`. It is an **IDE-only override** — it has no effect on the command line.
6868

69+
## How it runs on Linux/macOS vs. Windows
70+
71+
The wrapper contract is the same everywhere, but RobotCode hands the run to the wrapper differently depending on the operating system, and the resulting process tree differs:
72+
73+
- **Linux / macOS** — RobotCode **replaces itself** with the wrapper command (a POSIX `exec`). No extra process is left behind: the wrapper inherits RobotCode's process ID, stdio and signals directly. A shell wrapper can end with `exec "$@"` to replace itself with the run in turn, so signals and the exit code flow through on their own.
74+
- **Windows** — Windows has no way to replace a running process the way POSIX `exec` does. So RobotCode **starts the wrapper as a child process, waits for it, and forwards its exit code**. One extra RobotCode process stays in the tree just to wait. There is no `exec` here — a PowerShell wrapper simply runs the command as a child (`& $Command[0] …`) and waits, exactly as the example below does.
75+
76+
Either way the rules you write the script against don't change: run the command in the **foreground**, pass **stdio** through, and **propagate the exit code**.
77+
6978
## Writing your own wrapper script
7079

7180
A wrapper is just a command. RobotCode runs it with the arguments you configured first, and then appends the actual `robotcode` command line that has to run:

src/robotcode/cli/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging.config
44
import os
55
import shlex
6+
import subprocess
67
import sys
78
from pathlib import Path
89
from typing import Any, List, Literal, Optional
@@ -127,10 +128,16 @@ def _maybe_reexec_under_wrapper(
127128
# Mirrors the debug launcher's `debugger_script` handling.
128129
entry = [str(app.config.launcher_script)] if app.config.launcher_script else ["-m", "robotcode.cli"]
129130
command = [*wrapper, sys.executable, *entry, *sys.argv[1:]]
130-
app.verbose(lambda: "Re-executing under wrapper: " + " ".join(command))
131+
app.verbose(lambda: "Running under wrapper: " + " ".join(command))
131132

133+
# Run robotcode again under the wrapper; the guard env stops the spawned
134+
# robotcode from wrapping a second time. On POSIX we replace this process with
135+
# execvp (no extra process); Windows has no such exec, so there we spawn the
136+
# command, wait, and forward its exit code.
132137
os.environ[WRAPPER_APPLIED_ENV] = "1"
133138
try:
139+
if sys.platform == "win32":
140+
sys.exit(subprocess.run(command).returncode)
134141
os.execvp(command[0], command)
135142
except OSError as e:
136143
raise click.ClickException(f"Failed to execute wrapper command {wrapper!r}: {e}") from e

tests/robotcode/cli/test_wrapper.py

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
"""Tests for the `wrapper` re-exec feature of the `robotcode` CLI.
1+
"""Tests for the `wrapper` re-run feature of the `robotcode` CLI.
22
3-
The actual re-exec uses ``os.execvp`` to replace the process, so these tests
4-
spawn ``robotcode`` in a real subprocess (the in-process ``CliRunner`` would
5-
have its own process replaced). The wrapper is a small cross-platform Python
6-
script that records its invocation and then ``execv``s the wrapped command.
7-
8-
``os.execvp`` only has POSIX replace-semantics, so the subprocess tests are
9-
skipped on Windows.
3+
The re-run spawns robotcode again under the wrapper and waits for it, so these
4+
tests drive `robotcode` in a real subprocess (the in-process `CliRunner` would
5+
recurse into itself). The wrapper is a small cross-platform Python script that
6+
records its invocation and then runs the wrapped command. Everything is
7+
spawn-and-wait, so the tests run on every platform.
108
"""
119

1210
import json
@@ -21,19 +19,14 @@
2119

2220
from robotcode.plugin.click_helper.wrappable import is_wrappable
2321

24-
requires_posix_exec = pytest.mark.skipif(
25-
sys.platform == "win32",
26-
reason="the wrapper feature re-executes via POSIX os.execvp; not supported on Windows",
27-
)
28-
2922
# Records `SESSION_KIND` (to prove the profile env is applied *before* the
30-
# wrapper runs) into "<wrapper>.called", one line per invocation, then execs
31-
# the wrapped command (sys.argv[1:] = the python interpreter + robotcode args).
23+
# wrapper runs) into "<wrapper>.called", one line per invocation, then runs the
24+
# wrapped command (sys.argv[1:] = the python interpreter + robotcode args).
3225
_WRAPPER_PY = (
33-
"import os, sys\n"
26+
"import os, subprocess, sys\n"
3427
"with open(__file__ + '.called', 'a', encoding='utf-8') as f:\n"
3528
" f.write(os.environ.get('SESSION_KIND', '<unset>') + '\\n')\n"
36-
"os.execv(sys.argv[1], sys.argv[1:])\n"
29+
"sys.exit(subprocess.run(sys.argv[1:]).returncode)\n"
3730
)
3831

3932

@@ -43,12 +36,12 @@ def _write_wrapper(path: Path) -> Path:
4336

4437

4538
# A wrapper that records the command line it was handed (into "<path>.argv"),
46-
# then runs it — used to inspect how the re-exec reconstructed the invocation.
39+
# then runs it — used to inspect how the re-run reconstructed the invocation.
4740
_RECORDER_PY = (
48-
"import os, sys\n"
41+
"import subprocess, sys\n"
4942
"with open(__file__ + '.argv', 'w', encoding='utf-8') as f:\n"
5043
" f.write(' '.join(sys.argv[1:]))\n"
51-
"os.execv(sys.argv[1], sys.argv[1:])\n"
44+
"sys.exit(subprocess.run(sys.argv[1:]).returncode)\n"
5245
)
5346

5447

@@ -97,7 +90,7 @@ def project(tmp_path: Path) -> Path:
9790
return tmp_path
9891

9992

100-
# --- the @wrappable marker is on the right commands (pure, all platforms) ----
93+
# --- the @wrappable marker is on the right commands (no subprocess) ----------
10194

10295

10396
def test_execution_commands_are_marked_wrappable() -> None:
@@ -117,10 +110,9 @@ def test_non_execution_commands_are_not_wrappable() -> None:
117110
assert not is_wrappable(cmd), f"{cmd.name!r} should not be wrappable"
118111

119112

120-
# --- the re-exec behaviour (subprocess, POSIX only) --------------------------
113+
# --- the re-run behaviour (spawns robotcode in a subprocess) -----------------
121114

122115

123-
@requires_posix_exec
124116
def test_wrappable_command_runs_through_the_profile_wrapper(project: Path) -> None:
125117
wrapper = project / "wrap.py"
126118
result = _run_robotcode(project, ["-p", "x11", "run", "suite.robot"])
@@ -130,7 +122,6 @@ def test_wrappable_command_runs_through_the_profile_wrapper(project: Path) -> No
130122
assert _calls(wrapper) == ["xephyr"]
131123

132124

133-
@requires_posix_exec
134125
def test_guard_env_suppresses_wrapping(project: Path) -> None:
135126
"""The re-exec sets ROBOTCODE_WRAPPER_APPLIED before replacing the process,
136127
so a robotcode that already runs under a wrapper must not wrap again."""
@@ -144,15 +135,13 @@ def test_guard_env_suppresses_wrapping(project: Path) -> None:
144135
assert _calls(wrapper) == []
145136

146137

147-
@requires_posix_exec
148138
def test_no_wrapper_flag_disables_wrapping(project: Path) -> None:
149139
wrapper = project / "wrap.py"
150140
result = _run_robotcode(project, ["-p", "x11", "--no-wrapper", "run", "suite.robot"])
151141
assert result.returncode == 0, result.stderr
152142
assert _calls(wrapper) == []
153143

154144

155-
@requires_posix_exec
156145
def test_cli_wrapper_overrides_the_profile_wrapper(project: Path) -> None:
157146
profile_wrapper = project / "wrap.py"
158147
cli_wrapper = _write_wrapper(project / "cliwrap.py")
@@ -168,15 +157,13 @@ def test_cli_wrapper_overrides_the_profile_wrapper(project: Path) -> None:
168157
assert _calls(profile_wrapper) == []
169158

170159

171-
@requires_posix_exec
172160
def test_non_wrappable_command_is_not_wrapped(project: Path) -> None:
173161
wrapper = project / "wrap.py"
174162
# `discover` only parses files; it must never run through the wrapper.
175163
_run_robotcode(project, ["-p", "x11", "discover", "all", "suite.robot"])
176164
assert _calls(wrapper) == []
177165

178166

179-
@requires_posix_exec
180167
def test_reexec_goes_through_the_launcher_script(project: Path, tmp_path: Path) -> None:
181168
"""When robotcode was started through a bundled entry (`--launcher-script` /
182169
the bundled `__main__`), the re-exec must reuse that entry — not
@@ -204,7 +191,6 @@ def test_reexec_goes_through_the_launcher_script(project: Path, tmp_path: Path)
204191
assert "-m robotcode.cli" not in recorded
205192

206193

207-
@requires_posix_exec
208194
def test_direct_start_ignores_bundled_main_env(project: Path, tmp_path: Path) -> None:
209195
"""A directly started robotcode (no `--launcher-script`) must NOT be diverted
210196
to the bundled copy just because ROBOTCODE_BUNDLED_ROBOTCODE_MAIN is set — the
@@ -224,7 +210,6 @@ def test_direct_start_ignores_bundled_main_env(project: Path, tmp_path: Path) ->
224210
assert str(broken) not in recorded
225211

226212

227-
@requires_posix_exec
228213
def test_wrapper_propagates_the_run_exit_code(project: Path) -> None:
229214
"""The wrapper must not swallow the run's exit code (contract rule #1)."""
230215
(project / "fail.robot").write_text("*** Test Cases ***\nFails\n Should Be Equal 1 2\n", encoding="utf-8")
@@ -233,7 +218,7 @@ def test_wrapper_propagates_the_run_exit_code(project: Path) -> None:
233218
assert _calls(project / "wrap.py") == ["xephyr"] # and it did run through the wrapper
234219

235220

236-
# --- these paths return before any re-exec, so they run on every platform ----
221+
# --- the wrapper is ignored or disabled, with a warning ----------------------
237222

238223

239224
def test_wrapper_ignored_and_warns_on_non_wrappable_command(project: Path) -> None:

0 commit comments

Comments
 (0)