plesty.server.host

Everything that differs between operating systems, in one place.

plesty-server runs on Windows benches and on macOS/Linux developer machines. Every branch on the platform lives in this module and nowhere else: the rest of the package asks host for the answer and stays platform-blind. When a new difference turns up, add it here — one function per question, each documenting the Windows and the POSIX answer side by side.

Rules of the module:

  • No other module tests sys.platform or os.name.

  • Every function is total — it returns a sensible answer on every platform, never raises for “not supported here”.

  • Functions take the platform as a parameter where that makes them testable from any host (venv_python()); the module-level windows() is the default.

Attributes

Report

_RETRY_DELAYS

_STOP_POLL

Functions

windows(→ bool)

Whether platform is Windows — the only one with console windows and Scripts/.

venv_python(→ pathlib.Path)

The interpreter inside a virtual environment.

executable(→ str)

The file name of an executable called name: name.exe on Windows, name on POSIX.

split_args(→ list[str])

Split a written command line into arguments the way platform reads it.

_unquote(→ str)

Strip one surrounding pair of quotes, as POSIX splitting would.

link_or_copy(→ None)

Make link stand in for the file target.

remove_tree(→ None)

Delete a directory tree even when git left read-only files in it.

move_tree(→ None)

Rename a directory, waiting out whoever is holding it open.

_clear_readonly_and_retry(→ None)

spawn_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child that shows no window.

echoing_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child whose output must reach our stdout.

waiter_kwargs(→ dict[str, Any])

subprocess keyword arguments for the launcher that waits on a command.

interruptible_kwargs(→ dict[str, Any])

subprocess keyword arguments for a child this process must be able to stop.

request_stop(→ None)

Ask the waiter pid to shut its command down cleanly.

watch_for_stop(→ None)

Call on_stop when stop_file appears — the Windows half of a clean stop.

stop_signal(→ int)

The signal a clean stop arrives as, for a process that must catch one.

deliver_stop(→ None)

Ask child to end cleanly, from the process that started it.

detach(→ int)

Run run in a process nobody has to reap, and tell report its pid.

reap_launcher(→ None)

Wait for a launcher process once it has reported the detached pid.

forward_signals(→ None)

Route the signals that mean "stop" to handler in the current process.

pid_alive(→ bool)

Whether a process with pid exists (no psutil).

_pid_alive_windows(→ bool)

terminate(→ None)

Tell pid to end (SIGTERM). Windows has no equivalent; kill_tree() is next.

kill_tree(→ None)

End pid and everything it started, without asking.

_signal(→ None)

Send sig to pid (or its process group), ignoring a vanished target.

Module Contents

plesty.server.host.Report
plesty.server.host.windows(platform: str = sys.platform) bool

Whether platform is Windows — the only one with console windows and Scripts/.

Parameters:

platform (str)

Return type:

bool

plesty.server.host.venv_python(venv: pathlib.Path, platform: str = sys.platform) pathlib.Path

The interpreter inside a virtual environment.

Windows: <venv>/Scripts/python.exe. POSIX: <venv>/bin/python.

Parameters:
  • venv (pathlib.Path)

  • platform (str)

Return type:

pathlib.Path

plesty.server.host.executable(name: str, platform: str = sys.platform) str

The file name of an executable called name: name.exe on Windows, name on POSIX.

Parameters:
  • name (str)

  • platform (str)

Return type:

str

plesty.server.host.split_args(text: str, platform: str = sys.platform) list[str]

Split a written command line into arguments the way platform reads it.

POSIX: shlex.split() — backslash escapes the next character and quotes group; both are removed. Windows: backslash is the path separator and escapes nothing. POSIX splitting eats it silently, so --config C:\lab\dev.ini arrives as C:labdev.ini and the device is handed a path that cannot exist. Only quotes group here, and they are stripped as POSIX splitting strips them.

Parameters:
  • text (str) – The command line as the fleet file or the console spells it.

  • platform (str) – The host to split for; defaults to this one.

Return type:

list[str]

plesty.server.host._unquote(token: str) str

Strip one surrounding pair of quotes, as POSIX splitting would.

Parameters:

token (str)

Return type:

str

Make link stand in for the file target.

POSIX: a symlink. Windows: creating one needs SeCreateSymbolicLinkPrivilege, held by an elevated shell or a machine in Developer Mode and by neither a bench nor a CI runner — the attempt raises WinError 1314. The file is copied instead, which resolves to the same interpreter or program.

Parameters:
  • target (pathlib.Path) – The existing file.

  • link (pathlib.Path) – The path to create.

Return type:

None

plesty.server.host.remove_tree(path: pathlib.Path) None

Delete a directory tree even when git left read-only files in it.

Windows: shutil.rmtree() refuses read-only files, and git marks everything under .git/objects read-only — so a checkout could not be uninstalled where a PyPI venv could. The retry clears the bit first. A file that is open elsewhere cannot be deleted at all here, where POSIX unlinks it happily: an antivirus or the search indexer holds one for a moment (the retry waits that out), a running device server holds its own interpreter for as long as it runs (PermissionError reaches the caller, which is the honest answer — the device has to be stopped first). POSIX: the owner may delete read-only and open files; no retry is reached.

Parameters:

path (pathlib.Path)

Return type:

None

plesty.server.host._RETRY_DELAYS = (0.1, 0.3, 0.6)
plesty.server.host.move_tree(source: pathlib.Path, target: pathlib.Path) None

Rename a directory, waiting out whoever is holding it open.

Windows: renaming a directory fails with PermissionError while any process holds a handle to it or to a file inside it, and something always does for a moment right after a tree is written — an antivirus scanning a fresh venv, the search indexer. POSIX renames it out from under an open handle and never reaches the retry. When the waiting does not help, the error reaches the caller: whatever holds it is not letting go.

Parameters:
  • source (pathlib.Path)

  • target (pathlib.Path)

Return type:

None

plesty.server.host._clear_readonly_and_retry(func: collections.abc.Callable[[str], None], path: str, _exc: BaseException) None
Parameters:
  • func (collections.abc.Callable[[str], None])

  • path (str)

  • _exc (BaseException)

Return type:

None

plesty.server.host.spawn_kwargs(new_group: bool = False) dict[str, Any]

subprocess keyword arguments for a child that shows no window.

Windows: a console program started from a process without a console of its own gets a brand-new console window — one black box per uv call over the GUI. CREATE_NO_WINDOW stops it; the child keeps its handles, so job logs still fill. new_group adds CREATE_NEW_PROCESS_GROUP so it can be signalled on its own. POSIX: there is no window; new_group starts a new session so the child outlives (and is not interrupted with) its parent.

Parameters:

new_group (bool) – Detach the child from this process’s signals.

Return type:

dict[str, Any]

plesty.server.host.echoing_kwargs() dict[str, Any]

subprocess keyword arguments for a child whose output must reach our stdout.

Windows: CREATE_NO_WINDOW leaves the child with no console, and a child with neither a console nor handles of its own writes its output nowhere — a field test’s whole transcript vanished from the job log that way, leaving only the lines the bench itself had written around it. Naming the streams makes subprocess pass real handles, so the flag can suppress the window without taking the output with it. POSIX: inheriting is enough and there is no window to suppress; naming them changes nothing.

Falls back to plain inheritance when this process has no usable stdout — a windowed process has nothing to hand down in any case.

Return type:

dict[str, Any]

plesty.server.host.waiter_kwargs() dict[str, Any]

subprocess keyword arguments for the launcher that waits on a command.

Windows: the waiter needs a console of its own — hidden — for two reasons that pull in opposite directions. Its child must inherit a console, or, being a console program with none to inherit, Windows gives it a new and visible one: a black box on the operator’s screen for every device server and every job. And the waiter must share that console with the child, because GenerateConsoleCtrlEvent cannot cross from one console to another, and a clean stop is exactly that event.

CREATE_NO_WINDOW gives neither: it runs the waiter with no console at all, so the child allocates the visible one. CREATE_NEW_CONSOLE with SW_HIDE gives a real console created hidden — no window, nothing to flash, and an event the waiter can deliver.

POSIX: a new session, as for any detached child.

Return type:

dict[str, Any]

plesty.server.host.interruptible_kwargs() dict[str, Any]

subprocess keyword arguments for a child this process must be able to stop.

Windows: CREATE_NEW_PROCESS_GROUP makes the child the leader of a group that can be sent CTRL_BREAK_EVENT. It deliberately does not add CREATE_NO_WINDOW: that would give the child a console of its own, and a console control event cannot cross from one console to another. The child inherits the waiter’s console instead, which waiter_kwargs() creates hidden for exactly this — so no window appears either way. POSIX: nothing; the child stays in our group and send_signal reaches it.

Return type:

dict[str, Any]

plesty.server.host._STOP_POLL = 0.2
plesty.server.host.request_stop(pid: int, stop_file: pathlib.Path) None

Ask the waiter pid to shut its command down cleanly.

POSIX: SIGINT, which the waiter forwards to the command. Windows: no console control event can reach it. The waiter runs on an invisible console of its own and the process asking may have no console at all — plesty-server-gui.exe is a GUI binary — and GenerateConsoleCtrlEvent only reaches the caller’s own console. It would fail silently and the bench would fall through to killing every device outright, so the waiter watches for stop_file instead (watch_for_stop()) and creating it is the request.

Parameters:
  • pid (int) – The waiter that owns the command.

  • stop_file (pathlib.Path) – The file that waiter watches.

Return type:

None

plesty.server.host.watch_for_stop(stop_file: pathlib.Path, on_stop: collections.abc.Callable[[], None]) None

Call on_stop when stop_file appears — the Windows half of a clean stop.

POSIX: returns at once. A stop arrives as a signal there, and the handler forward_signals() installed does the same work. Windows: starts a daemon thread polling for the file, so the waiter can be asked to stop by a process that shares no console with it.

Parameters:
  • stop_file (pathlib.Path) – The file whose appearance means “stop”.

  • on_stop (collections.abc.Callable[[], None]) – Run once, on the watching thread, when it appears.

Return type:

None

plesty.server.host.stop_signal() int

The signal a clean stop arrives as, for a process that must catch one.

POSIX: SIGINT. Windows: SIGBREAK. CTRL_BREAK_EVENT is the only console control event that can be aimed at a single process group — CTRL_C_EVENT is disabled outright for a group made with CREATE_NEW_PROCESS_GROUP — so it is what deliver_stop() sends, and Python delivers it as SIGBREAK. A server that only handles SIGINT is killed outright here (exit 0xC000013A) instead of closing its device.

Return type:

int

plesty.server.host.deliver_stop(child: subprocess.Popen[bytes]) None

Ask child to end cleanly, from the process that started it.

POSIX: SIGINT. Windows: CTRL_BREAK_EVENT to the group the child leads — it shares this process’s console, which is what interruptible_kwargs() buys. A child that has already gone is not an error.

Parameters:

child (subprocess.Popen[bytes])

Return type:

None

plesty.server.host.detach(run: collections.abc.Callable[[], int], report: Report) int

Run run in a process nobody has to reap, and tell report its pid.

POSIX: fork; the child starts a new session, points its standard streams at /dev/null, runs and _exits. The parent reports the child’s pid and returns 0 — it exits at once, so the child is reparented to init and no zombie is left for whoever started us. Windows: there is no fork; this process is the detached one. It reports its own pid, then runs — so a caller must never wait for it to finish (see reap_launcher()).

Parameters:
  • run (collections.abc.Callable[[], int]) – The work of the detached process; its return value is its exit code.

  • report (Report) – Receives the pid the caller should track.

Returns:

The exit code of the calling process.

Return type:

int

plesty.server.host.reap_launcher(proc: subprocess.Popen[bytes], timeout: float = 10) None

Wait for a launcher process once it has reported the detached pid.

POSIX: the launcher is the intermediate parent of detach(); it has exited already, and waiting collects it so it is not left a zombie. Windows: the launcher is the long-lived process — waiting would block until the server or job it runs has ended, so nothing is done.

Parameters:
  • proc (subprocess.Popen[bytes])

  • timeout (float)

Return type:

None

plesty.server.host.forward_signals(handler: collections.abc.Callable[[int, Any], None]) None

Route the signals that mean “stop” to handler in the current process.

POSIX: SIGINT, SIGTERM and SIGHUP (the terminal closed). Windows: SIGINT/SIGTERM (as Python maps them) plus SIGBREAK, which is ignored rather than forwarded — a console child in the same group receives CTRL_BREAK_EVENT on its own.

Installed before any child starts: a caught signal resets to its default across exec, whereas an ignored one is inherited.

Parameters:

handler (collections.abc.Callable[[int, Any], None])

Return type:

None

plesty.server.host.pid_alive(pid: int) bool

Whether a process with pid exists (no psutil).

POSIX: kill(pid, 0) — a permission error still means it exists. Windows: OpenProcess + GetExitCodeProcess = STILL_ACTIVE. Servers and jobs are never children of the asking process (see detach()), so there are no zombies to mistake for live processes.

Parameters:

pid (int)

Return type:

bool

plesty.server.host._pid_alive_windows(pid: int) bool
Parameters:

pid (int)

Return type:

bool

plesty.server.host.terminate(pid: int) None

Tell pid to end (SIGTERM). Windows has no equivalent; kill_tree() is next.

Parameters:

pid (int)

Return type:

None

plesty.server.host.kill_tree(pid: int) None

End pid and everything it started, without asking.

POSIX: SIGKILL to the process group (the detached process leads one). Windows: taskkill /T /F.

Parameters:

pid (int)

Return type:

None

plesty.server.host._signal(pid: int, sig: int, group: bool = False) None

Send sig to pid (or its process group), ignoring a vanished target.

Parameters:
  • pid (int)

  • sig (int)

  • group (bool)

Return type:

None