Skip to content

Don't block HTTP actors on user callbacks - #3059

Closed
plajjan wants to merge 1 commit into
mainfrom
http-nonblocking-callbacks
Closed

Don't block HTTP actors on user callbacks#3059
plajjan wants to merge 1 commit into
mainfrom
http-nonblocking-callbacks

Conversation

@plajjan

@plajjan plajjan commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

http.Client invoked user callbacks with an explicit await: _on_conn_connect did "await async on_connect(self)" and _on_con_receive did "await async cb(self, r)" for every parsed response. Awaiting parks the client actor until the user's callback activation has completed, so the client processes none of its own messages in the meantime. A callback that calls back into the client and waits for the result, for example by assigning the result of a post call, deadlocks both actors: the callback actor waits for the client to process its call, while the client waits for the callback activation to complete. Both actors then stop processing all further messages, including timers. The same pattern existed on the server side: http.Listener and http.TLSListener awaited the user's on_accept callback, and http.Server and http.TLSServer called the user's request handler in value position, which likewise waits for the handler activation to complete. A handler that awaits the respond callback, which is an action of the server actor, deadlocked the same way.

The awaits bought no ordering that the runtime does not already provide. Messages sent from one actor to another are delivered in the order they were sent, so invoking the callbacks as plain asynchronous calls still delivers each response to its request's callback in request order. All user callbacks are now invoked without awaiting them, and the actors keep serving their connections while user callbacks run. The listener also no longer stalls accepting further connections while one accept callback runs.

Two pieces of server machinery silently relied on the blocking behavior and are reworked to stand on their own. First, Server.cb_install parsed a request that had arrived before the handler was installed and then dropped it. This was masked by the listener awaiting on_accept before installing the receive callback on the accepted connection, which made buffered early data impossible as long as on_accept installed the handler synchronously; an on_accept that installs the handler later, for example after fetching configuration, would silently lose the first request. cb_install now dispatches requests buffered while no handler was installed, and request parsing loops, so several requests pipelined into a single TCP segment are all dispatched rather than only the first. Second, the respond closure handed to the request handler compared the live request counter instead of the id of its own request, which was only correct while the server was parked during the handler so that at most one request was in flight; a response to any concurrent request was silently dropped, with buffering left as a TODO in the code. respond now captures its request id, and a response to a later request is buffered until the responses to all earlier requests have been sent, so responses go out on the connection in request order even when handlers respond out of order.

The http.Client, http.Server and the listeners invoked user callbacks
in ways that parked the invoking actor until the user's callback
activation had completed: the client did "await async on_connect(self)"
and "await async cb(self, r)", the listeners awaited on_accept, and the
servers called the request handler in value position. A parked actor
processes no messages, so a callback that in turn called back into the
parked actor and waited for the result would deadlock both actors: the
callback actor waits for the parked actor to process its call, while
the parked actor waits for the callback activation to complete. Both
then stop processing all further messages, including timers.

The awaits bought no ordering that the runtime does not already
provide: messages from one actor to another are delivered in the order
they were sent, so invoking the callbacks as plain asynchronous calls
still delivers responses to each callback actor in request order. All
user callbacks are now invoked without awaiting them, and the actors
keep serving their connections while user callbacks run.

Two pieces of server machinery silently relied on the blocking
behavior and are reworked to stand on their own:

- Server.cb_install parsed a request that had been buffered before the
  handler was installed and then dropped it. This was masked by the
  listener awaiting on_accept before installing the receive callback,
  which made early buffered data impossible as long as on_accept
  installed the handler synchronously. cb_install now dispatches
  buffered requests, and request parsing loops so that several
  requests pipelined into one TCP segment are all dispatched rather
  than only the first.

- The respond closure handed to the request handler compared the live
  request counter instead of the id of its own request, which was only
  correct while the server was parked during the handler so that at
  most one request was in flight; a response to any concurrent request
  was silently dropped, with buffering left as a TODO. respond now
  captures its request id, and a response to a later request is
  buffered until the responses to all earlier requests have been sent,
  so responses go out on the connection in request order even when
  handlers respond out of order.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12b653309d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread base/src/http.act
send_response(status_code, headers, body)
response_count += 1
# Send buffered responses that are next in line
while not close_connection and response_count + 1 in outstanding_responses:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Track connection closure per pipelined request

When multiple requests arrive in one buffer and a later request specifies Connection: close, _process_buf sets the shared close_connection flag before any asynchronous handler can respond. The first response then closes the socket, and this condition also prevents buffered later responses from being flushed, so valid pipelined requests can lose every response after the first. Capture the close policy per request and close only after that request's ordered response has been written; the mirrored TLS path has the same issue.

Useful? React with 👍 / 👎.

@plajjan plajjan closed this Aug 12, 2026
@plajjan
plajjan deleted the http-nonblocking-callbacks branch August 12, 2026 20:51
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.

1 participant