Skip to content

pipe: make consumed listener close authoritative - #369

Open
Devon Krisman (dkrisman) wants to merge 1 commit into
microsoft:mainfrom
dkrisman:fix/issue-85-listener-close
Open

pipe: make consumed listener close authoritative#369
Devon Krisman (dkrisman) wants to merge 1 commit into
microsoft:mainfrom
dkrisman:fix/issue-85-listener-close

Conversation

@dkrisman

Copy link
Copy Markdown

Summary

Fixes #85. Once makeConnectedServerPipe consumes the listener's close signal,
treat closure as authoritative and always return ErrPipeListenerClosed,
instead of reusing a raced ConnectNamedPipe result.

Problem

win32PipeListener.Close() sends a single value on l.closeCh. When
makeConnectedServerPipe receives it, the current code closes the pending pipe
handle and then reuses the connect goroutine's result, converting only nil
and ErrFileClosed to ErrPipeListenerClosed.

Closing the handle does not guarantee the connect result is one of those two. A
client that attaches in the window before ConnectNamedPipe is called makes it
return synchronously: ERROR_PIPE_CONNECTED (which connectPipe normalizes to
nil) if the client is still attached, or ERROR_NO_DATA if it already
disconnected. In the ERROR_NO_DATA case the error is passed through,
listenerRoutine's retry loop calls makeConnectedServerPipe again, and the
one close signal has already been spent: the new connect pends forever and
Close() waits forever on l.doneCh.

Solution

Once makeConnectedServerPipe receives from l.closeCh, that close is
authoritative. It still closes the pending pipe handle and drains the connect
result so the goroutine completes, but always returns
ErrPipeListenerClosed, which stops listenerRoutine.

Two regression tests:

  • TestListenerCloseOverridesRacedConnect drives makeConnectedServerPipe
    directly: a raw client attaches and disconnects in the pre-connect window
    (reliably producing the synchronous ERROR_NO_DATA), then a close signal
    races the connect result. It fails within a few iterations on the current
    code and passes with this change.
  • TestListenerCloseRacesPendingConnect races Accept, a dialing client, and
    Listener.Close end to end, with bounded timeouts so a regression fails
    instead of hanging the test process.

Testing

  • go test -race -count=100 -run 'Test.*Listener.*Close' .
  • go test -race .
  • golangci-lint run --config=.golangci.yml clean (v1.64.8)
  • go generate ./... with no generated diff
  • Verified TestListenerCloseOverridesRacedConnect fails on unpatched main
    with expected ErrPipeListenerClosed, got The pipe is being closed.

Related work

PR #324 also touches listener shutdown synchronization more broadly (PR #364
did as well before it was closed). This patch is limited to the specific
lost-close race from #85: the goroutine that consumes closeCh must not let a
raced connect result override listener closure.

Once makeConnectedServerPipe receives from closeCh, always return
ErrPipeListenerClosed. ConnectNamedPipe can race the handle close and
report ERROR_PIPE_CONNECTED (normalized to nil by connectPipe) or
ERROR_NO_DATA instead of ErrFileClosed. The old close branch reused
that raced result, so listenerRoutine kept running after the single
close signal was spent and win32PipeListener.Close waited forever.

Add a regression test that races a client connect against the close
signal, and a stress test racing Accept with Close.

Fixes microsoft#85

Signed-off-by: Devon Krisman <winio@krisman.dev>
@dkrisman
Devon Krisman (dkrisman) requested a review from a team as a code owner August 3, 2026 01:43
@dkrisman

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Trustin Lee (trustin) added a commit to leapmux/leapmux that referenced this pull request Aug 7, 2026
…366)

## Motivation

The Windows CI job timed out after 10 minutes with the Hub parked in
shutdown

(https://github.com/leapmux/leapmux/actions/runs/31161153380/job/92811556092).
The goroutine dump names the mechanism.

go-winio's `win32PipeListener.Close` sends ONE token on the listener's
private
`closeCh` and then waits for `doneCh`. An accept that is already in
flight takes
that token first, inside `makeConnectedServerPipe`, and that path maps
the
aborted connect to `ErrPipeListenerClosed` only when the connect
returned `nil`
or `ErrFileClosed`. Any other result -- the `ERROR_NO_DATA` that
`locallisten.WaitReady`'s connect-and-close probe leaves behind, for
example --
goes back to the listener routine, which reads
`closed = err == ErrPipeListenerClosed` as false and keeps running.
`doneCh`
stays open and the close never returns. That is
microsoft/go-winio#85, open since 2018 and
unfixed in
v0.6.2, the newest release; its fix,
microsoft/go-winio#369, is open and unmerged.

The hang does not stay inside winio. `http.Server.Shutdown` closes its
listeners
while it holds `srv.mu`, so `Serve` cannot return, no connection can
finish, and
`hub.Serve` waits forever for a listener result. Solo's `join` never
sees
`hubDone`. `Shutdown`'s own context deadline cannot save it, because it
blocks
before it reads the context. The desktop sidecar runs the same path, so
a real
shutdown can wedge the same way -- this is not only a test problem.

The wedge was seen before and worked around by deleting coverage:
`TestDialer_NpipeAcceptsFullNTPath` carried a comment describing this
exact
goroutine state and had been cut down to a parser-only check.

## Modifications

- `npipeListener.Close` retries the winio close every 250 ms, up to 8
times. A
later token stops the listener routine from either state it parks in:
its own
select receives the token, or `makeConnectedServerPipe` receives it and
aborts
a connect that no client satisfied, which does map to
`ErrPipeListenerClosed`.
- The retry is bounded and reports `errCloseStuck` rather than wait a
listener
  out. Returning is the point: a `Close` that blocks holds `srv.mu` and
  deadlocks the whole `http.Server`. The bound also covers
microsoft/go-winio#357, a hang no extra token
can
release. The cost of giving up is one pipe name that stays taken, which
the
  error names.
- `newNpipeListener` becomes the one construction site, so a listener a
test
  builds carries the same close policy as a production one.
- `TestDialer_NpipeAcceptsFullNTPath` gets its round trip back.
- New tests cover the retry, the bounded give-up, the error the listener
reports, a double close (the Hub closes its local listener twice),
concurrent
  closes, and the race against a real pipe.

No breaking change and no deprecation. The behavior is Windows-only; the
Unix
listener is untouched.

## Result

- A Hub or desktop sidecar shutdown on Windows no longer deadlocks when
a
listener close races an accept that is in flight. `Shutdown` returns,
`Serve`
  returns, and solo's `Stop` completes.
- A listener that cannot be closed at all is reported to the caller
instead of
blocking it forever, so the failure reads as a named error in the
teardown
  path rather than as a 10-minute CI timeout with no message.
@PLN

PLN commented Sep 6, 2026

Copy link
Copy Markdown

Independent downstream confirmation on Windows with go-winio v0.6.2: a watchdog/restart race test hung with Close waiting on doneCh and listenerRoutine back in its top-level select. This supports the consumed-close failure described in #85, rather than proving an outstanding overlapped operation never completes.

We tested the equivalent change locally: after consuming closeCh, close the pending pipe, drain the connect result, and unconditionally return ErrPipeListenerClosed. With that patch, 100 race-enabled watchdog/restart repetitions passed, followed by our full downstream race suite and go vet. The unpatched focused repetition reproduced the hang.

We intend to carry a pinned local patch while this is reviewed. This is independent downstream validation of the production change, not a claim that we have run this PR's added tests or that it resolves every shutdown failure described in #357. Thanks for submitting the focused fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Race condition in makeConnectedServerPipe causes win32PipeListener.Close() deadlock

2 participants