fix: make serve lifecycle work on Windows (process + permission semantics) - #132
Merged
Merged
Conversation
…antics)
`amplifier-agent serve` (status / stop / restart + startup state persistence)
relied on three POSIX-only behaviors that break on native Windows:
1. write_state_file / _ensure_state_dir chmod to 0o700/0o600 and then STRICTLY
verify the mode via stat.S_IMODE, raising PermissionError on mismatch. NTFS
has no POSIX mode bits (stat reports 0o777), so that verification fails on
every Windows machine -- serve.json cannot be written, so `serve` cannot even
start. Now the strict chmod+verify is guarded to POSIX; on Windows the state
dir lives under %USERPROFILE%\.amplifier-agent (ACL-restricted to the user).
2. `serve stop --force`, the graceful-timeout escalation, and `serve restart`
all call os.kill(pid, signal.SIGKILL). signal.SIGKILL does not exist on
Windows -> AttributeError before os.kill runs. Added _hard_kill(): SIGKILL on
POSIX, SIGTERM (which maps to TerminateProcess) on Windows.
3. is_pid_alive() used os.kill(pid, 0). On Windows signal 0 is CTRL_C_EVENT
semantics, not an existence check -- it reports DEAD pids as alive (false
positive), so `serve status` shows a dead server as running and the stale
state file is never cleaned. Added _is_pid_alive_windows() using a
non-destructive Win32 handle probe (OpenProcess(SYNCHRONIZE) +
WaitForSingleObject(0)).
Also: `serve restart` used start_new_session=True to detach, a POSIX no-op on
Windows; now uses DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP there. Every POSIX
path is byte-unchanged (guarded on os.name == "nt").
Evidence -- teeth both ways, native Windows (Python 3.14.3), real functions
driven against a real dummy process:
BASELINE: is_pid_alive(dead_pid) -> True (false positive)
write_state_file -> PermissionError: Failed to set mode 0700
force-kill -> AttributeError: module 'signal' has no
attribute 'SIGKILL'
FIXED: is_pid_alive dead->False live->True
write_state_file -> wrote+read serve.json
force-kill(_hard_kill)-> process terminated
Note: an earlier audit predicted os.kill(pid,0) would TERMINATE the server; a
direct probe on Python 3.14.3 showed it does not (it is CTRL_C_EVENT, a no-op
for an unrelated process), so the real is_pid_alive defect is the false-positive
liveness result above, not a kill.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…helper extraction
Refinements to the Windows serve lifecycle fix:
- Extract duplicated chmod-then-verify permission logic into _enforce_mode()
helper. POSIX behavior is byte-for-byte identical (still fails closed with
PermissionError rather than writing api_key plaintext). Windows branch
skips unenforceable POSIX mode verification but preserves access control
intent (ACL on %USERPROFILE%\.amplifier-agent).
- Fix two real ctypes bugs in _is_pid_alive_windows():
* Declare explicit argtypes/restype. Without restype = wintypes.HANDLE,
ctypes defaults to c_int and truncates 64-bit HANDLE, leaking handles
and reading valid handles as falsy.
* Use ctypes.WinDLL with use_last_error=True and ctypes.get_last_error()
instead of kernel32.GetLastError(), which can return stale/cleared values.
- Correct and expand explanatory comments:
* Name the actual CPython mechanism: os.kill maps CTRL_C_EVENT/CTRL_BREAK_EVENT
to GenerateConsoleCtrlEvent; CTRL_C_EVENT == 0, so signal 0 delivers real
Ctrl+C, not a benign liveness probe.
* Document signal.SIGKILL access with hasattr (raises AttributeError on
Windows); SIGTERM is the correct fallback.
* Add _IS_WINDOWS constant with notes on three platform divergences.
- Update docs/spec/http-face.md to clarify 0600/0700 permission verification
as a guarantee on POSIX, best-effort on Windows (ACL is the boundary).
- Update CHANGELOG.md with Fixed entry under [Unreleased] for all three
Windows lifecycle fixes plus permission-verification change.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Salil Das (sadlilas)
force-pushed
the
fix/windows-serve-lifecycle
branch
from
August 18, 2026 23:07
6d29984 to
84872dd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
amplifier-agent serve(status / stop / restart + startup state persistence) relied on three POSIX-only behaviors that break on native Windows. Every POSIX path is behaviorally unchanged.1. State file can't be written →
servecan't start.write_state_file/_ensure_state_dirchmod to0o700/0o600and then strictly verify the mode viastat.S_IMODE, raisingPermissionErroron mismatch. NTFS has no POSIX mode bits (stat reports0o777/0o666), so that verification fails on every Windows machine —serve.jsoncannot be written at all. The strict verify is now POSIX-only; on Windows the protection boundary is the ACL on%USERPROFILE%\.amplifier-agent, which by default denies other standard users.chmodis still called there — only the POSIX-specific assertion is dropped.2.
serve stop --forcecrashes. The force path, the graceful-timeout escalation, andserve restartall calledos.kill(pid, signal.SIGKILL).signal.SIGKILLdoes not exist on Windows — accessing the attribute raisesAttributeErrorbeforeos.killever runs. Added_hard_kill():SIGKILLon POSIX,SIGTERMon Windows (CPython routes it toTerminateProcess, the correct unconditional-kill equivalent).3.
is_pid_alive()was unsafe and wrong on Windows. It usedos.kill(pid, 0). CPython'sposixmodule.cmapsCTRL_C_EVENT/CTRL_BREAK_EVENTtoGenerateConsoleCtrlEventand every other signal toTerminateProcess. Becausewincon.hdefinesCTRL_C_EVENT == 0, signal 0 takes the first branch: it delivers a real console Ctrl+C to the target's process group instead of testing for existence. Replaced with_is_pid_alive_windows()— a non-destructiveOpenProcess(SYNCHRONIZE)+WaitForSingleObject(handle, 0)probe that observes without signalling.Also:
serve restartusedstart_new_session=Trueto detach — POSIX-only; Windows now usesDETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.Correction to an earlier claim in this PR
An earlier revision of this description stated that signal 0 on Windows is "
CTRL_C_EVENT, a no-op for an unrelated process," and downgraded theis_pid_alivedefect to a mere false-positive liveness result. That conclusion was wrong, and it understated the bug.Signal 0 does map to
CTRL_C_EVENT, but CPython does not treat it as a no-op — it callsGenerateConsoleCtrlEvent(CTRL_C_EVENT, pid), which delivers a genuine Ctrl+C to every process sharing that console process group. It is benign only when the target happens not to share your console. Whenservewas started from the same terminal — the common case for a local dev server —serve statuscould interrupt the very server it was reporting on, and could hit unrelated processes in that group as collateral.So the defect is not just a bad reading; it is a status command with a side effect on the thing it observes. The handle-probe replacement is what makes the observation actually passive.
What changed since the original commit
The original fix (
6d710cf, Brian Krabach (@bkrabach)) is preserved as its own commit. A follow-up commit (84872dd) applies these refinements:_is_pid_alive_windows():argtypes/restypedeclared. Withoutrestype = wintypes.HANDLE, ctypes defaults toc_intand truncates the 64-bit HANDLE —CloseHandlewould then be handed a bogus value (leaking the real handle), and a valid handle could read as falsy.ctypes.WinDLL(..., use_last_error=True)+ctypes.get_last_error()instead ofkernel32.GetLastError(). The direct call reads the error state after ctypes' own bookkeeping and can return a stale or cleared value, which would misclassify an ACCESS_DENIED (process alive, not ours) as "dead."_enforce_mode()helper, shared by_ensure_state_dirandwrite_state_file. POSIX behavior is byte-for-byte identical.is_pid_aliveasserted signal 0 would "terminate" the process, which is the wrong branch ofposixmodule.c. Also documented thatsignal.SIGKILLmust be probed withhasattr(accessing it raises, so it cannot be caught).docs/spec/http-face.mdupdated. This is a spec-first repo and the permission contract is stated there; it now says the 0600/0700 enforcement is a guarantee on POSIX and a best effort on Windows, and why.CHANGELOG.mdentry added under[Unreleased].Verification
ruff check,ruff format --check, andpyright src/are clean.Behavioral verification against the real lifecycle functions on macOS (POSIX), driving real subprocesses:
Windows-native evidence from the original commit (Python 3.14.3, real dummy process):
Known limits
ubuntu-latestonly, so nothing guards these paths against future regression. The Windows evidence above is one machine, one Python (3.14.3). Worth a follow-up; out of scope here.