diff --git a/verifiers/serve/server/env_worker.py b/verifiers/serve/server/env_worker.py index 137714861f..0e76d6a2c0 100644 --- a/verifiers/serve/server/env_worker.py +++ b/verifiers/serve/server/env_worker.py @@ -257,9 +257,26 @@ async def send_error_response(error: str) -> None: async def stats_loop(self, interval: float = 10.0) -> None: """Loop to push worker stats to the router.""" + libc = None + try: + import ctypes + + libc = ctypes.CDLL("libc.so.6") + except OSError: + pass while True: await asyncio.sleep(interval) + # Return freed arena pages to the OS at the heartbeat cadence. + # Long multimodal rollouts churn hundreds of MB of boxed objects + # per rollout; without trim the worker's RSS ratchets to its + # high-water mark (~3x the live set, measured). ctypes calls + # release the GIL, so this never stalls the loop — unlike + # gc.collect(), which must NOT be called here (full collections + # on fat heaps are what caused worker heartbeat timeouts). + if libc is not None: + libc.malloc_trim(0) + stats = EnvWorkerStats( worker_id=self.worker_id, timestamp=time.time(), diff --git a/verifiers/v1/utils/endpoint_utils.py b/verifiers/v1/utils/endpoint_utils.py index 161d3c9a5b..09583a650a 100644 --- a/verifiers/v1/utils/endpoint_utils.py +++ b/verifiers/v1/utils/endpoint_utils.py @@ -224,6 +224,18 @@ def rollout_queue(self, rollout_key: str) -> asyncio.Queue[str]: def get_request(self, request_id: str) -> ConfigData: return cast(ConfigData, self.server.intercepts[request_id]) + def discard_request(self, request_id: str) -> None: + """Drop a delivered intercept from the server's per-request store. + + Each intercept retains the raw request body — the full message + history including in-sandbox base64 screenshots — and the server + only sweeps them at rollout unregister, so without per-delivery + discard a long browser rollout holds every turn's request body + simultaneously (~74% of env-worker memory measured). The HTTP + handler keeps its own local reference, so delivery is unaffected. + """ + self.server.intercepts.pop(request_id, None) + def request_context( self, request_id: str, request: ConfigData ) -> ModelRequestContext: @@ -450,14 +462,17 @@ async def forward_request( state._set_error(error_info(e)) raise finally: - if bool(request.get("stream")): - if request.get("protocol") != "openai_chat_completions": - raise NotImplementedError( - "Streaming interception is currently supported for OpenAI Chat Completions." - ) - await synthesize_stream(request, response, error) - else: - deliver_response(request, response, error) + try: + if bool(request.get("stream")): + if request.get("protocol") != "openai_chat_completions": + raise NotImplementedError( + "Streaming interception is currently supported for OpenAI Chat Completions." + ) + await synthesize_stream(request, response, error) + else: + deliver_response(request, response, error) + finally: + endpoint.discard_request(request_id) def normalize_endpoint_prompt(request: ConfigData) -> Messages: