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?

<venv>/bin/python

<venv>/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).

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.