Skip to content

Commit 2763066

Browse files
committed
fix(sandboxed-gym): frame rollout responses and honour an offline environment
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>
1 parent ce7e9d7 commit 2763066

6 files changed

Lines changed: 401 additions & 21 deletions

File tree

‎packages/sandboxed_gym/src/sandboxed_gym/host/models.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ class NemoGymSandboxedConfig(BaseModel):
223223
sandboxed: bool = False
224224
host_provider: str = "opensandbox"
225225
environment_path: str | None = None
226+
# Whether the environment package's wheelhouse is a complete closure. A wheels-v1 package
227+
# can ship wheels and still need an index for its agent, so this is not derivable from the
228+
# format -- the caller who built it decides.
229+
environment_offline: bool = False
226230
sandbox: SandboxConfig | None = None
227231
job_id: str = DEFAULT_JOB_ID
228232
episode_broker: dict[str, Any] = Field(default_factory=dict)

‎packages/sandboxed_gym/src/sandboxed_gym/orchestrator.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
)
3535
from sandboxed_gym.host.provider import SandboxedGymHostProvider, get_host_provider
3636
from sandboxed_gym.runtime.gym_host_runtime import (
37+
ENVIRONMENT_OFFLINE_ENV_KEY,
3738
ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY,
3839
GYM_GLOBAL_CONFIG_ENV_KEY,
3940
)
@@ -276,6 +277,8 @@ def build_gym_host_spec(
276277
# The mount path is ``/job/environment`` with or without a FileSet. This flag is how the
277278
# host distinguishes a required package from an image-bundled tree at the same path.
278279
bootstrap_extra[ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY] = "true"
280+
if cfg.environment_offline:
281+
bootstrap_extra[ENVIRONMENT_OFFLINE_ENV_KEY] = "true"
279282
bootstrap_env = build_bootstrap_env(
280283
cfg.job_id,
281284
cfg.environment_path or sandbox.env_mount_path,

‎packages/sandboxed_gym/src/sandboxed_gym/runtime/gym_host_runtime.py‎

Lines changed: 93 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
#: for image-bundled Gym too; this flag is how a missing ``nemo-environment.yaml`` becomes a
4141
#: FileSet error instead of a silent fallback to the image-shipped environment.
4242
ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY = "NMP_ENVIRONMENT_PACKAGE_REQUIRED"
43+
ENVIRONMENT_OFFLINE_ENV_KEY = "NMP_ENVIRONMENT_OFFLINE"
4344
UV_CACHE_DIR_KEY = "uv_cache_dir"
4445
UV_VENV_DIR_KEY = "uv_venv_dir"
4546
# Writable /job/work subdirectory where wheels are installed for the running Gym host.
@@ -58,6 +59,7 @@
5859
MODEL_CALLS_RESULT_KEY = "_nmp_model_calls"
5960
# uv setting that points Gym's per-server dependency resolver at the staged wheelhouse.
6061
UV_FIND_LINKS_ENV_KEY = "UV_FIND_LINKS"
62+
UV_OFFLINE_ENV_KEY = "UV_OFFLINE"
6163
NEMO_GYM_EXTRA_ROOTS_ENV_KEY = "NEMO_GYM_EXTRA_ROOTS"
6264
#: Which agent, resources server, and model to run. Gym has no schema for this key, so
6365
#: the host pops it and rewrites ``config_paths`` before Gym parses the dict.
@@ -220,6 +222,11 @@ def _environment_package_required() -> bool:
220222
return os.environ.get(ENVIRONMENT_PACKAGE_REQUIRED_ENV_KEY, "").strip().lower() in {"1", "true", "yes"}
221223

222224

225+
def _environment_offline() -> bool:
226+
"""Whether to resolve from the wheelhouse and uv's cache alone."""
227+
return os.environ.get(ENVIRONMENT_OFFLINE_ENV_KEY, "").strip().lower() in {"1", "true", "yes"}
228+
229+
223230
def _load_runtime_environment_package(
224231
environment_path: str,
225232
*,
@@ -287,6 +294,18 @@ def _install_wheels_v1_dependencies(package: EnvironmentPackage | None, work_pat
287294
# for image-owned Gym and its core dependencies.
288295
os.environ[UV_FIND_LINKS_ENV_KEY] = wheels_dir
289296

297+
if _environment_offline():
298+
# That fallback is a liability when the index is configured but unreachable: uv fails
299+
# to resolve `uv venv --seed` rather than falling back to --find-links.
300+
if UV_OFFLINE_ENV_KEY in os.environ:
301+
print(
302+
f"gym-host: offline requested, but {UV_OFFLINE_ENV_KEY}="
303+
f"{os.environ[UV_OFFLINE_ENV_KEY]} is already set",
304+
flush=True,
305+
)
306+
else:
307+
os.environ[UV_OFFLINE_ENV_KEY] = "1"
308+
290309
# Gym starts agent and resource servers as child Python processes. Prepend the wheel target so
291310
# those processes can import the environment's vendored dependencies.
292311
existing_pythonpath = os.environ.get("PYTHONPATH")
@@ -606,33 +625,46 @@ def run_rollouts_sync(
606625

607626

608627
class Handler(BaseHTTPRequestHandler):
628+
# A rollout body's length is not known when the status line has to leave, and HTTP/1.0
629+
# cannot delimit such a body except by closing the connection -- so a hop is free to treat
630+
# the first bytes it sees as the whole response, which is how a lone " " heartbeat reached
631+
# a caller as a finished body and failed to parse. Chunked is the delimiter, and it is
632+
# 1.1-only.
633+
protocol_version = "HTTP/1.1"
634+
# Set per request in do_POST; read by the heartbeat in _await_results.
635+
_chunked: bool = False
609636
max_request_bytes: int = 268_435_456
610637
max_response_bytes: int = 268_435_456
611638
heartbeat_interval_s: float = _HEARTBEAT_INTERVAL_S
612639
rollout_deadline_s: float = _DEFAULT_ROLLOUT_DEADLINE_S
613640

641+
def parse_request(self) -> bool:
642+
"""Accept the request, then decline to reuse its connection.
643+
644+
Reuse gains this server nothing -- a rollout runs for minutes, so a handshake per
645+
request rounds to zero -- and would cost: an unread request body is what the server
646+
would parse as the next request line, and the 413 path declines to read one on
647+
purpose. Set here so it also covers request lines that never reach a handler, and
648+
early enough for _announce_close to advertise it.
649+
"""
650+
parsed = super().parse_request()
651+
self.close_connection = True
652+
return parsed
653+
614654
def do_GET(self) -> None:
615655
if not self.path.startswith("/health"):
616-
self.send_response(404)
617-
self.end_headers()
656+
self._send_empty(404)
618657
return
619658
# Must match do_POST: a host that passes /health and then 503s every rollout is
620659
# invisible to wait_ready.
621660
if not _READY or _HEAD_SERVER_CONFIG is None or _ROLLOUT_HELPER is None:
622-
body = json.dumps({"status": "starting"}).encode("utf-8")
623-
self.send_response(503)
661+
self._send_json(503, {"status": "starting"})
624662
else:
625-
body = json.dumps({"status": "ready"}).encode("utf-8")
626-
self.send_response(200)
627-
self.send_header("Content-Type", "application/json")
628-
self.send_header("Content-Length", str(len(body)))
629-
self.end_headers()
630-
self.wfile.write(body)
663+
self._send_json(200, {"status": "ready"})
631664

632665
def do_POST(self) -> None:
633666
if not self.path.startswith("/rollouts/run"):
634-
self.send_response(404)
635-
self.end_headers()
667+
self._send_empty(404)
636668
return
637669
if not _READY or _HEAD_SERVER_CONFIG is None or _ROLLOUT_HELPER is None:
638670
self._send_json(503, _runtime_error("bootstrap_failed", "Gym host not ready"))
@@ -690,14 +722,25 @@ def do_POST(self) -> None:
690722
# Committed to 200 before the work is done, so the first byte leaves immediately and no hop
691723
# can mistake a long batch for a dead one. Everything judgeable from the request alone was
692724
# rejected with a real status above; failures from here travel in the body as
693-
# {"error": ...}, which the caller already treats as fatal. No Content-Length: the body is
694-
# delimited by the close that `Connection: close` promises, which is what allows the
695-
# heartbeats below to precede a payload of unknown length.
725+
# {"error": ...}, which the caller already treats as fatal.
726+
self._chunked = self.request_version >= "HTTP/1.1"
727+
if not self._chunked:
728+
# A 1.0 caller cannot parse chunks, and 1.0's own answer for a body of unknown
729+
# length is the close-delimited one that lost the response in the first place. So
730+
# buffer instead: the proxy's first-byte cap now covers the whole rollout, but a
731+
# late answer is a loud failure where a truncated one is a wrong reward.
732+
self._send_body(200, self._await_results(future, started))
733+
return
734+
696735
self.send_response(200)
697736
self.send_header("Content-Type", "application/json")
698-
self.send_header("Connection", "close")
737+
self.send_header("Transfer-Encoding", "chunked")
738+
self._announce_close()
699739
self.end_headers()
700-
self.wfile.write(self._await_results(future, started))
740+
self._write_chunk(self._await_results(future, started))
741+
# Terminator. Only this ends the body.
742+
self.wfile.write(b"0\r\n\r\n")
743+
self.wfile.flush()
701744

702745
def _await_results(self, future: concurrent.futures.Future[list[dict]], started: float) -> bytes:
703746
"""Wait for ``future``, heartbeating while it runs, and return the body to send.
@@ -718,9 +761,12 @@ def _await_results(self, future: concurrent.futures.Future[list[dict]], started:
718761
# its own, which is not this loop's tick.
719762
done, _ = concurrent.futures.wait([future], timeout=min(self.heartbeat_interval_s, remaining))
720763
if not done:
764+
if not self._chunked:
765+
# Nothing is on the wire yet on the buffered path, so there is nothing to
766+
# heartbeat into; that caller waits for the whole body.
767+
continue
721768
try:
722-
self.wfile.write(b" ")
723-
self.wfile.flush()
769+
self._write_chunk(b" ")
724770
except OSError as exc:
725771
# The caller is gone. Nothing will read this batch, so stop paying for it:
726772
# cancelling the future propagates to the collector task on the shared loop.
@@ -765,14 +811,40 @@ def _await_results(self, future: concurrent.futures.Future[list[dict]], started:
765811
def _error_body(self, code: str, message: str) -> bytes:
766812
return json.dumps(_runtime_error(code, message)).encode("utf-8")
767813

768-
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
769-
body = json.dumps(payload).encode("utf-8")
814+
def _write_chunk(self, data: bytes) -> None:
815+
"""Emit one HTTP chunk. Empty writes are dropped: a zero-length chunk ends the body."""
816+
if not data:
817+
return
818+
self.wfile.write(b"%X\r\n%s\r\n" % (len(data), data))
819+
self.wfile.flush()
820+
821+
def _announce_close(self) -> None:
822+
"""Tell the peer the connection is ending rather than let it find out.
823+
824+
A pooling proxy told nothing here returns the socket to its pool and fails on the
825+
next request it sends down it.
826+
"""
827+
if self.close_connection:
828+
self.send_header("Connection", "close")
829+
830+
def _send_empty(self, status: int) -> None:
831+
"""Frame a bodiless response: zero bytes still has to say it is zero bytes."""
832+
self.send_response(status)
833+
self._announce_close()
834+
self.send_header("Content-Length", "0")
835+
self.end_headers()
836+
837+
def _send_body(self, status: int, body: bytes) -> None:
770838
self.send_response(status)
839+
self._announce_close()
771840
self.send_header("Content-Type", "application/json")
772841
self.send_header("Content-Length", str(len(body)))
773842
self.end_headers()
774843
self.wfile.write(body)
775844

845+
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
846+
self._send_body(status, json.dumps(payload).encode("utf-8"))
847+
776848
def log_message(self, format: str, *args: Any) -> None:
777849
return
778850

‎packages/sandboxed_gym/src/sandboxed_gym/serve_config.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class SandboxedGymServeConfig(BaseModel):
3030
job_id: str = DEFAULT_JOB_ID
3131
host_provider: str = "opensandbox"
3232
environment_path: str | None = None
33+
# Whether that package's wheelhouse is a complete closure. A wheels-v1 package can ship
34+
# wheels and still need an index for its agent, so this is not derivable from the format.
35+
environment_offline: bool = False
3336
sandbox: SandboxConfig
3437
episode_broker: EpisodeBrokerConfig | dict[str, Any] = Field(default_factory=dict)
3538
gym_global_config: dict[str, Any] = Field(default_factory=dict)

0 commit comments

Comments
 (0)