fix: warn when feedback arrives with no step state (and a falsified hypothesis) - #4
Merged
Merged
Conversation
A clean-machine run of the documented quickstart dropped its connection
twice in six minutes:
ConnectionClosedError: sent 1011 (internal error) keepalive ping
timeout; no close frame received
The cause is not the network. `websockets` defaults to a 20 s ping with a
20 s timeout, and a learn step is CPU-bound Python that holds the GIL for
longer than that on a slow machine. The event loop never gets to read the
pong, so the server closes a connection whose client is perfectly healthy.
The disconnect itself is survivable - the env client reconnects. What is
not survivable is what the reconnect loses. `prev_node_map`,
`step_state_map`, `terminated_map`, `truncated_map` and `last_obs_map` are
local to `_handler`, so a new connection starts with all five empty. The
first feedback after a reconnect then reaches the algorithm with
`last_obs={}`, `step_state=None` and `terminated=False`, and is stored as a
transition. No exception, no warning: a corrupt transition in the buffer,
and more of them the slower the machine. It only triggers under load, which
is why it has never shown up in a fast-machine run.
Two changes:
* `ping_interval=None` on `serve`. The protocol already has its own
liveness mechanism - a server that waits FEEDBACK_WAIT_TIMEOUT for
feedback and gives up - so the ping adds no detection the server did not
already have, and costs this.
* a warning when feedback arrives for an environment with no step state.
Every environment that reaches feedback was given an action first on the
same connection, so a miss means exactly one thing, and it should say so
rather than quietly feed the learner an empty observation.
Regression test asserts both `ping_interval` and `max_size` are None at the
`serve` call, verified to fail when the keyword is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 11, 2026
The comment justified the change and stopped there, which made it read as free. It is not: the feedback timeout bounds the wait between an action and its feedback, and the recv that waits for the next infer is unbounded, so a peer that dies without closing its socket now parks a coroutine until TCP gives up rather than being noticed in 20 s. Also records why a long ping timeout was rejected instead of chosen, since that is the obvious next question and the answer - the value would have to exceed a learn step of 16 x ceil(buffer_size / 1024) gradient steps on unknown hardware - is not obvious. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier commits on this branch turned the server's keepalive off, on the reasoning that a CPU-bound learn step holds the event loop past the 20 s ping timeout and kills a healthy connection. That was measured and it is not what happens. LocalTrainingBackend runs learn through asyncio.to_thread, so the loop is free throughout, and five learn steps of 177-190 s each - nine times the timeout - produced no close at all. Two runs, default and inflated epoch counts, in experiments/e8-keepalive-hypothesis. The incident that started this was a machine suspending for one hour and fifty-three minutes mid-run, visible as a gap in a log that is otherwise written every thirty seconds. The timeout that fired came from the client library, not the server's, so turning the server's keepalive off would not have prevented it either. So ping_interval goes back to the library default and the test that pinned it is gone. What stays is the warning when feedback arrives for an environment with no step state, which was read out of the handler rather than inferred from the incident, and is true whatever caused the reconnect. The test file keeps the two assertions that are still real - compression and max_size - and records the falsified hypothesis so nobody re-derives it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes up the measurement that refuted this branch's original premise, with both runs, their logs, and the log from the incident that started it. Two refutations. At default settings one of three learn steps ran about 24 seconds, already past the 20 second ping timeout, and closed nothing. With the epoch count raised to 400 the learn step ran 177 to 190 seconds, nine times the timeout, five cycles running, and closed nothing. The cause is a line in training_backend.py: learn goes through asyncio.to_thread, so the event loop is free the whole time. The incident itself was a machine suspending for one hour fifty three minutes, visible as a hole in a log written every thirty seconds, and the timeout that fired belonged to the client library rather than the server. Also indexes E8 in the experiments README, under the questions and under "What was measured badly, and corrected", and aligns the E2 row there with the authorship caveat that E2's own findings now carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What ships
One warning, in
_handler:Every environment that reaches
feedbackwas handed anactionfirst, on the same connection, and that is what fills the map. So a miss means exactly one thing: the connection was replaced mid-run, and the five per-environment maps —prev_node_map,step_state_map,terminated_map,truncated_map,last_obs_map— went with it. The transition then gets built from an empty observation and stored.That used to happen in total silence. The real fix is on the client side, PlugRL/plugrl-env-client#5, which now drops a held feedback instead of resending it across a reconnect. This is the server saying so when it happens anyway.
Plus
experiments/e8-keepalive-hypothesis/, below.The hypothesis that did not survive
A clean-machine run of the documented quickstart dropped its connection with
sent 1011 (internal error) keepalive ping timeout.websocketspings every 20 s with a 20 s timeout; a learn step is CPU-bound; therefore the server misses its pong and kills a healthy connection. Plausible at every step, and wrong.E8 measured it. Gradient steps per learn are
num_updates_per_batch * ceil(buffer_size / batch_size), so raising the epoch count makes the learn long while keeping the fill short:Nine times the ping timeout, five times over, and nothing closed. The reason is one line in
server/training_backend.py:Learning already runs off the event loop. The hypothesis was not merely unproven — it was contradicted by code sitting there to be read.
What actually happened
The client's own log, either side of the failure:
Those timing lines are emitted every 30 s without a break from 12:24:14. Then 1 h 53 min of nothing. A slow process still logs; this one was frozen. The machine suspended with the run open.
Two details the first diagnosis had backwards: the traceback is in
websockets/sync/connection.py, the client library, sosent 1011means the client gave up on the server, not the reverse; and the server log for that run contains no keepalive line at all. Turning the server's keepalive off would not have prevented this incident.Why the rest still stands
The state-loss path was read out of the handler and reproduced in a unit test. It was never inferred from the incident — only the cause was, and the cause is the part that was wrong. Any reconnect costs the same state: a suspend, a flaky link, a server restart.
E6 was checked for contamination and is clean: one connection per seed, zero reconnects across all three.
Verification
ruff check --exclude third_party .clean, format clean.tests/test_serve_options.pykeeps only the assertions that are still true —compressionandmax_sizeareNone— and records the falsified hypothesis in its docstring so nobody re-derives it.Specification updated in PlugRL/plugrl-protocol#3, which lost its keepalive corollary for the same reason.
🤖 Generated with Claude Code