How to Convert an Async OpenEnv EnvClient to Synchronous Usage with the `.sync()` Wrapper
OpenEnv provides a thin synchronous façade via the .sync() method that wraps the async EnvClient in a dedicated background event loop, allowing you to use reset(), step(), and other methods synchronously without blocking your main thread.
The huggingface/OpenEnv library defines its core environment client (EnvClient) as an asynchronous abstraction to maintain persistent WebSocket connections without blocking the caller. For scripts, REPLs, or libraries that require classic synchronous flow, OpenEnv offers a seamless conversion pathway through the .sync() wrapper method.
Understanding the Async-to-Sync Architecture
OpenEnv’s synchronous support relies on two primary components working together to bridge the async/sync divide while keeping the underlying WebSocket and Docker provisioning logic unchanged.
The sync() Factory Method
In src/openenv/core/env_client.py at line 85, the abstract EnvClient class defines the sync() method. This factory imports and returns an instance of SyncEnvClient, passing the original async client (self) to the wrapper. This design allows any concrete client inheriting from EnvClient to expose synchronous capabilities instantly.
The SyncEnvClient Wrapper Class
Implemented in src/openenv/core/sync_client.py, the SyncEnvClient class creates a dedicated background asyncio event loop running in its own daemon thread. When you call synchronous methods like reset() or step(), the wrapper schedules the underlying coroutine via asyncio.run_coroutine_threadsafe and blocks until the result is available.
The class implements __getattr__ to automatically wrap any coroutine-based method on the async client, caching the sync wrapper for subsequent calls. This ensures that new methods added to the async client become immediately available in the synchronous interface without additional boilerplate.
Converting Your Async Client to Synchronous
To convert async OpenEnv EnvClient to synchronous usage, instantiate your client as usual and chain the .sync() method. The resulting object behaves like a standard blocking Python API.
Basic Synchronous Usage
The most common pattern uses the context manager protocol to ensure proper cleanup:
from echo_env import CallToolAction, EchoEnv
# Obtain a synchronous wrapper from an async client
sync_client = EchoEnv(base_url="https://openenv-echo-env.hf.space").sync()
# Use it as a normal context manager
with sync_client:
# Reset the environment (synchronous)
result = sync_client.reset()
print(result.observation.echoed_message) # → "Echo environment ready!"
# Step the environment
result = sync_client.step(
CallToolAction(
tool_name="echo_message",
arguments={"message": "Hello, World!"},
)
)
print(result.observation.result) # → "Hello, World!"
Explicit Connection Management
For scenarios requiring manual control over the WebSocket lifecycle, call connect() and disconnect() explicitly before closing the wrapper:
from openenv.core import GenericEnvClient
# Create an async client and wrap it
client = GenericEnvClient(base_url="ws://localhost:8000").sync()
# Manually manage the connection
client.connect() # Establishes the WebSocket in the background loop
obs = client.reset()
obs = client.step({"code": "print('hello')"})
client.disconnect() # Closes the WebSocket
client.close() # Stops the background event loop
Accessing Async-Only Methods
Even after wrapping, you retain access to the underlying async client for mixed-mode workflows:
async_client = EchoEnv(base_url="...") # async client
sync_client = async_client.sync() # sync wrapper
# Directly call an async method (still async)
await async_client.some_async_helper()
Resource Management and Cleanup
The SyncEnvClient implements the context-manager protocol (__enter__/__exit__) and exposes a close() method that properly terminates resources. When invoked, these methods call the async client’s close() coroutine and stop the background event loop cleanly, ensuring no stray daemon threads remain. Always use the context manager pattern or explicitly call close() when finished to prevent thread leaks.
Summary
- File location: The
.sync()method is defined insrc/openenv/core/env_client.py(line 85), returning aSyncEnvClientfromsrc/openenv/core/sync_client.py. - Mechanism: The wrapper runs a dedicated
asyncioevent loop in a background daemon thread, usingasyncio.run_coroutine_threadsafeto bridge sync calls to async methods. - Automatic wrapping:
__getattr__automatically makes any async method available as a synchronous equivalent without manual registration. - Cleanup: Use context managers (
withstatement) or callclose()to stop the background loop and prevent resource leaks.
Frequently Asked Questions
How do I convert an async OpenEnv EnvClient to synchronous usage without rewriting my codebase?
Call the .sync() method on any EnvClient instance to receive a SyncEnvClient wrapper that exposes the same API as synchronous methods. This wrapper handles all async/await complexity internally by running a background event loop in a separate thread, allowing you to use standard blocking calls like reset() and step() immediately.
Does the synchronous wrapper support all methods available on the async client?
Yes. The SyncEnvClient class in src/openenv/core/sync_client.py implements __getattr__ to dynamically wrap any coroutine-based method it encounters. This means new methods added to EnvClient subclasses automatically become available in the synchronous interface without requiring updates to the wrapper code.
What happens if I don't close the synchronous client properly?
Failing to close the SyncEnvClient leaves a daemon thread running with an active asyncio event loop, which can prevent your program from exiting cleanly or cause resource leaks. Always use the context manager protocol (with sync_client:) or explicitly call sync_client.close() to shut down the background loop and release the WebSocket connection.
Can I mix synchronous and asynchronous calls in the same workflow?
Yes. You can maintain references to both the original async client and the wrapped synchronous client simultaneously. After calling sync_client = async_client.sync(), you can still await async_client methods directly while using sync_client for blocking operations, enabling hybrid async/sync architectures within the same process.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →