---
title: "Porting POSIX process supervision to native Windows: the primitives that fail silently"
date: 2026-07-24
category: architecture-patterns
module: "skills (peer-job-runner.py, byte-duplicated across ce-doc-review, ce-code-review, ce-pov, ce-work, ce-plan, ce-brainstorm; ce-babysit-pr/scripts/pr-snapshot)"
problem_type: architecture_pattern
component: tooling
severity: high
applies_when:
  - "Porting a POSIX-only supervisor, daemon, or detached-job runner to native Windows Python"
  - "Replacing fork/setsid, killpg, or process groups with Windows equivalents"
  - "Reading or writing bytes through os.open on Windows (result artifacts, logs, caches)"
  - "Reimplementing a uid/mode-based file-ownership or privacy check on Windows"
  - "A worker's child processes survive teardown on Windows but not on macOS/Linux"
  - "Replacing fcntl.flock with a Windows file lock, or relying on a shared/read lock"
  - "Replacing a `ps`-based process-identity or start-time probe used as a PID-reuse guard"
  - "os.replace raises PermissionError on Windows but never on macOS/Linux"
tags: [windows, cross-platform, process-supervision, job-objects, taskkill, ctypes, file-ownership, o-binary, detached-jobs, file-locking, msvcrt, pid-reuse, atomic-rename]
---

# Porting POSIX process supervision to native Windows: the primitives that fail silently

## Context

`peer-job-runner.py` supervises detached cross-model peer jobs. It was POSIX-only:
`os.fork`/`os.setsid` to detach, `killpg` to reap the worker tree, `fstat().st_uid ==
geteuid()` plus mode `0700`/`0600` for ownership and privacy. Native Windows Python has
none of those, so the runner refused to start there
(`skills/ce-doc-review/scripts/peer-job-runner.py`, issue #1243).

The port's lesson is not that the primitives are missing — that part is obvious and fails
loudly. It is that **each POSIX primitive has a Windows analog that differs in a way that
fails silently**: no exception, no error, just a wrong result, a leaked process, or
corrupted bytes. Every item below was verified by running it on Windows 11 / Python 3.11,
and several contradicted the reasonable-sounding assumption that preceded them.

Related: [Detached job lifecycle for delegated work](../skill-design/detached-job-lifecycle-for-delegated-work.md)
describes the platform-independent lifecycle contract this runner implements; this doc
covers only the platform-primitive mapping underneath it.

## Guidance

### 1. Process-tree teardown: `killpg` has no direct analog

This is the one most likely to leak processes forever.

A POSIX process group **outlives its leader**. `killpg` targets the pgid, so a teardown
still sweeps surviving members after the leader exits — which is why the POSIX code
deliberately does *not* early-return on a dead leader.

`taskkill /T` walks parent→child **from a live parent**. Against an already-exited pid it
reports "process not found" and kills nothing, so a worker's grandchildren leak
permanently. Reproduced: leader exited, grandchild survived, `taskkill /T /F` on the
exited pid returned rc=128 and the grandchild kept running.

The real analog is a **Job Object**: job membership is inherited and does not depend on a
live parent, so `TerminateJobObject` reaches descendants of an exited leader.

**But a named job object is only reachable by a process holding a handle.** Windows
releases a named kernel object's *name* once the last handle closes, even while member
processes keep the object itself alive. Verified: after killing the process that created
the job, `OpenJobObjectW` on the same name fails with `ERROR_FILE_NOT_FOUND` (2) while a
member grandchild was still running.

So a cross-process teardown (a reap running after the supervisor died) needs a second
mechanism: a `CreateToolhelp32Snapshot` walk. That works **because Windows never reparents
orphans** — a dead pid still appears as `th32ParentProcessID` on its live children. This is
the exact inverse of POSIX, where orphans reparent to init and the parent pid is gone.

```
POSIX:   pgid survives leader        -> killpg sweeps orphans
         orphans reparent to init    -> parent-pid walk is useless

Windows: job survives leader         -> TerminateJobObject sweeps orphans
         but the job NAME dies with the last handle
         orphans keep a dangling ppid -> snapshot walk IS the fallback
```

Also: a graceful `taskkill /T` (no `/F`) sends `WM_CLOSE`, which a console-less process
cannot receive — it reports "can only be terminated forcefully". A TERM-then-grace-then-KILL
shape ported literally spends the whole grace window achieving nothing. Dropping it cut reap
latency from ~2800 ms to ~180 ms and closed a race where a reap caller and the supervisor
both wrote a terminal record.

### 2. `os.open` is text mode on Windows — silent corruption

Windows CPython opens `os.open()` descriptors in CRT **text mode** by default: writes
expand `\n` → `\r\n`, and reads **stop at the first `0x1A`** (Ctrl-Z, the DOS EOF). A
published result artifact containing a `0x1A` is silently truncated, and byte-cap
accounting drifts from `st_size`.

```python
O_BINARY = getattr(os, "O_BINARY", 0)   # 0 on POSIX -> no-op there
fd = os.open(path, os.O_RDONLY | O_NOFOLLOW | O_BINARY)
```

Apply it to every `os.open` that moves bytes. Note `O_NOFOLLOW` is also `0` on Windows, so
the no-follow protection quietly disappears — say so rather than leaving a docstring
claiming it.

### 3. Ownership: SIDs, and check the handle not the path

There is no uid and mode bits are not access control. The equivalent identity is the
current user's **SID**, and two details matter:

- **Check the opened HANDLE** (`GetSecurityInfo` via `msvcrt.get_osfhandle(fd)`), not the
  path. That preserves the TOCTOU property the POSIX `fstat`-by-fd check has; a path-based
  check silently reintroduces the race.
- **An elevated token has two identity SIDs** — the user SID and the token's *default
  owner* SID, which for an admin is `BUILTIN\Administrators`. Objects the process creates
  may be owned by either, so a strict equality check against the user SID alone rejects the
  process's own files under elevation.

Privacy is an ACL, not a mode. `icacls /inheritance:r` is the closest analog to `chmod
0700` but is **destructive and irreversible** in a way `chmod` is not — it permanently drops
inherited ACEs. Only re-ACL directories you created or own; do not "repair" a
user-supplied root the way an unconditional `chmod 0700` safely would.

### 4. Liveness, detach, and shell-outs

- `os.kill(pid, 0)` does not work for liveness on Windows. Worse, `os.kill(pid, SIGTERM)`
  maps to `TerminateProcess` — a probe that actually kills. Use `OpenProcess` +
  `WaitForSingleObject(h, 0)` (`WAIT_TIMEOUT` = alive).
- Detach is `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` re-invoking the script through an
  internal subcommand (there is no fork). `CREATE_BREAKAWAY_FROM_JOB` is the analog of
  `setsid`'s reparent-to-init — it lets the supervisor outlive a harness that runs inside a
  kill-on-close job — and must be retried without the flag when the job forbids breakaway.
- A child of a console-less parent gets a **new console** unless `CREATE_NO_WINDOW` is set,
  so every job flashes a window. `CREATE_NO_WINDOW` is mutually exclusive with
  `DETACHED_PROCESS`, so the detached supervisor uses one and its worker uses the other.
- `subprocess.run(..., check=False)` suppresses a **nonzero exit**, not a **missing
  executable** — an absent tool still raises `FileNotFoundError`. In a teardown path that
  turns an already-classified `done` job into `failed`. Wrap fire-and-forget shell-outs in
  `except OSError`.
- Resolve `icacls`/`taskkill` by absolute `%SystemRoot%\System32` path: `CreateProcess`
  searches the application and current directories before System32, so bare names are a
  binary-hijack surface.

### 5. State files: locking, identity, and rename

`ce-babysit-pr`'s `pr-snapshot` watcher hit the same family from a different angle (issue
#1280). It needed no fork or job object at all — only a lock, a start-time probe, and a
rename — and all three still differ.

**`fcntl.flock` -> `msvcrt.locking`, with three semantic changes.** It locks a byte range
**from the current file offset**, not the whole file, so the offset must be pinned (seek to
0, lock 1 byte) or an unlock at a different offset silently leaves the range held. It has
**no shared mode** — every holder is exclusive, so a POSIX `LOCK_SH` reader has to become a
writer, which is free only if critical sections are short. And its blocking mode `LK_LOCK`
**gives up after ~10 seconds** (1s × 10 retries) rather than waiting, so the faithful
translation of "block until the holder releases" is a retry loop around the *non-blocking*
`LK_NBLCK`, not `LK_LOCK`. Also: do not acquire the lock through a truncating `open(path,
"w")` — on Windows, truncating a file another process holds a byte-range lock on fails, so
lock acquisition becomes the race it was meant to prevent. Use `os.open(..., O_RDWR |
O_CREAT)`.

**That retry loop must discriminate on `errno`, not catch `OSError`.** `msvcrt.locking`
reports genuine contention as **`EACCES`** and real defects as `EBADF` (closed descriptor) or
`EINVAL` (bad range) — all `OSError`, so a bare `except OSError: sleep; continue` retries the
unfixable ones forever, at the poll interval, emitting nothing. Because the POSIX side is an
unbounded `flock` wait, the loop has no timeout to rescue it: the process simply never returns.
For a supervised watcher that is observably identical to "monitoring silently stopped" — the
class of failure the port existed to fix. Retry `EACCES`; re-raise everything else. Testing this
needs the primitive **patched**, not provoked with a real bad descriptor: the `os.lseek` that
pins the offset rejects a closed fd *before* the loop is entered, so the obvious test passes
against the broken loop too.

**`ps -o lstart=` -> `GetProcessTimes`.** A PID-reuse guard needs process *creation time*;
Windows recycles PIDs aggressively, so this matters more there, not less. `OpenProcess`
with `PROCESS_QUERY_LIMITED_INFORMATION` plus `GetProcessTimes` is the analog, with
`QueryFullProcessImageNameW` standing in for `command=`. Two asymmetries: Windows keeps an
exited process openable while any handle to it remains, so a "dead" pid can still yield an
identity (harmless if you compare identities rather than test existence); and
`QueryFullProcessImageNameW` fails on an exited process, so the image half goes empty while
the creation-time half stays valid. Guard the POSIX branch too — a missing `ps` raises
`FileNotFoundError`, it does not return nonzero.

**`os.replace` is not unconditional.** POSIX rename succeeds over an open destination;
Windows refuses with `PermissionError` while any other handle to the destination is open.
Any unlocked best-effort reader — or a virus scanner — turns a routine concurrent read into
a *crashed writer*, and it is timing-dependent, so it passes every local test. Retry the
rename briefly instead of treating the sharing violation as failure.

Note that a lock file used only for locking moves no bytes, so item 2's `O_BINARY` does not
apply to it. Apply that rule by whether the descriptor carries data, not by platform.

## Why This Matters

Every failure above is silent. Nothing raises; the job reports `done`, the artifact looks
plausible, the reap returns 0 — while a peer process leaks forever or a result is truncated
mid-JSON. A port that "passes the smoke test" can be wrong in all of these ways at once,
which is why the checklist matters more than any single fix.

The teardown item is the expensive one: it took an adversarial review plus two live
experiments to establish that the obvious mechanism (`taskkill /T`) cannot work for the
orphan case, that the correct mechanism (Job Object) is unreachable cross-process, and that
the fallback is sound only because of a Windows/POSIX asymmetry in orphan reparenting. That
is a day of rediscovery for the next person.

## When to Apply

Reach for this when porting any POSIX-shaped supervisor, daemon, or job runner to native
Windows — and specifically when a POSIX invariant is load-bearing. The tell is a comment
explaining *why* a POSIX call is safe ("the pgid outlives its leader, so this still
sweeps"). That reasoning is exactly what does not port, and a literal translation will
compile, run, pass a happy-path test, and leak.

Do not assume the platform branch is only about missing APIs. Ask, for each guarantee the
POSIX code relies on: *what kernel object provides this, and what is its lifetime on
Windows?*

## Examples

Teardown that looks correct and leaks:

```python
# WRONG on Windows: cannot reach a dead leader's children
def kill_tree(root_pid, grace):
    subprocess.run(["taskkill", "/T", "/F", "/PID", str(root_pid)], check=False)
```

Teardown that actually sweeps:

```python
alive = _win_pid_alive(root_pid)
if job_name and _win_terminate_job(job_name):   # primary: reaches exited-leader children
    return alive
for pid in _win_descendants_deepest_first(root_pid):  # fallback: dangling-ppid snapshot walk
    _win_terminate_pid(pid)
if alive:
    _win_terminate_pid(root_pid)
return alive
```

Keeping the POSIX path provably untouched is worth more than symmetry. Every Windows branch
sits behind `if IS_WINDOWS:`; shared helpers were changed only where the POSIX result is
identical by inspection (e.g. a reap-request helper that reduces to the original flag check
when `IS_WINDOWS` is false). On a Windows box the POSIX suite cannot run, so byte-identical
POSIX code is the only available regression guarantee.

Cross-platform tests are possible but narrower than they look: patching `IS_WINDOWS` on
Linux raises `NameError` for most Windows branches, because the `_win_*` helpers and the
`ctypes`/`msvcrt` imports only exist when the module is imported under
`sys.platform == "win32"`. Only branches that return before touching a `_win_*` helper —
platform selection, marker-file logic, and the `O_BINARY` byte round-trip — are testable on
the existing CI.

## See Also

- Interpreter resolution is a separate Windows trap in the same family: bare `python3`
  resolves to a Microsoft Store stub that satisfies `command -v` but exits without running
  Python. Tracked in issue #1247.
