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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions