Summary
On Windows, headroom wrap claude makes Claude Code fail with:
API Error: Unable to connect to API (ConnectionRefused)
Regular claude (no wrap) works fine. The failure is intermittent at first, then "consistent" within a session — once it happens, every subsequent request fails until the proxy is restarted.
Root cause: the proxy's uvicorn server runs on the asyncio ProactorEventLoop (uvicorn's default for single-process runs on Windows), whose accept loop self-destructs on a benign Windows accept error, silently closing the listening socket.
Environment
- OS: Windows 11
- Python: 3.12 (CPython)
- headroom: 0.25.0
- uvicorn: 0.49.0
- Claude Code: claude-cli/2.1.181 (node v24.3.0, undici keep-alive client)
Root cause
uvicorn 0.49's asyncio_loop_factory returns asyncio.ProactorEventLoop for single-process Windows runs (uvicorn/loops/asyncio.py):
def asyncio_loop_factory(use_subprocess: bool = False):
if sys.platform == "win32" and not use_subprocess:
return asyncio.ProactorEventLoop # headroom proxy lands here
return asyncio.SelectorEventLoop
CPython's Proactor accept loop (asyncio/proactor_events.py → BaseProactorEventLoop._start_serving.loop) treats a benign AcceptEx completion error as fatal:
def loop(f=None):
try:
if f is not None:
conn, addr = f.result() # raises OSError(WinError 64) here
...
f = self._proactor.accept(sock) # re-arm — SKIPPED on exception
except OSError as exc:
if sock.fileno() != -1:
self.call_exception_handler({'message': 'Accept failed on a socket', ...})
sock.close() # <-- closes the LISTENING socket
else:
self._accept_futures[sock.fileno()] = f
f.add_done_callback(loop) # re-arm only happens on success
When a keep-alive client (Claude Code's undici pool) RSTs a connection mid-accept, AcceptEx completes with WinError 64 "The specified network name is no longer available". The except OSError branch closes the listening socket and never re-arms accept. The proxy is now bound but no longer accepting → every later connection gets ConnectionRefused. It is permanent for that proxy process, which is why the symptom goes from intermittent to "consistent."
This is why:
- regular
claude works (no local proxy);
headroom wrap claude -- -p "..." (print mode) appears to work — a single quick accept completes before any RST churn;
- interactive sessions consistently fail — undici opens/closes pooled connections, and one RST during accept kills the listener.
Evidence (from ~/.headroom/logs/proxy.log)
2026-06-18 11:30:49,632 - asyncio - ERROR - Task exception was never retrieved
future: <Task finished name='Task-8' coro=<IocpProactor.accept.<locals>.accept_coro() ...
exception=OSError(22, 'The specified network name is no longer available', None, 64, None)>
File "...asyncio\windows_events.py", line 555, in finish_accept
ov.getresult()
OSError: [WinError 64] The specified network name is no longer available
2026-06-18 11:30:49,633 - asyncio - ERROR - Accept failed on a socket
socket: <asyncio.TransportSocket fd=1400, family=2, type=1, proto=6, laddr=('127.0.0.1', 8787)>
OSError: [WinError 64] The specified network name is no longer available
The error is on the proxy's own listening socket (laddr=('127.0.0.1', 8787)).
Why SelectorEventLoop is immune
SelectorEventLoop._accept_connection (asyncio/selector_events.py) calls sock.accept() only after the listening socket is readable and handles errors per-connection — a transient OSError is re-raised for the loop to log and ignore, and the read handler for the listening socket stays registered, so it keeps accepting. (This is also why uvicorn's reload/multi-worker subprocess mode, which uses SelectorEventLoop, never hits this.)
Suggested fix
Force the SelectorEventLoop on Windows in headroom/proxy/server.py → run_server, before uvicorn.run(...):
uvicorn_kwargs: dict[str, Any] = {}
if sys.platform == "win32":
# CPython's Proactor accept loop closes the listening socket on a benign
# AcceptEx error (WinError 64 from a keep-alive client RST mid-accept) and
# never re-arms accept -> proxy stops listening -> ConnectionRefused. The
# SelectorEventLoop handles accept errors per-connection. The proxy uses no
# asyncio subprocesses/signal handlers, so the selector loop is safe here.
uvicorn_kwargs["loop"] = "asyncio:SelectorEventLoop"
Notes:
Config.get_loop_factory returns import_from_string(self.loop) directly for names not in LOOP_FACTORIES, so "asyncio:SelectorEventLoop" becomes the zero-arg loop factory. Verified working on uvicorn 0.49.
- Setting a global
WindowsSelectorEventLoopPolicy() does not work — uvicorn instantiates the loop class directly via its factory and ignores the policy.
- Safe only because the proxy uses no asyncio subprocesses or signal handlers (both unsupported on the Windows selector loop) — confirmed by grep across
headroom/proxy/.
Verification
With the patch applied locally:
- Confirmed the proxy runs on
_WindowsSelectorEventLoop.
- Stressed the proxy with 400 abrupt RST (
SO_LINGER 0) connections interleaved with /health checks: listener stayed up, /health returned 200 throughout, zero accept errors.
headroom wrap claude interactive session now works reliably.
Summary
On Windows,
headroom wrap claudemakes Claude Code fail with:Regular
claude(no wrap) works fine. The failure is intermittent at first, then "consistent" within a session — once it happens, every subsequent request fails until the proxy is restarted.Root cause: the proxy's uvicorn server runs on the asyncio ProactorEventLoop (uvicorn's default for single-process runs on Windows), whose accept loop self-destructs on a benign Windows accept error, silently closing the listening socket.
Environment
Root cause
uvicorn 0.49's
asyncio_loop_factoryreturnsasyncio.ProactorEventLoopfor single-process Windows runs (uvicorn/loops/asyncio.py):CPython's Proactor accept loop (
asyncio/proactor_events.py→BaseProactorEventLoop._start_serving.loop) treats a benignAcceptExcompletion error as fatal:When a keep-alive client (Claude Code's undici pool) RSTs a connection mid-accept,
AcceptExcompletes withWinError 64"The specified network name is no longer available". Theexcept OSErrorbranch closes the listening socket and never re-armsaccept. The proxy is now bound but no longer accepting → every later connection getsConnectionRefused. It is permanent for that proxy process, which is why the symptom goes from intermittent to "consistent."This is why:
claudeworks (no local proxy);headroom wrap claude -- -p "..."(print mode) appears to work — a single quick accept completes before any RST churn;Evidence (from
~/.headroom/logs/proxy.log)The error is on the proxy's own listening socket (
laddr=('127.0.0.1', 8787)).Why SelectorEventLoop is immune
SelectorEventLoop._accept_connection(asyncio/selector_events.py) callssock.accept()only after the listening socket is readable and handles errors per-connection — a transientOSErroris re-raised for the loop to log and ignore, and the read handler for the listening socket stays registered, so it keeps accepting. (This is also why uvicorn's reload/multi-worker subprocess mode, which uses SelectorEventLoop, never hits this.)Suggested fix
Force the SelectorEventLoop on Windows in
headroom/proxy/server.py→run_server, beforeuvicorn.run(...):Notes:
Config.get_loop_factoryreturnsimport_from_string(self.loop)directly for names not inLOOP_FACTORIES, so"asyncio:SelectorEventLoop"becomes the zero-arg loop factory. Verified working on uvicorn 0.49.WindowsSelectorEventLoopPolicy()does not work — uvicorn instantiates the loop class directly via its factory and ignores the policy.headroom/proxy/.Verification
With the patch applied locally:
_WindowsSelectorEventLoop.SO_LINGER0) connections interleaved with/healthchecks: listener stayed up,/healthreturned200throughout, zero accept errors.headroom wrap claudeinteractive session now works reliably.