Skip to content

[core][sandbox] Add a gRPC facade for external sandbox clients - #65839

Open
xyuzh wants to merge 15 commits into
ray-project:masterfrom
xyuzh:sandbox-modal-facade
Open

[core][sandbox] Add a gRPC facade for external sandbox clients#65839
xyuzh wants to merge 15 commits into
ray-project:masterfrom
xyuzh:sandbox-modal-facade

Conversation

@xyuzh

@xyuzh xyuzh commented Sep 1, 2026

Copy link
Copy Markdown
Member

Why are these changes needed?

Stacked on #65633 (the Ray Sandbox HTTP API); review the last two commits.

#65633 exposes the sandbox control plane as a REST service. This PR adds a gRPC facade in front of the same detached SandboxHost actors, so an unmodified third-party sandbox client SDK pointed at this server can create sandboxes, exec commands, and use its filesystem API against a Ray cluster.

The facade implements the subset of the SDK's control-plane (ModalClient) and command-router (TaskCommandRouter) services that its Sandbox API uses. The wire contract is vendored under http/_proto/ as a minimal protobuf/grpclib stub set generated from checked-in .proto sources, so nothing third-party is needed to build or test. gRPC routes on the fully qualified method path and protobuf frames fields by number, so the vendored identifiers reproduce the external service's on-the-wire names verbatim; only the surface the facade uses is declared.

Design:

  • Stateless where possible. Object ids carry their payload (im-<b64 image ref>, st-<b64 env json>) and the sandbox id doubles as the client task id. Named creates are scoped to the client app and idempotent; a dead actor under a live name is killed and recreated. The one in-process table is the exec table, which evicts finished records past a cap, so run a single facade process per cluster.
  • Filesystem API via exec emulation. The SDK implements its filesystem API by exec-ing a helper binary that sandbox images don't contain. The router recognizes its argv and emulates WriteFile / ReadFile / ListFiles with SandboxHost file operations; large writes flush in bounded slices.
  • Scheduling-aware. Every actor call is bounded, so an unscheduled actor on a scaling cluster surfaces as UNAVAILABLE (retried by the SDK) instead of hanging, and a dead actor maps to NOT_FOUND (the SDK's "task shut down" signal).

Run with python -m ray.experimental.sandbox.http.grpc_facade (requires grpclib). Documented in the sandboxes guide under the HTTP API service section.

Commits:

  1. [core][sandbox] Vendor the gRPC wire contract for the sandbox facade: .proto sources, generated stubs, and the lint excludes for them.
  2. [core][sandbox] Add a gRPC facade for external sandbox clients: the facade, its tests, and docs.

Review fixes that touch code shared with #65633 (name:group exec-user resolution, a bounded runsc download, a bounded DELETE, docs wording) landed on that branch and are included here through the stack.

Related issue number

Stacked on #65633.

Checks

  • test_grpc_facade.py drives the facade end to end over gRPC with the vendored stubs, backed by the same fake resolver and runtime seams the REST app tests use. It skips when grpclib is absent, so the sandbox core CI job (which does not install grpclib) adds no new dependency. Ran locally: 9 passed; the HTTP API suite (test_http_app.py, test_http_schemas.py, test_sandbox_host.py) still passes (63 passed).
  • The facade module is never imported from sandbox/__init__.py or the REST app.py import graph, so it stays inert unless explicitly run.
  • ruff check, ruff check --select I, black, and pydoclint (the pre-commit versions and flags) pass on the changed files.

Exposes ray.experimental.sandbox over a versioned REST API (/api/v1)
served by Ray Serve, so sandboxes can be managed from outside the Ray
cluster with nothing but an HTTP client and a bearer token — e.g. as an
Anyscale service, or by agent-evaluation frameworks like Harbor.

Design:
- Each sandbox is a named, detached SandboxHost actor; the actors are
  the registry, so the Serve app is stateless and replicas can scale or
  restart without losing sandboxes.
- Creation and execution are async submit + poll (with optional
  long-poll wait_seconds <= 30s) because image pulls and agent commands
  outlive HTTP requests and load-balancer limits.
- The TTL reclaims both the sandbox and its hosting actor (the core
  runtime's TTL is deliberately disabled here so there is one owner).
- Capabilities, network modes, DNS, shell, and workdir semantics are the
  core SandboxConfig's (ray-project#65570); the API validates network against
  VALID_NETWORK_MODES and defaults capabilities to
  DOCKER_DEFAULT_CAPABILITIES, patching nothing.
- fastapi is only needed by this subpackage (ray[serve]); the base
  sandbox package never imports it.

Testing: 54 unit tests run with no cluster and no runsc (fake runtime +
fake actor resolver + FastAPI TestClient), including an OpenAPI contract
snapshot; a runsc-gated integration test covers the real path. Validated
end to end as a local 'serve run' and as an Anyscale service, driving
real gVisor sandboxes.

Signed-off-by: xyuzh <xinyzng@gmail.com>
POST /sandboxes now synthesizes its 202 response from the request
instead of awaiting describe() on the new actor: on a saturated cluster
the actor may be queued behind capacity for longer than a client (or
load balancer) read timeout, and the endpoint has everything it needs
to answer without touching the actor. Surfaced by a Harbor concurrency
test that oversubscribed a single node.

Signed-off-by: xyuzh <xinyzng@gmail.com>
A 16-way Terminal-Bench run against a cold cluster surfaced two
capacity bugs:

- The Serve deployment used the default max_ongoing_requests, but this
  API is long-poll based (requests deliberately hold a slot for up to
  ~30s), so a handful of concurrent clients saturated the replica and
  the platform load balancer answered 503 for everyone else. Raise it
  to 1000; the app is entirely async I/O.
- Calls to a SandboxHost whose detached actor exists but has not been
  *scheduled* yet (cluster autoscaling) block indefinitely. Bound every
  actor call by the request's own long-poll budget plus a configurable
  scheduling grace: describe paths report a synthesized 'pending'
  instead of hanging, and exec/file paths return 409 with a retry
  hint.

Signed-off-by: xyuzh <xinyzng@gmail.com>
An actor whose cpu/memory shape can never fit the cluster raises
ActorUnschedulableError from any call; the app let it escape as an
opaque 500. Terminal-Bench tasks declaring cpus=4/memory_mb=8192 on a
cluster of 4CPU-16GB workers hit this for every large task. Map it to
409 'unschedulable' carrying Ray's own message, so clients see exactly
which resource shape cannot be satisfied.

Signed-off-by: xyuzh <xinyzng@gmail.com>
Proxies in front of a deployed service cap request bodies (an Anyscale
ingress rejected a 4.8MB upload with 413 and killed an 11MB one
mid-body), so single-request file uploads have a hidden size ceiling.
PUT /files gains an append flag, plumbed through host, runtime, and
backend (cat >> instead of cat >), so clients can chunk arbitrarily
large uploads into proxy-sized pieces.

Signed-off-by: xyuzh <xinyzng@gmail.com>
Match the style established in ray-project#65627 for the networking section:
soft-wrapped prose, em-dash and semicolon clauses split into separate
sentences, and bold list leads. Also document the new append flag on
PUT /files and the 409 unschedulable error code.

Signed-off-by: xyuzh <xinyzng@gmail.com>
… shared runsc cache, concurrent listing

Three review findings:

- A dead detached actor made its client_token permanently return 404;
  the idempotent-create path now clears the dead actor and creates a
  fresh sandbox under the same name.
- The opt-in runsc download leaked a ~40MB temp dir per boot and
  raced concurrent boots; it now uses one shared cached path per node
  with an atomic rename.
- GET /sandboxes described actors sequentially; it now gathers
  concurrently.

Signed-off-by: xyuzh <xinyzng@gmail.com>
runsc exec takes numeric -user uid[:gid]; names are resolved against
the image's own /etc/passwd host-side. Plumbed through runtime, the
HTTP API (StartExecRequest.user), and the backend, with tests. Brings
exec to parity with container engines' exec --user and the Harbor
environment contract's user= parameter.

Signed-off-by: xyuzh <xinyzng@gmail.com>
@xyuzh
xyuzh requested review from a team as code owners September 1, 2026 15:44

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an experimental HTTP REST API service and a Modal-compatible gRPC facade for Ray Sandbox, allowing users to manage sandboxes, execute commands, and perform file operations from outside the Ray cluster. Key feedback includes addressing a bug in the gVisor backend's user resolution logic when a group or GID is specified, preventing potential indefinite hangs during runsc downloads by replacing urlretrieve with a timed-out urlopen call, and fixing a timeout calculation bug in the Modal compatibility layer's SandboxWait endpoint that causes it to exit prematurely when no timeout is specified.

Comment thread python/ray/experimental/sandbox/backend/gvisor.py Outdated
Comment thread python/ray/experimental/sandbox/http/host.py Outdated
Comment thread python/ray/experimental/sandbox/http/grpc_facade.py Outdated
Comment thread python/ray/experimental/sandbox/http/modal_compat.py Outdated
Comment thread python/ray/experimental/sandbox/http/grpc_facade.py Outdated
Comment thread python/ray/experimental/sandbox/http/app.py
Comment thread python/ray/experimental/sandbox/http/grpc_facade.py
@xyuzh
xyuzh force-pushed the sandbox-modal-facade branch from e72ac4b to 9354d62 Compare September 1, 2026 18:04
@xyuzh xyuzh changed the title [core][sandbox] Add a Modal-wire-compatible gRPC facade for Ray Sandbox [core][sandbox] Add a gRPC facade for external sandbox clients Sep 1, 2026
Comment thread python/ray/experimental/sandbox/http/grpc_facade.py Outdated
@ray-gardener ray-gardener Bot added the core Issues that should be addressed in Ray Core label Sep 1, 2026
@xyuzh
xyuzh requested a review from a team as a code owner September 1, 2026 22:38
…serve.run

test_http_integration.py boots the API with `serve.run(build_app(...))`, but
the http package did not declare a Bazel dependency on
//python/ray/serve:serve_lib. Under bazel's sandboxed runfiles only a partial
ray.serve namespace was present, so the test errored at setup with
'module ray.serve has no attribute run'. Declare the serve_lib dependency and
grant serve_lib visibility to the sandbox http package so analysis succeeds.

Signed-off-by: xyuzh <xinyzng@gmail.com>
The core sandbox test job builds with --install-mask all-ray-libraries,
which removes python/ray/serve from the tree, so the
//python/ray/serve:serve_lib bazel dep resolves to "no such package" and
the runsc-gated integration test has no serve to import. app.py already
imports serve lazily inside build_app, so the py_library needs no serve
dep: drop it (and the now-moot visibility grant on serve/BUILD.bazel) and
importorskip serve in the end-to-end test, which only runs in a full dev
container where serve is installed.

Signed-off-by: xyuzh <xinyzng@gmail.com>
Two failures surfaced once the core sandbox job started running these
tests (it installs with --install-mask all-ray-libraries):

- test_http_app: three tests drove TestClient with a bare client instead
  of `with _client(...) as client:`, so each request ran on a fresh event
  loop while the SandboxHost's asyncio.Events stayed bound to the first
  one ("bound to a different event loop"). Wrap them like the others.
- test_http_integration: the mask leaves `ray.serve` importable but
  without `serve.run`, so importorskip did not skip. Add a hasattr guard.

Signed-off-by: xyuzh <xinyzng@gmail.com>
@xyuzh xyuzh added the go add ONLY when ready to merge, run all tests label Sep 2, 2026
Addresses review: a client_token create retry during a brief actor blip was
misread as a permanent death and killed the live sandbox.

_is_actor_gone matched ActorUnavailableError (Ray makes it a RayActorError
subclass), so _actor_call mapped a transient blip to 404 sandbox_not_found —
the create path then killed and recreated the actor, destroying a running
sandbox. Split transient unreachability into _is_actor_unavailable, checked
first (since it subclasses RayActorError), and map it to a retryable 503
sandbox_unavailable. Only genuine death (ActorDiedError / RayActorError) still
maps to 404.

Regression tests: test_transient_actor_unavailable_maps_to_503,
test_dead_actor_maps_to_404.

Signed-off-by: xyuzh <xinyzng@gmail.com>

@dstrodtman dstrodtman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs-team style pass from Douglas Strodtman (Anyscale docs). Claude Code assisted; I read every comment below and stand behind each one.

Scope: prose style, grammar, and docs conventions only. Technical accuracy — the endpoint contracts, status codes, the resource and capability semantics, the image tags in the examples — is the ray-core and ray-serve maintainers' call, not mine, and I'm not asserting anything about it.

This reads cleanly: sentence-case headings throughout, consistent serial commas, such as over like, and the #networking-and-dns anchors all resolve against the heading in this file. Two small prose nits inline, both optional. No approval implied — core and serve owners are the gate here.

Comment thread doc/source/ray-core/sandboxes.md Outdated
Comment thread doc/source/ray-core/sandboxes.md Outdated
…download and delete

- Resolve `name:group` exec users correctly: compare the passwd entry
  against the user name, keep an explicit numeric gid, and resolve group
  names via the image's /etc/group.
- Download runsc with a socket timeout instead of `urlretrieve`, which
  can hang forever, and drop the partial file on failure.
- Bound `DELETE /sandboxes/{id}` like every other actor call so an
  unscheduled actor is still killed instead of holding the request.
- Docs wording per the docs-team review.
Check in a minimal protobuf/grpclib stub set for the subset of a
third-party sandbox SDK's control-plane and command-router services that
the Ray Sandbox gRPC facade implements, generated from hand-authored
`.proto` sources under `http/_proto/`. gRPC routes on the fully qualified
method path and protobuf frames fields by number, so the identifiers
reproduce the external service's on-the-wire names verbatim.

The generated stubs are excluded from lint and formatting like the other
checked-in `*_pb2.py` files.
Serve the detached `SandboxHost` actors behind the REST API over gRPC so
an unmodified third-party sandbox client can create sandboxes, run
commands, and use its filesystem API against a Ray cluster.

- Stateless where possible: image and secret ids carry their payload and
  the sandbox id doubles as the client task id. Named creates are scoped
  to the client app and idempotent; a dead actor under a live name is
  killed and recreated.
- Every actor call is bounded so an unscheduled actor surfaces as
  UNAVAILABLE (retried by the SDK) and a dead one as NOT_FOUND.
- The filesystem API is emulated from the SDK's helper-binary argv with
  `SandboxHost` file operations; large writes flush in bounded slices.
- The in-process exec table evicts finished records past a cap.

Run with `python -m ray.experimental.sandbox.http.grpc_facade`
(requires `grpclib`). Tests drive the facade over the wire with the
vendored stubs and skip when `grpclib` is absent.
@xyuzh
xyuzh force-pushed the sandbox-modal-facade branch from e41087a to 531b5f7 Compare September 4, 2026 18:24

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 531b5f7. Configure here.

)
logger.debug("exec %s on %s: %s", request.exec_id, request.task_id, command[:2])
state.add_exec(request.exec_id, record)
await stream.send_message(sr_pb2.TaskExecStartResponse())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exec start is not idempotent

Medium Severity

TaskExecStart always starts a new SandboxHost job and overwrites state.execs[exec_id]. _bounded can return UNAVAILABLE after wait_for while the original Ray actor call is still queued, and the SDK retries that status. A retry then runs the command again and orphans the first job.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 531b5f7. Configure here.

break
if loop.time() >= deadline:
break
await asyncio.sleep(1.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero-timeout wait still blocks

Low Severity

SandboxWait treats timeout=0 as the SDK’s non-blocking poll, but every loop iteration still calls describe.remote(wait_seconds=1.0) inside _bounded. A booting sandbox blocks for about a second, and an unscheduled actor can block for the full scheduling grace.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 531b5f7. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants