Skip to content

Windows: headroom wrap claude fails with ConnectionRefused (Proactor accept loop closes listening socket on WinError 64) #1116

@prpande

Description

@prpande

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.pyBaseProactorEventLoop._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.pyrun_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions