Skip to content

No synchronous path to bootstrap a container: from_docker_image/from_env are async-only and there's no public base_url #935

Description

@sergiopaniego

Summary

The whole instance surface of EnvClient is dual-mode (works from both sync and async callers) thanks to _dispatch / _AutoAsyncResult / _run_syncreset, step, state, close, connect all resolve correctly whether or not there's a running event loop.

But the constructors that bootstrap a container are async-only:

  • EnvClient.from_docker_image() and EnvClient.from_env() are plain async def classmethods — no dual-mode wrapper. Calling them from synchronous code returns an un-awaited coroutine.
  • There is no public getter for the container URL (only the private self._base_url, set via self._set_base_url(); the previously-public EnvClient.base_url was removed in the core refactor).

The net effect: a synchronous consumer can use the entire client except the one thing that's advertised as the ergonomic one-liner (Env.from_docker_image("...")). There is no from_docker_image_sync, the async classmethod can't be awaited from a sync loop, and even if you could construct the client you can't read its URL back through a public API.

Why this matters (concrete consumer: TRL)

The TRL GRPO examples run inside a synchronous rollout loop, so they can't await from_docker_image(...). To bootstrap a docker-image env they now have to drop down to the provider and hand-roll the exact sequence that from_docker_image already does internally:

# TRL examples/scripts/openenv/{catch,sudoku}.py
provider = LocalDockerProvider()
env_url = provider.start_container(args.env_image)
provider.wait_for_ready(env_url)
# ... then build a sync client with base_url=env_url and use it via _dispatch

Compare with EnvClient.from_docker_image internals:

provider = LocalDockerProvider()
base_url = provider.start_container(image, **kwargs)
provider.wait_for_ready(base_url)
client = cls(base_url=base_url, provider=provider)
await client.connect()

The first three lines are identical. So sync consumers end up duplicating OpenEnv's own bootstrap logic. This is not just verbose — it's a maintenance hazard: if the bootstrap sequence gains a step (auth, retries, a different readiness check, a changed default provider), the hand-rolled copies silently diverge and won't inherit it.

(For reference, OpenEnv's own examples/browsergym_harness_eval_common.py uses the provider directly too, which confirms this is the current de-facto sync path — but it's a lower layer than the advertised facade.)

Current workaround: huggingface/trl#6329 — the TRL examples bypass from_docker_image and bootstrap via LocalDockerProvider directly, as shown above.

Proposed fixes (any one closes the gap)

  1. Make the constructors dual-mode like the rest of the surface (preferred, most consistent): from_docker_image / from_env return an awaitable in async code and a fully-connected client in sync code.
  2. Add sync variants, e.g. from_docker_image_sync() / from_env_sync().
  3. Re-expose a public base_url (read-only property over _base_url), so a sync caller that bootstraps via the provider can still round-trip through the client instead of tracking the URL separately.

(1) is the cleanest — it restores symmetry: today every instance method is dual-mode, only the constructors are not.

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions