# Cross-platform development `plesty-server` runs on Windows benches and on macOS/Linux developer machines. This page lists what actually differs, and the one rule that keeps those differences from spreading through the code. ## The rule > Every branch on the operating system lives in `plesty/server/host.py` and > nowhere else. `host.py` is short and organised by question — *which host is this*, *filesystem layout*, *child processes* — and every function documents its **Windows** and **POSIX** answer side by side. The rest of the package asks `host` and stays platform-blind: `supervisor.py` calls `host.interrupt(pid)` and never mentions `SIGINT` or `CTRL_BREAK_EVENT`. The rule is enforced, not just stated: `tests/test_host.py` greps every module in the package for platform markers (`sys.platform`, `os.name`, `win32`, `creationflags`, `S_IWRITE`, `/Scripts/`, `taskkill`, …) and fails when one appears outside `host.py`. A contributor who adds a Windows workaround in the wrong place gets a red test, not a code-review comment a month later. ## What differs, and what `host` does about it | Question | POSIX (macOS, Linux) | Windows | `host` function | |---|---|---|---| | Where is the interpreter of a venv? | `/bin/python` | `/Scripts/python.exe` | `venv_python(venv, platform)` — takes the platform as a parameter so the Windows answer is tested on every host | | Which interpreter re-executes us? | any; `sys.executable` is the answer | `sys.executable` under the Qt console is `pythonw.exe`, which **cannot own a console** — its console-subsystem child then gets a new, visible one | `console_interpreter()` | | How do I start a long-running child with no window the operator sees? | any child; no console exists | a console program with **no console to inherit** gets a new, *visible* one — the waiter takes a hidden console of its own (`CREATE_NEW_CONSOLE` + `SW_HIDE`) and the child inherits it | `waiter_kwargs()` | | How do I start a process nobody has to reap? | `fork()`; the child calls `setsid()` and redirects its streams, the parent exits at once — the child is adopted by `init` | there is no `fork`; the launcher process **is** the long-lived one and prints its own pid | `detach(run, report)` | | Should I wait for the launcher after it reported a pid? | yes — it has exited, waiting collects it (no zombie) | **no** — it is the waiter; waiting would block until the server ends (this bug existed: `start()` returned only when the server died) | `reap_launcher(proc)` | | How do I start a child without a console window popping up? | nothing to do | `CREATE_NO_WINDOW`; `CREATE_NEW_PROCESS_GROUP` to signal it on its own | `spawn_kwargs(new_group)` | | How do I ask a process to stop cleanly? | `SIGINT` (the waiter forwards it to the server) | `CTRL_BREAK_EVENT` to the process group | `interrupt(pid)` | | …to stop a bit more firmly? | `SIGTERM` | no equivalent — skipped | `terminate(pid)` | | …to end it and everything it started? | `SIGKILL` to the process group | `taskkill /PID n /T /F` | `kill_tree(pid)` | | Which signals should the waiter forward? | `SIGINT`, `SIGTERM` | same, plus `SIGBREAK` which is ignored (the child gets its own `CTRL_BREAK_EVENT`) | `forward_signals(handler)` | | Is a pid alive? | `kill(pid, 0)` — a permission error still means "exists" | `OpenProcess` + `GetExitCodeProcess == STILL_ACTIVE` | `pid_alive(pid)` | | Can I delete a git checkout? | yes | `rmtree` refuses read-only files, and git marks `.git/objects` read-only — clear the bit and retry | `remove_tree(path)` | Everything else — paths via `pathlib`, `os.environ`, `subprocess.Popen` with lists, `os.replace` for atomic writes, UTF-8 with `errors="replace"` when reading logs — behaves the same on both and needs no branch. ## Habits that keep it that way - **Parameterise instead of branching** where you can: `venv_python` takes `platform`; tests call it with `"win32"` on a Mac. - **Make every `host` function total** — it returns a sensible answer on every platform and never raises "not supported here". `terminate` on Windows does nothing rather than failing. - **Mark the lines a host cannot reach** with `# pragma: no cover — exercised on the Windows CI job`, and make sure the CI actually has one — it is `tests:windows` in `.gitlab-ci.yml`, which runs the whole suite on a `saas-windows-medium-amd64` runner for merge requests, the default branch, `exp` and tags. Coverage on macOS or Linux says nothing about the Windows branch; that job is where `detach`, `deliver_stop` and `kill_tree` are truly executed. The marker is a claim about it, so a marker without the job is a false one. - **Never test on the platform name when you can test for the feature** (`hasattr(signal, "SIGBREAK")`) — but keep even that inside `host.py`, so the one-glance inventory stays complete. - **Prefer files over process relationships** for shared state; a JSON record in the home behaves identically everywhere, a parent/child link does not (see [Process management](process_management.md)). ## Adding a new difference 1. Write the function in `host.py` under the matching heading, with the *POSIX:* / *Windows:* lines in its docstring. 2. Call it from the service; do not import `sys.platform` there. 3. Run `uv run pytest tests/test_host.py` — the guard test tells you if a marker slipped out. 4. If the Windows branch cannot run here, mark it `pragma: no cover` and add a case `tests:windows` will exercise — the marker excludes the line from the Linux coverage gate, it does not excuse it from being run.