Skip to content

fix: warn when feedback arrives with no step state (and a falsified hypothesis) - #4

Merged
tactino merged 4 commits into
mainfrom
fix/keepalive-drops-state
Sep 11, 2026
Merged

tactino merged 4 commits into
mainfrom
fix/keepalive-drops-state

Conversation

@tactino

@tactino tactino commented Sep 11, 2026

Copy link
Copy Markdown
Member

Read the last section first. This branch started as a keepalive fix. Measurement killed that hypothesis, and the keepalive change is no longer in it. The commit history keeps the wrong version rather than hiding it.

What ships

One warning, in _handler:

step_state = step_state_map.get(eid, None)
if step_state is None:
    logger.warning(f"Feedback for env {eid} arrived with no step state. ...")

Every environment that reaches feedback was handed an action first, 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. websockets pings 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:

run gradient steps per learn learns learn duration keepalive timeouts
default epochs 256 3 7–11 s 0
400 epochs 1600 5 177–190 s 0

Nine times the ping timeout, five times over, and nothing closed. The reason is one line in server/training_backend.py:

step, log_dict = await asyncio.to_thread(self._algorithm.learn)

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:

12:28:16.237 | INFO    | Intermediate rollout timing summary: env_steps=5353 ...
14:21:53.007 | WARNING | Connection closed during INFER/ACTION exchange ...

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, so sent 1011 means 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

  • 54 passed. ruff check --exclude third_party . clean, format clean.
  • tests/test_serve_options.py keeps only the assertions that are still true — compression and max_size are None — 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

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>
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>
@tactino tactino changed the title fix: keepalive pings silently corrupt training data on slow machines fix: warn when feedback arrives with no step state (and a falsified hypothesis) Sep 11, 2026
Gotham-Zolio and others added 2 commits September 11, 2026 15:22
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>
@tactino
tactino merged commit c921669 into main Sep 11, 2026
3 checks passed
@tactino
tactino deleted the fix/keepalive-drops-state branch September 11, 2026 19:48
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