Skip to content

React: send the mutations of all states over one websocket - #207

Open
benh wants to merge 2 commits into
react-websocket-when-mutators-boundfrom
react-single-mutations-websocket
Open

benh wants to merge 2 commits into
react-websocket-when-mutators-boundfrom
react-single-mutations-websocket

Conversation

@benh

@benh benh commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

This is on top of #202, and only the last two commits are new. It should be pointed at main once #202 is merged.

Problem

A browser sends the mutations of a state over a websocket that has the state in its path. With #202 that is a websocket for every state whose mutators are bound, rather than for every state, but browsers limit how many websockets can be open, to around 200 (255 in Chrome), so a page that mutates a lot of states can still run out of them. That is the case with TLS as well, see #202.

The first mutation of every state also has to wait for a websocket to be opened, which without RFC 8441 is a connection of its own.

Fix

Send the mutations of all states of a backend over one websocket.

The first commit adds that websocket to the backend, at /__/reboot/websocket/mutate. Every MutateRequest says what state it is a mutation of, and so does every MutateResponse:

Mutations of Are performed
The same state In the order that they were sent, as before
Different states Concurrently, so that a mutation only ever waits for mutations of the same state

Without a state in its path the websocket is routed to any server, by the routing filter that we already have. That is fine because performing a mutation that was sent over a websocket has always meant calling the server that is authoritative for the state, see react_mutate(). What is new is that the server being called is now often a different one.

The second commit makes the generated React code use it. A state can not tell the difference: what it sends its mutations over looks like a websocket of its own, which is why the generated code barely changes. Because the backend responds to the mutations of a state in order, a state can still tell what mutation a response is for by counting.

The websocket is opened once the first mutator is bound, or called, and closed once no state is used anymore.

Compatibility

  • Older frontend, newer backend: nothing changes, a websocket with a state in its path is handled as before.
  • Newer frontend, older backend: the backend assumes that the websocket is for a single state, fails to find the state in the path, and responds with a status that does not say what state it is for. At that point the frontend goes back to a websocket for every state, for that backend, and sends the mutations that did not get a response again.

Trade-offs

  • A mutation of a state that is on a different server than the websocket takes a call to that server, where before it was a call from a server to itself.
  • All mutations share one connection, so they are all interrupted if it gets closed, and all delayed if it loses a packet.
  • Reactive readers without TLS still have a websocket each. They could use this websocket as well, which would remove the limit without TLS, but that is not part of this.

Size

Code Comments Blank
Added, not counting tests 276 71 68
Removed 14 0 2

That is more than the 200 lines that I estimated, and it removes less, because the code for a websocket for a single state has to stay for older backends.

Testing

  • //tests/reboot/aio:react_websocket_mutate_test_py uses the websocket through Envoy the way a browser does, against a backend with 4 servers, for 8 states, so that some of them are on a different server than the websocket. It also covers a state whose ID is percent-encoded and mutations that fail. A second test case uses mutations that take as long as the test wants them to, to show which mutations wait for which, and that they are cancelled when the websocket is closed.
  • //tests/reboot/react/test_mutator_websocket:test now has 12 cases, including that responses for different states can arrive in any order, that the websocket is opened again if it gets closed, and going back to a websocket for every state.
  • //tests/reboot/react/..., including the Selenium tests with and without TLS, //tests/reboot/routing/..., //tests/reboot/routing_filter/..., //tests/reboot/aio/internals:health_servicer_tests_py and //tests/reboot:greeter_generation_react_tests_py pass.
  • To make sure the Selenium tests were not passing by going back to a websocket for every state, I ran test_mutations, test_idempotent_writer, test_websockets_connection and test_writer_abort_with_retrying_reactive_reader with that disabled, and they pass.

I have not tested this on a cluster, only against a local Envoy. What I would check there is that a websocket without a state in its path makes it through the Istio gateway to a pod, and a mutation of a state that is on a different pod.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WceTY5nXYmn6txE4haF8tw

benh and others added 2 commits September 30, 2026 00:21
A browser sends the mutations of a state over a websocket that has
the state in its path, which is what routes it to the server that is
authoritative for the state. That takes a websocket for every state
that is being mutated, and browsers limit how many websockets can be
open, to around 200 (255 in Chrome).

This adds a websocket without a state in its path, that can be used
for the mutations of any state: every `MutateRequest` says what
state it is a mutation of, and so does every `MutateResponse`.

It is routed to any server, which is fine because performing a
mutation that was sent over a websocket has always meant calling the
server that is authoritative for the state.

The mutations of a state are performed, and responded to, in the
order that they were sent, just like when they have a websocket of
their own. The mutations of different states are performed
concurrently, so that a mutation only ever waits for mutations of
the same state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WceTY5nXYmn6txE4haF8tw
Rather than a websocket for every state whose mutators are bound
there is now one for every backend, that is opened once the first
mutator is bound and closed once no state is used anymore.

A state can not tell the difference: what it sends its mutations
over looks like a websocket of its own, and because the backend
responds to the mutations of a state in order it can still tell what
mutation a response is for by counting.

A backend from before this responds without saying what state its
response is for, at which point we go back to a websocket for every
state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WceTY5nXYmn6txE4haF8tw

This branch has not been deployed

No deployments
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