Skip to content

Commit 91f84d8

Browse files
authored
Auto sync env clients (#874)
* fix: auto-dispatch env client sync calls * fix: preserve async dispatch in sync loop * fix: repair env client dispatch modes
1 parent a2253e6 commit 91f84d8

4 files changed

Lines changed: 235 additions & 45 deletions

File tree

src/openenv/core/env_client.py

Lines changed: 128 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import time
4141
from abc import ABC, abstractmethod
4242
from contextlib import suppress
43-
from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
43+
from typing import Any, Callable, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar
4444
from urllib.parse import urlsplit
4545

4646
from .client_types import StateT, StepResult
@@ -58,10 +58,49 @@
5858
ActT = TypeVar("ActT")
5959
ObsT = TypeVar("ObsT")
6060
EnvClientT = TypeVar("EnvClientT", bound="EnvClient")
61+
ResultT = TypeVar("ResultT")
6162

6263
_VALID_CLIENT_MODES = ("simulation", "production")
6364

6465

66+
class _AutoAsyncResult(Generic[ResultT]):
67+
"""Awaitable result that can also be resolved by synchronous access."""
68+
69+
def __init__(
70+
self,
71+
client: "EnvClient[Any, Any, Any]",
72+
coro_factory: Callable[[], Any],
73+
):
74+
self._client = client
75+
self._coro_factory = coro_factory
76+
self._resolved = False
77+
self._result: ResultT | None = None
78+
79+
def __await__(self):
80+
async def _await_result():
81+
self._client._claim_execution_mode("async")
82+
return await self._coro_factory()
83+
84+
return _await_result().__await__()
85+
86+
def _sync_result(self) -> ResultT:
87+
if not self._resolved:
88+
self._result = self._client._run_sync(self._coro_factory)
89+
self._resolved = True
90+
return self._result
91+
92+
def __getattr__(self, name: str) -> Any:
93+
return getattr(self._sync_result(), name)
94+
95+
def __bool__(self) -> bool:
96+
return bool(self._sync_result())
97+
98+
def __repr__(self) -> str:
99+
if self._resolved:
100+
return repr(self._result)
101+
return f"<{type(self).__name__} pending>"
102+
103+
65104
def _normalize_mode(mode: Optional[str]) -> str:
66105
"""Resolve and validate the client communication mode."""
67106
raw_mode = (
@@ -212,6 +251,8 @@ def __init__(
212251
self._start_provider_on_connect = base_url is None
213252
self._child_clients: list[EnvClient[Any, Any, Any]] = []
214253
self._ws: Optional[ClientConnection] = None
254+
self._execution_mode: Optional[str] = None
255+
self._sync_client: Optional["SyncEnvClient[ActT, ObsT, StateT]"] = None
215256
self._ws_loop: Optional[asyncio.AbstractEventLoop] = None
216257
if base_url is not None:
217258
self._set_base_url(base_url)
@@ -295,7 +336,56 @@ def __setattr__(self, name: str, value: Any) -> None:
295336
raise AttributeError("Cannot modify mode after initialization")
296337
super().__setattr__(name, value)
297338

298-
async def connect(self) -> "EnvClient":
339+
def _claim_execution_mode(self, mode: str) -> None:
340+
"""Lock the client to sync or async execution on first use."""
341+
if self._execution_mode is None:
342+
self._execution_mode = mode
343+
elif self._execution_mode != mode:
344+
raise RuntimeError(
345+
f"EnvClient is already being used in {self._execution_mode} mode. "
346+
"Create a separate client instance when mixing sync and async code."
347+
)
348+
349+
def _run_sync(
350+
self,
351+
coro_factory: Callable[[], Any],
352+
*,
353+
allow_async_handoff: bool = False,
354+
) -> Any:
355+
"""Run an async operation through the sync wrapper."""
356+
if allow_async_handoff and self._execution_mode == "async":
357+
self._execution_mode = "sync"
358+
else:
359+
self._claim_execution_mode("sync")
360+
if self._sync_client is None:
361+
self._sync_client = self.sync()
362+
return self._sync_client._run(coro_factory())
363+
364+
def _dispatch(self, coro_factory: Callable[[], Any]) -> Any:
365+
"""Return an awaitable in async code and a concrete result in sync code."""
366+
try:
367+
running_loop = asyncio.get_running_loop()
368+
except RuntimeError:
369+
running_loop = None
370+
371+
if self._execution_mode == "sync":
372+
sync_loop = (
373+
self._sync_client._loop if self._sync_client is not None else None
374+
)
375+
if running_loop is sync_loop:
376+
return coro_factory()
377+
return self._run_sync(coro_factory)
378+
if running_loop is not None:
379+
if self._execution_mode == "async":
380+
return coro_factory()
381+
return _AutoAsyncResult(self, coro_factory)
382+
383+
return self._run_sync(coro_factory, allow_async_handoff=True)
384+
385+
def connect(self) -> Any:
386+
return self._dispatch(self._connect_async)
387+
388+
async def _connect_async(self) -> "EnvClient":
299389
"""
300390
Establish WebSocket connection to the server.
301391
@@ -352,7 +442,10 @@ async def connect(self) -> "EnvClient":
352442

353443
return self
354444

355-
async def disconnect(self) -> None:
445+
def disconnect(self) -> Any:
446+
return self._dispatch(self._disconnect_async)
447+
448+
async def _disconnect_async(self) -> None:
356449
"""Close the WebSocket connection."""
357450
if self._ws is not None:
358451
ws = self._ws
@@ -374,17 +467,13 @@ async def disconnect(self) -> None:
374467
async def _ensure_connected(self) -> None:
375468
"""Ensure WebSocket connection is established on the current loop.
376469
377-
Always delegates to `connect()` rather than pre-checking `self._ws is
378-
None`: `connect()` itself is the one that knows whether an existing
379-
`_ws` is reusable (same event loop) or stale (a different one, e.g.
380-
from a prior `from_env()` call now being driven through `.sync()`'s
381-
own loop). A pre-check here that only looked at `_ws is None` would
382-
skip `connect()` entirely whenever `_ws` is already set -- including
383-
the stale-loop case -- so the reconnect logic would never run for
384-
callers that never explicitly call `.connect()` themselves (e.g.
385-
`client.sync().reset()` right after `from_env()`).
470+
Always delegates to `_connect_async()` rather than pre-checking
471+
`self._ws is None`: `_connect_async()` itself is the one that knows
472+
whether an existing `_ws` is reusable (same event loop) or stale (a
473+
different one, e.g. from a prior `from_env()` call now being driven
474+
through `.sync()`'s own loop).
386475
"""
387-
await self.connect()
476+
await self._connect_async()
388477

389478
async def _send(self, message: Dict[str, Any]) -> None:
390479
"""Send a message over the WebSocket."""
@@ -570,7 +659,10 @@ def _parse_state(self, payload: Dict[str, Any]) -> StateT:
570659
"""Convert a JSON response from the state endpoint to a State object."""
571660
raise NotImplementedError
572661

573-
async def reset(self, **kwargs: Any) -> StepResult[ObsT]:
662+
def reset(self, **kwargs: Any) -> Any:
663+
return self._dispatch(lambda: self._reset_async(**kwargs))
664+
665+
async def _reset_async(self, **kwargs: Any) -> StepResult[ObsT]:
574666
"""
575667
Reset the environment with optional parameters.
576668
@@ -588,7 +680,10 @@ async def reset(self, **kwargs: Any) -> StepResult[ObsT]:
588680
response = await self._send_and_receive(message)
589681
return self._parse_result(response.get("data", {}))
590682

591-
async def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
683+
def step(self, action: ActT, **kwargs: Any) -> Any:
684+
return self._dispatch(lambda: self._step_async(action, **kwargs))
685+
686+
async def _step_async(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
592687
"""
593688
Execute an action in the environment.
594689
@@ -608,7 +703,10 @@ async def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
608703
response = await self._send_and_receive(message)
609704
return self._parse_result(response.get("data", {}))
610705

611-
async def state(self) -> StateT:
706+
def state(self) -> Any:
707+
return self._dispatch(self._state_async)
708+
709+
async def _state_async(self) -> StateT:
612710
"""
613711
Get the current environment state from the server.
614712
@@ -619,7 +717,10 @@ async def state(self) -> StateT:
619717
response = await self._send_and_receive(message)
620718
return self._parse_state(response.get("data", {}))
621719

622-
async def close(self) -> None:
720+
def close(self) -> Any:
721+
return self._dispatch(self._close_async)
722+
723+
async def _close_async(self) -> None:
623724
"""
624725
Close the WebSocket connection and clean up resources.
625726
@@ -632,7 +733,7 @@ async def close(self) -> None:
632733
self._child_clients.clear()
633734

634735
try:
635-
await self.disconnect()
736+
await self._disconnect_async()
636737
finally:
637738
try:
638739
if self._provider is not None:
@@ -648,6 +749,7 @@ async def close(self) -> None:
648749

649750
async def __aenter__(self) -> "EnvClient":
650751
"""Enter async context manager, ensuring connection is established."""
752+
self._claim_execution_mode("async")
651753
await self.connect()
652754
return self
653755

@@ -656,17 +758,13 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
656758
await self.close()
657759

658760
def __enter__(self) -> "EnvClient":
659-
"""Sync context manager entry - raises error suggesting async usage."""
660-
raise TypeError(
661-
"EnvClient is async by default. Use 'async with' instead of 'with', "
662-
"or call .sync() to get a synchronous wrapper:\n"
663-
" async with client: # async usage\n"
664-
" with client.sync(): # sync wrapper"
665-
)
761+
"""Enter sync context manager, ensuring connection is established."""
762+
self._run_sync(self._connect_async)
763+
return self
666764

667765
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
668-
"""Sync context manager exit - should not be reached."""
669-
pass # pragma: no cover
766+
"""Exit sync context manager, closing connection."""
767+
self._run_sync(self._close_async)
670768

671769
def sync(self) -> "SyncEnvClient":
672770
"""
@@ -694,4 +792,6 @@ def sync(self) -> "SyncEnvClient":
694792
"""
695793
from .sync_client import SyncEnvClient
696794

697-
return SyncEnvClient(self)
795+
if self._sync_client is None:
796+
self._sync_client = SyncEnvClient(self)
797+
return self._sync_client

src/openenv/core/sync_client.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,13 @@ def _ensure_loop(self) -> asyncio.AbstractEventLoop:
127127
assert self._loop is not None
128128
return self._loop
129129

130+
def _claim_sync_mode(self) -> None:
131+
if hasattr(self._async, "_claim_execution_mode"):
132+
self._async._claim_execution_mode("sync")
133+
130134
def _run(self, coro: Any) -> Any:
131135
"""Run coroutine on dedicated loop and block for result."""
136+
self._claim_sync_mode()
132137
loop = self._ensure_loop()
133138
future: concurrent.futures.Future[Any] = asyncio.run_coroutine_threadsafe(
134139
coro, loop
@@ -162,12 +167,14 @@ def connect(self) -> "SyncEnvClient[ActT, ObsT, StateT]":
162167
Returns:
163168
self for method chaining
164169
"""
165-
self._run(self._async.connect())
170+
self._claim_sync_mode()
171+
self._run(self._async._connect_async())
166172
return self
167173

168174
def disconnect(self) -> None:
169175
"""Close the connection."""
170-
self._run(self._async.disconnect())
176+
self._claim_sync_mode()
177+
self._run(self._async._disconnect_async())
171178

172179
def reset(self, **kwargs: Any) -> StepResult[ObsT]:
173180
"""
@@ -180,7 +187,8 @@ def reset(self, **kwargs: Any) -> StepResult[ObsT]:
180187
Returns:
181188
StepResult containing initial observation
182189
"""
183-
return self._run(self._async.reset(**kwargs))
190+
self._claim_sync_mode()
191+
return self._run(self._async._reset_async(**kwargs))
184192

185193
def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
186194
"""
@@ -195,7 +203,8 @@ def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]:
195203
Returns:
196204
StepResult containing observation, reward, and done status
197205
"""
198-
return self._run(self._async.step(action, **kwargs))
206+
self._claim_sync_mode()
207+
return self._run(self._async._step_async(action, **kwargs))
199208

200209
def state(self) -> StateT:
201210
"""
@@ -204,7 +213,8 @@ def state(self) -> StateT:
204213
Returns:
205214
State object with environment state information
206215
"""
207-
return self._run(self._async.state())
216+
self._claim_sync_mode()
217+
return self._run(self._async._state_async())
208218

209219
def close(self) -> None:
210220
"""Close the connection and clean up resources."""
@@ -213,7 +223,8 @@ def close(self) -> None:
213223
with suppress(Exception):
214224
child.close()
215225
self._child_clients.clear()
216-
self._run(self._async.close())
226+
self._claim_sync_mode()
227+
self._run(self._async._close_async())
217228
finally:
218229
self._stop_loop()
219230

tests/envs/test_repl_env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -964,7 +964,7 @@ async def fake_send_and_receive(message):
964964
return {"data": {}}
965965
raise AssertionError(f"Unexpected message type: {message['type']}")
966966

967-
monkeypatch.setattr(env.async_client, "connect", fake_connect)
967+
monkeypatch.setattr(env.async_client, "_connect_async", fake_connect)
968968
monkeypatch.setattr(
969969
env.async_client, "_send_and_receive", fake_send_and_receive
970970
)

0 commit comments

Comments
 (0)