Skip to content

fix(sandboxed-gym): frame rollout responses and honour an offline environment - #1929

Merged
SandyChapman merged 2 commits into
mainfrom
aalgo-583-backport-rl-host-fixes/schapman
Sep 11, 2026
Merged

SandyChapman merged 2 commits into
mainfrom
aalgo-583-backport-rl-host-fixes/schapman

Conversation

@SandyChapman

@SandyChapman SandyChapman commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Two fixes landed on NeMo-RL's fork of this code while the branch that deletes that fork sat unmerged. Neither exists here, so completing that consolidation would drop both — the exact drift consolidating is meant to end. This ports them.

Unblocks soluwalana/RL#25, which currently cannot rebase: three of its five conflicts are files carrying these fixes.

Upstream, for side-by-side review

These are ports, not cherry-picks — runtime/gym_host_runtime.py here has diverged from the fork (it carries SG_EXAMPLE_ID and the model-call capture, which the fork does not). Compare against:

This change Fork PR Commit
Response framing soluwalana/RL#23fix: frame the rollout response so a proxy cannot truncate it eab835b1
environment_offlineUV_OFFLINE soluwalana/RL#22chore: add UV_OFFLINE for wheels-v1 envs 5e4346e9, 19720f30
environment_offline through to the actors soluwalana/RL#24fix: pass environment_offline through to the Gym actors 9932dc8a

Two differences a reviewer diffing against the fork will notice, both deliberate:

  • The fork's configure_environment_wheelhouse(env_root, *, offline=...) takes the flag as an argument; here the runtime reads NMP_ENVIRONMENT_OFFLINE inside _install_wheels_v1_dependencies, which is where this package already stages the wheelhouse and sets UV_FIND_LINKS.
  • The fork's inline commentary is not carried over. The contract lives in the tests — test_connections_are_never_reused, test_rollouts_run_sends_a_length_to_an_http_10_caller, test_an_offline_environment_takes_uv_off_the_index and the rest name and enforce it.

Changes

Response framing

/rollouts/run emits a whitespace heartbeat while a batch runs, because the body can take minutes and the status line has to leave first. The body went out under HTTP/1.0 with neither a length nor chunking, delimited only by the connection closing — and the OpenSandbox proxy does not wait for that close. It returned 200 with the lone " " heartbeat as the finished response, and the caller failed with JSONDecodeError: Expecting value: line 1 column 1 (char 0).

Chunked framing is the delimiter and it is 1.1-only, so Handler moves to HTTP/1.1 and takes only that half:

  • parse_request sets close_connection = True. Reuse gains this server nothing — a rollout runs for minutes, so a handshake per request rounds to zero — and costs a real hazard: an unread request body (the 413 path declines to read one on purpose) is what the server would otherwise parse as the next request line.
  • Every response is explicitly framed, through _send_empty, _send_body, _send_json, or the chunked path terminated by the zero-length chunk.
  • An HTTP/1.0 caller cannot parse chunks, so it gets the batch buffered behind a Content-Length. A late answer is a loud failure where a truncated one is a wrong reward.

Offline environments

nmp/rl's grpo_config already sets environment_offline on a manifest whose wheelhouse is a complete closure (grpo_config.py:299), and this package had no such field. NemoGymSandboxedConfig is extra="forbid", so that key does not pass through unnoticed — it fails validation, and a sandboxed run with an offline environment could not start at all once NeMo-RL consumes this package.

The field now exists on the caller dialect and the serve config, reaches the host as NMP_ENVIRONMENT_OFFLINE, and sets UV_OFFLINE alongside the wheelhouse's UV_FIND_LINKS. Index fallback is otherwise retained, which is a liability only when an index is configured but unreachable: uv then fails to resolve uv venv --seed rather than falling back to --find-links.

Deliberately a caller decision, not derived: a wheels-v1 package can ship wheels and still need an index for its agent. An operator's own UV_OFFLINE wins.

A stale path the deletion exposes

gym_host.sh fell back to $root/nemo_rl/environments/sandbox/gym_host_runtime.py when no runtime was given — NeMo-RL's copy of this file, which soluwalana/RL#25 deletes. Unreachable through the package's own API, since default_gym_host_entrypoint always passes the runtime as argv[4], but reachable by anyone invoking the script with three arguments.

Stale regardless: gym_host_runtime_path in entrypoint.py already falls back to the packages/ path alone, with no RL branch. The shell now matches it.

Found by sweeping nemo-platform for the old import path while reviewing RL#25 — it is the only place in this repo that still named it.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with documentation updates
  • Documentation only
  • Contributor tooling or automation
  • CI, build, or test infrastructure

Quality Gates

  • Tests added or updated for changed behavior
  • Existing tests cover changed behavior — justification:
  • Tests not applicable — justification:
  • Documentation updated for user-visible behavior
  • Documentation not applicable — justification: both behaviours are documented where a maintainer meets them — the framing invariant on Handler, the offline trade-off on the config field and at the UV_OFFLINE assignment.

Verification

  • Pull request title follows the repository's Conventional Commit format
  • Every commit includes an appropriate Signed-off-by: trailer
  • uv run pre-commit run -a passes, or any blocked checks are identified below
  • Targeted tests pass, or tests are marked not applicable above
  • No secrets, API keys, or credentials are included

Targeted validation:

  • uv run pytest packages/sandboxed_gym/tests -q339 passed, 5 skipped (329 before)
  • pre-commit on the changed files → ruff, ruff format, ty, copyright, merge-conflict all pass
  • Ten tests added. Mutation-checked in both directions:
    • Restoring the close-delimited write and the raw heartbeat fails the three framing tests
    • Reverting protocol_version to 1.0 fails test_connections_are_never_reused
    • Dropping Content-Length: 0 from _send_empty fails test_bodiless_responses_carry_a_length
    • Never forwarding or never acting on environment_offline fails one offline test each

Review notes

An independent review (Codex, read-only) cleared the things most likely to bite and returned two findings:

  • _chunked leaking across requests or threads — cleared. ThreadingHTTPServer creates one handler instance per connection and parse_request closes every connection, so it cannot leak.
  • Unframed bodies on any path — cleared. Every 1.1 path writes the terminator, including the result-error, deadline and oversized-response paths.
  • Evaluator does not pass environment_offline (major) — accurate, and deliberately left alone. The field defaults to False, so Evaluator's behaviour is unchanged; it simply cannot opt in yet. Wiring it means adding a knob to Evaluator's plan and job API, which is a product decision, not part of restoring parity with the RL fork. Worth a follow-up.
  • HTTP/0.9 responses carry no framing (minor) — not a defect. HTTP/0.9 has no headers by design, so its body is close-delimited, and parse_request closes the connection. Pre-existing, and no in-repo caller reaches it: urllib/http.client send 1.1.

Every in-repo caller therefore takes the chunked path; the buffered 1.0 path is a compatibility fallback. One consequence worth naming: on that fallback a disconnected caller is no longer detected mid-rollout, because there is nothing on the wire to fail a write against. It surfaces at the deadline instead.

Not verified

No sandboxed run against a live OpenSandbox host — the original defect was observed through the real proxy, and the regression guard here reproduces its framing at the socket level rather than through that proxy.

Summary by CodeRabbit

  • New Features

    • Added an optional offline environment mode for sandboxed gym workloads.
    • Offline mode supports package installation from locally available resources while respecting configured offline settings.
    • Improved rollout responses with streaming heartbeats for compatible HTTP/1.1 clients and buffered responses for older clients.
    • Responses now close connections appropriately and handle empty, missing, and invalid routes consistently.
  • Tests

    • Expanded coverage for offline operation, configuration propagation, and HTTP response behavior.

@github-actions github-actions Bot added the fix label Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 41578/52690 78.9% 63.0%
Integration Tests 25302/49745 50.9% 22.8%

…ironment

Two fixes landed on NeMo-RL's fork of this code while the branch that deletes that
fork sat unmerged. Neither exists here, so completing that consolidation would drop
both -- the exact drift consolidating is meant to end.

Response framing. `/rollouts/run` emits a whitespace heartbeat while a batch runs,
because the body can take minutes and the status line has to leave first. The body
went out under HTTP/1.0 with neither a length nor chunking, delimited only by the
connection closing -- and the OpenSandbox proxy does not wait for that close. It
returned 200 with the lone " " heartbeat as the finished response, and the caller
failed with `JSONDecodeError: Expecting value: line 1 column 1 (char 0)`. Chunked
framing is the delimiter and it is 1.1-only, so the handler moves to 1.1 and takes
only that half: `parse_request` declines connection reuse, which this server gains
nothing from -- a rollout runs for minutes, so a handshake per request rounds to
zero -- and which would cost a real hazard, because an unread request body (the 413
path declines to read one on purpose) is what the server would otherwise parse as
the next request line. Every response is now explicitly framed through one of
`_send_empty`, `_send_body`, `_send_json` or the chunked path. A 1.0 caller cannot
parse chunks, so it gets the batch buffered behind a Content-Length instead: a late
answer is a loud failure where a truncated one is a wrong reward.

Offline environments. `nmp/rl`'s grpo_config already sets `environment_offline` on a
manifest whose wheelhouse is a complete closure, and this package had no such field.
`NemoGymSandboxedConfig` is `extra="forbid"`, so that key does not pass through
unnoticed -- it fails validation, and a sandboxed run with an offline environment
could not start once NeMo-RL consumes this package. The field now exists on both the
caller dialect and the serve config, reaches the host as `NMP_ENVIRONMENT_OFFLINE`,
and sets `UV_OFFLINE` alongside the wheelhouse's `UV_FIND_LINKS`. Index fallback is
otherwise retained, which is a liability only when an index is configured but
unreachable: uv then fails to resolve `uv venv --seed` rather than falling back to
`--find-links`. Not derivable from the package format -- a wheels-v1 package can
ship wheels and still need an index for its agent -- so the caller decides, and an
operator's own `UV_OFFLINE` wins.

Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@SandyChapman
SandyChapman force-pushed the aalgo-583-backport-rl-host-fixes/schapman branch from 2763066 to cf0ac5d Compare September 10, 2026 13:18
@SandyChapman
SandyChapman marked this pull request as ready for review September 10, 2026 13:26
@SandyChapman
SandyChapman requested review from a team as code owners September 10, 2026 13:26
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 4d9c6b17-229e-4c51-9b86-049d06367a75

📥 Commits

Reviewing files that changed from the base of the PR and between cf0ac5d and 8479799.

📒 Files selected for processing (1)
  • packages/sandboxed_gym/src/sandboxed_gym/host/gym_host.sh

Included review availability: Your plan provides up to 12 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Sandboxed Gym now supports environment_offline. The orchestrator propagates offline mode to the runtime, which configures uv. The gym host selects the packaged runtime and uses explicit HTTP response framing with chunked HTTP/1.1 rollout responses.

Sandboxed Gym runtime

Layer / File(s) Summary
Offline configuration propagation
packages/sandboxed_gym/src/sandboxed_gym/host/models.py, packages/sandboxed_gym/src/sandboxed_gym/serve_config.py, packages/sandboxed_gym/src/sandboxed_gym/orchestrator.py, packages/sandboxed_gym/src/sandboxed_gym/host/gym_host.sh, packages/sandboxed_gym/tests/test_sandboxed_gym_host.py
Adds environment_offline, propagates the runtime environment flag when enabled, selects the packaged runtime, and tests enabled and default behavior.
Offline dependency resolution
packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py, packages/sandboxed_gym/tests/test_gym_host_runtime.py
Recognizes offline mode, sets UV_OFFLINE when needed, preserves existing values, and tests wheels-v1 installation behavior.
HTTP response framing
packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py, packages/sandboxed_gym/tests/test_gym_host_runtime.py
Adds shared response helpers, chunked HTTP/1.1 rollout responses, buffered non-HTTP/1.1 responses, connection closure, and framing tests.

Sequence Diagram(s)

sequenceDiagram
  participant NemoGymSandboxedConfig
  participant orchestrator
  participant gym_host_runtime
  participant uv
  NemoGymSandboxedConfig->>orchestrator: environment_offline=True
  orchestrator->>gym_host_runtime: NMP_GYM_OFFLINE=1
  gym_host_runtime->>uv: UV_OFFLINE=1
Loading

Merge Risk: ⚪ Minimal · up to 84797

This change adds offline sandbox configuration and explicit rollout response framing while removing the obsolete runtime fallback. Focused tests pass, and no concrete merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two primary changes: rollout response framing and offline environment support.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch aalgo-583-backport-rl-host-fixes/schapman

Comment @coderabbitai help to get the list of available commands.

`gym_host.sh` fell back to `$root/nemo_rl/environments/sandbox/gym_host_runtime.py`
when no runtime was given. That path is NeMo-RL's fork of this file, which is being
deleted as RL moves onto this package, so the fallback would name a file that cannot
exist.

Unreachable through the package's own API -- `default_gym_host_entrypoint` always
passes the runtime as argv[4] -- but reachable by anyone invoking the script with
three arguments, and stale either way: `gym_host_runtime_path` in entrypoint.py
already falls back to the `packages/` path alone, with no RL branch. The shell now
matches it.

Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@SandyChapman
SandyChapman added this pull request to the merge queue Sep 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 11, 2026
@SandyChapman
SandyChapman added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 2373abf Sep 11, 2026
64 checks passed
@SandyChapman
SandyChapman deleted the aalgo-583-backport-rl-host-fixes/schapman branch September 11, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants