plesty.server.host ================== .. py:module:: plesty.server.host .. autoapi-nested-parse:: 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 :mod:`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 (:func:`venv_python`); the module-level :func:`windows` is the default. Attributes ---------- .. autoapisummary:: plesty.server.host.Report plesty.server.host._RETRY_DELAYS plesty.server.host._STOP_POLL Functions --------- .. autoapisummary:: plesty.server.host.windows plesty.server.host.venv_python plesty.server.host.executable plesty.server.host.split_args plesty.server.host._unquote plesty.server.host.link_or_copy plesty.server.host.remove_tree plesty.server.host.move_tree plesty.server.host._clear_readonly_and_retry plesty.server.host.spawn_kwargs plesty.server.host.echoing_kwargs plesty.server.host.waiter_kwargs plesty.server.host.interruptible_kwargs plesty.server.host.request_stop plesty.server.host.watch_for_stop plesty.server.host.stop_signal plesty.server.host.deliver_stop plesty.server.host.detach plesty.server.host.reap_launcher plesty.server.host.forward_signals plesty.server.host.pid_alive plesty.server.host._pid_alive_windows plesty.server.host.terminate plesty.server.host.kill_tree plesty.server.host._signal Module Contents --------------- .. py:data:: Report .. py:function:: windows(platform: str = sys.platform) -> bool Whether *platform* is Windows — the only one with console windows and ``Scripts/``. .. py:function:: venv_python(venv: pathlib.Path, platform: str = sys.platform) -> pathlib.Path The interpreter inside a virtual environment. Windows: ``/Scripts/python.exe``. POSIX: ``/bin/python``. .. py:function:: executable(name: str, platform: str = sys.platform) -> str The file name of an executable called *name*: ``name.exe`` on Windows, ``name`` on POSIX. .. py:function:: split_args(text: str, platform: str = sys.platform) -> list[str] Split a written command line into arguments the way *platform* reads it. POSIX: :func:`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. :param text: The command line as the fleet file or the console spells it. :param platform: The host to split for; defaults to this one. .. py:function:: _unquote(token: str) -> str Strip one surrounding pair of quotes, as POSIX splitting would. .. py:function:: link_or_copy(target: pathlib.Path, link: pathlib.Path) -> None 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. :param target: The existing file. :param link: The path to create. .. py:function:: remove_tree(path: pathlib.Path) -> None Delete a directory tree even when git left read-only files in it. Windows: :func:`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 (:exc:`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. .. py:data:: _RETRY_DELAYS :value: (0.1, 0.3, 0.6) .. py:function:: 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. .. py:function:: _clear_readonly_and_retry(func: collections.abc.Callable[[str], None], path: str, _exc: BaseException) -> None .. py:function:: spawn_kwargs(new_group: bool = False) -> dict[str, Any] :mod:`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. :param new_group: Detach the child from this process's signals. .. py:function:: echoing_kwargs() -> dict[str, Any] :mod:`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 :mod:`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. .. py:function:: waiter_kwargs() -> dict[str, Any] :mod:`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. .. py:function:: interruptible_kwargs() -> dict[str, Any] :mod:`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 :func:`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. .. py:data:: _STOP_POLL :value: 0.2 .. py:function:: 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 (:func:`watch_for_stop`) and creating it is the request. :param pid: The waiter that owns the command. :param stop_file: The file that waiter watches. .. py:function:: 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 :func:`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. :param stop_file: The file whose appearance means "stop". :param on_stop: Run once, on the watching thread, when it appears. .. py:function:: 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 :func:`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. .. py:function:: 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 :func:`interruptible_kwargs` buys. A child that has already gone is not an error. .. py:function:: 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 ``_exit``\ s. 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 :func:`reap_launcher`). :param run: The work of the detached process; its return value is its exit code. :param report: Receives the pid the caller should track. :returns: The exit code of the *calling* process. .. py:function:: 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 :func:`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. .. py:function:: 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. .. py:function:: 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 :func:`detach`), so there are no zombies to mistake for live processes. .. py:function:: _pid_alive_windows(pid: int) -> bool .. py:function:: terminate(pid: int) -> None Tell *pid* to end (``SIGTERM``). Windows has no equivalent; :func:`kill_tree` is next. .. py:function:: 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``. .. py:function:: _signal(pid: int, sig: int, group: bool = False) -> None Send *sig* to *pid* (or its process group), ignoring a vanished target.