Skip to content

Fix WinHttpRequest destructor hang when the request context is never bound - #7353

Draft
Dan Cristoloveanu (dcristoloveanu) wants to merge 1 commit into
Azure:mainfrom
dcristoloveanu:dcristo/winhttp-request-context-hang
Draft

Fix WinHttpRequest destructor hang when the request context is never bound#7353
Dan Cristoloveanu (dcristoloveanu) wants to merge 1 commit into
Azure:mainfrom
dcristoloveanu:dcristo/winhttp-request-context-hang

Conversation

@dcristoloveanu

Copy link
Copy Markdown
Member

Fixes #7352

Problem

WinHttpRequest::~WinHttpRequest() closes the request handle and then waits for
WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING before returning. That barrier is deliberate and
necessary: WinHTTP is used in async mode (WINHTTP_FLAG_ASYNC), the callback context is a raw
pointer to the WinHttpAction owned by the request, and the callback also dereferences
m_httpRequest. Returning before HANDLE_CLOSING would let a WinHTTP worker thread touch freed
memory.

However, WinHttpAction::StatusCallback() discards every notification that arrives with
dwContext == 0, and the context is bound to the handle only by WinHttpSendRequest() — while
the status callback is registered much earlier, at the end of the constructor. Any request
destroyed in between (for example when an exception is thrown while preparing headers or querying
the body stream length) therefore closes its handle, receives HANDLE_CLOSING with no context, has
it dropped, and blocks in WaitForAction forever. The wait uses a default-constructed
Azure::Core::Context, so ThrowIfCancelled() never fires and the poll loop spins indefinitely —
the thread is lost for the lifetime of the process.

CompleteActionWithError() intentionally does not signal while waiting for HANDLE_CLOSING, so
the REQUEST_ERROR / ERROR_WINHTTP_OPERATION_CANCELLED notification that WinHTTP raises when
closing a handle with a pending operation does not release the waiter either.

This is the hang that remains in the window #6637 addressed for the FailFast case; that PR's own
comment already observes that "the initiateAction call is a call to WinHttpSendRequest which
establishes the SendContext".

Fix

Bind the context to the request handle in the constructor with
WinHttpSetOption(WINHTTP_OPTION_CONTEXT_VALUE, ...), immediately before registering the status
callback. HANDLE_CLOSING is then always delivered with a valid context, so the destructor's
barrier always completes. WinHttpSendRequest() continues to pass the same value, which is
idempotent.

The barrier itself is unchanged — the fix makes it satisfiable rather than removing it.

Alternatives considered

  • Track whether the context was bound and skip the wait when it was not. Safe for the same
    reason the bug exists (with no context every callback is already dropped, so there is nothing to
    synchronize against), but smaller in scope and leaves HANDLE_CLOSING unobservable.
  • Give the destructor's wait a deadline. Rejected: abandoning the wait permits a WinHTTP worker
    thread to invoke the callback after WinHttpAction/WinHttpRequest are destroyed, converting a
    hang into a use-after-free.
  • Signal the event from CompleteActionWithError() during close. Rejected for the same reason:
    it would release the waiter before HANDLE_CLOSING, which is precisely what the barrier prevents.

Test

sdk/core/azure-core/test/ut/win_http_transport_test.cpp adds
WinHttpTransport.RequestThatFailsDuringSetupDoesNotHang.

SendRequest() calls request.GetBodyStream()->Length() before WinHttpSendRequest(), and
BodyStream::Length() is not noexcept, so a body stream that throws from Length() enters the
window deterministically. The test needs no network: WinHttpOpen(), WinHttpConnect() and
WinHttpOpenRequest() only allocate handles, and the request fails before anything is sent.

The work runs on a detached thread guarded by a 30 s future wait, so a regression fails the test
instead of hanging the CI run (a blocked destructor cannot be joined).

Verified against the vendored 1.16.3 sources in a downstream consumer: an equivalent test hangs
and fails on the 30 s timeout without the product change, and passes in 0.76 s with it. A leak
checker also reported the abandoned WinHttpRequest from
WinHttpTransportImpl::CreateRequestHandle() before the fix, and no leaks after.

Pull Request Checklist

  • C++ Guidelines
  • Doxygen docs — n/a, no public API change
  • Unit tests
  • No unwanted commits/changes
  • Descriptive title/description
    • PR is single purpose
    • Related issue listed
  • Comments in source
  • No typos
  • Update changelog
  • Not work-in-progress — opened as a draft for maintainer feedback on the approach
  • External references or docs updated
  • Self review of PR done
  • Any breaking changes? — none; behavior only changes in the case that previously hung

…bound

~WinHttpRequest closes the request handle and then waits for
WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING so that no WinHTTP worker thread can dereference the
WinHttpAction after it is freed. WinHttpAction::StatusCallback discards every notification that
arrives with dwContext == 0, and the context is associated with the handle only by
WinHttpSendRequest, while the status callback is registered at the end of the constructor. A
request destroyed between those two points therefore waits for a notification that is discarded,
on a default-constructed Azure::Core::Context that is never cancelled, and the calling thread is
lost for the lifetime of the process.

Bind the context with WinHttpSetOption(WINHTTP_OPTION_CONTEXT_VALUE) in the constructor so
HANDLE_CLOSING is always delivered and the existing barrier always completes. WinHttpSendRequest
continues to pass the same value, which is idempotent.

Adding a timeout to the destructor's wait would not be a valid fix: abandoning the barrier lets a
WinHTTP worker thread invoke the callback after the objects are destroyed, turning a hang into a
use-after-free.

Adds a regression test that constructs a request whose body stream throws from Length(), which
enters the window without requiring any network traffic.

Fixes Azure#7352
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 2 pipeline(s).
8 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] WinHttpRequest destructor blocks forever when the request is destroyed before WinHttpSendRequest binds the context

1 participant