Skip to content

Commit ba94ef3

Browse files
committed
Preserve direct request ownership
1 parent 3037090 commit ba94ef3

4 files changed

Lines changed: 48 additions & 26 deletions

File tree

src/kernel/_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def __init__(
213213
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
214214
self._browser_routing = browser_routing_config_from_env()
215215
install_direct_vm_auth_stripping(self._client)
216-
install_stale_direct_vm_auth_eviction(self._client, cache=self.browser_route_cache)
216+
install_stale_direct_vm_auth_eviction(self._client)
217217

218218
@cached_property
219219
def deployments(self) -> DeploymentsResource:
@@ -612,7 +612,7 @@ def __init__(
612612
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
613613
self._browser_routing = browser_routing_config_from_env()
614614
install_async_direct_vm_auth_stripping(self._client)
615-
install_async_stale_direct_vm_auth_eviction(self._client, cache=self.browser_route_cache)
615+
install_async_stale_direct_vm_auth_eviction(self._client)
616616

617617
@cached_property
618618
def deployments(self) -> AsyncDeploymentsResource:

src/kernel/lib/browser_routing/raw_http.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import httpx
88

99
from .util import sanitize_curl_raw_params
10-
from .routing import BrowserRoute
10+
from .routing import BrowserRoute, mark_direct_vm_headers
1111
from ..._types import Body, Timeout, NotGiven, not_given
1212
from ..._models import FinalRequestOptions
1313

@@ -33,7 +33,7 @@ def request_via_browser_route(
3333
method=method.upper(),
3434
url=route.base_url.rstrip("/") + "/curl/raw",
3535
params=query,
36-
headers=headers or {},
36+
headers=mark_direct_vm_headers(headers),
3737
content=_normalize_binary_content(content),
3838
json_data=json,
3939
timeout=_normalize_timeout(timeout),
@@ -93,7 +93,7 @@ async def async_request_via_browser_route(
9393
method=method.upper(),
9494
url=route.base_url.rstrip("/") + "/curl/raw",
9595
params=query,
96-
headers=headers or {},
96+
headers=mark_direct_vm_headers(headers),
9797
content=_normalize_binary_content(content),
9898
json_data=json,
9999
timeout=_normalize_timeout(timeout),

src/kernel/lib/browser_routing/routing.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class BrowserRoutingConfig:
3333
subresources: tuple[str, ...] = field(default_factory=tuple)
3434

3535

36-
_EVICTION_HOOK_CACHE_ATTR = "_kernel_browser_route_cache"
36+
_DIRECT_VM_EVICTION_HOOK_ATTR = "_kernel_direct_vm_eviction_hook"
3737
_DIRECT_VM_AUTH_HOOK_ATTR = "_kernel_direct_vm_auth_hook"
3838
_DIRECT_VM_REQUEST_MARKER_HEADER = "x-kernel-direct-vm-request"
3939
_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION = "kernel_stale_direct_vm_auth"
@@ -207,7 +207,7 @@ def is_stale_direct_vm_auth_response(response: httpx.Response) -> bool:
207207
return bool(response.request.url.params.get("jwt"))
208208

209209

210-
def install_stale_direct_vm_auth_eviction(client: httpx.Client, *, cache: BrowserRouteCache) -> None:
210+
def install_stale_direct_vm_auth_eviction(client: httpx.Client) -> None:
211211
"""Evict stale direct-to-VM routes as soon as the response status is known.
212212
213213
httpx reads the body of a non-streamed response inside `send()`, so a caller
@@ -222,34 +222,38 @@ def install_stale_direct_vm_auth_eviction(client: httpx.Client, *, cache: Browse
222222
failing body or raising.
223223
"""
224224
hooks = client.event_hooks.setdefault("response", [])
225-
if _has_eviction_hook(hooks, cache):
225+
if any(getattr(hook, _DIRECT_VM_EVICTION_HOOK_ATTR, False) for hook in hooks):
226226
return
227227

228228
def handle_response(response: httpx.Response) -> None:
229-
request_cache = _direct_vm_route_cache(response.request) or cache
229+
request_cache = _direct_vm_route_cache(response.request)
230+
if request_cache is None:
231+
return
230232
_reject_unreplayable_direct_vm_redirect(response, cache=request_cache)
231233
if is_stale_direct_vm_auth_response(response):
232234
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
233235
maybe_evict_browser_route_from_response(response, cache=request_cache)
234236

235-
setattr(handle_response, _EVICTION_HOOK_CACHE_ATTR, cache)
237+
setattr(handle_response, _DIRECT_VM_EVICTION_HOOK_ATTR, True)
236238
hooks.insert(0, handle_response)
237239

238240

239-
def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient, *, cache: BrowserRouteCache) -> None:
241+
def install_async_stale_direct_vm_auth_eviction(client: httpx.AsyncClient) -> None:
240242
"""Async counterpart of `install_stale_direct_vm_auth_eviction`."""
241243
hooks = client.event_hooks.setdefault("response", [])
242-
if _has_eviction_hook(hooks, cache):
244+
if any(getattr(hook, _DIRECT_VM_EVICTION_HOOK_ATTR, False) for hook in hooks):
243245
return
244246

245247
async def handle_response(response: httpx.Response) -> None:
246-
request_cache = _direct_vm_route_cache(response.request) or cache
248+
request_cache = _direct_vm_route_cache(response.request)
249+
if request_cache is None:
250+
return
247251
_reject_unreplayable_direct_vm_redirect(response, cache=request_cache)
248252
if is_stale_direct_vm_auth_response(response):
249253
response.request.extensions[_STALE_DIRECT_VM_AUTH_REQUEST_EXTENSION] = True
250254
maybe_evict_browser_route_from_response(response, cache=request_cache)
251255

252-
setattr(handle_response, _EVICTION_HOOK_CACHE_ATTR, cache)
256+
setattr(handle_response, _DIRECT_VM_EVICTION_HOOK_ATTR, True)
253257
hooks.insert(0, handle_response)
254258

255259

@@ -281,12 +285,6 @@ async def strip_auth(request: httpx.Request) -> None:
281285
hooks.append(strip_auth)
282286

283287

284-
def _has_eviction_hook(hooks: list[Any], cache: BrowserRouteCache) -> bool:
285-
# A copied client shares both the httpx client and the route cache, so the
286-
# hook is registered once per cache instead of once per client.
287-
return any(getattr(hook, _EVICTION_HOOK_CACHE_ATTR, None) is cache for hook in hooks)
288-
289-
290288
def _reject_unreplayable_direct_vm_redirect(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
291289
if not response.has_redirect_location or not direct_vm_request_body_is_known_unreplayable(response.request):
292290
return
@@ -469,6 +467,12 @@ def rewrite_direct_vm_options(
469467
return rewritten
470468

471469

470+
def mark_direct_vm_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
471+
marked = dict(headers or {})
472+
marked[_DIRECT_VM_REQUEST_MARKER_HEADER] = "true"
473+
return marked
474+
475+
472476
def prepare_direct_vm_request(request: httpx.Request, *, cache: BrowserRouteCache) -> None:
473477
if request.headers.pop(_DIRECT_VM_REQUEST_MARKER_HEADER, None) is None:
474478
return

tests/test_browser_routing.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ def test_browser_request_uses_curl_raw() -> None:
190190
request = cast(httpx.Request, cast(Any, route.calls[0]).request)
191191
assert "curl/raw" in str(request.url)
192192
assert request.url.params.get("jwt") == "token-abc"
193+
assert request.headers.get("Authorization") is None
194+
assert request.headers.get("x-kernel-direct-vm-request") is None
193195

194196

195197
@respx.mock
@@ -344,6 +346,8 @@ async def test_async_raw_browser_create_warms_route_cache() -> None:
344346
assert routed.content == b"ok"
345347
request = cast(httpx.Request, cast(Any, routed_request.calls[0]).request)
346348
assert request.url.params.get("jwt") == "token-abc"
349+
assert request.headers.get("Authorization") is None
350+
assert request.headers.get("x-kernel-direct-vm-request") is None
347351

348352

349353
@respx.mock
@@ -1727,9 +1731,15 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
17271731

17281732

17291733
@pytest.mark.parametrize("origin_first", [True, False])
1730-
def test_direct_vm_redirect_evicts_originating_cache_when_http_client_is_shared(
1734+
@pytest.mark.parametrize(
1735+
("status_code", "expected_error"),
1736+
[(307, APIConnectionError), (401, AuthenticationError)],
1737+
)
1738+
def test_direct_vm_failure_evicts_originating_cache_when_http_client_is_shared(
17311739
monkeypatch: pytest.MonkeyPatch,
17321740
origin_first: bool,
1741+
status_code: int,
1742+
expected_error: type[Exception],
17331743
) -> None:
17341744
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
17351745
requests: list[tuple[httpx.URL, bytes]] = []
@@ -1740,7 +1750,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response:
17401750
body = b"".join(cast(Iterator[bytes], request.stream))
17411751
requests.append((request.url, body))
17421752
return httpx.Response(
1743-
307,
1753+
status_code,
17441754
headers={"location": "http://other-vm.test/browser/kernel/fs/write_file"},
17451755
)
17461756

@@ -1760,8 +1770,9 @@ def make_client() -> Kernel:
17601770
other_client, client = make_client(), make_client()
17611771

17621772
try:
1773+
assert len(http_client.event_hooks["response"]) == 1
17631774
_cache_browser(client)
1764-
with pytest.raises(APIConnectionError):
1775+
with pytest.raises(expected_error):
17651776
client.browsers.fs.write_file("sess-1", _UnseekableFile(b"payload"), path="/tmp/x")
17661777
assert client.browser_route_cache.get("sess-1") is None
17671778
assert other_client.browser_route_cache.get("sess-1") is None
@@ -1778,9 +1789,15 @@ def make_client() -> Kernel:
17781789

17791790
@pytest.mark.asyncio
17801791
@pytest.mark.parametrize("origin_first", [True, False])
1781-
async def test_async_direct_vm_redirect_evicts_originating_cache_when_http_client_is_shared(
1792+
@pytest.mark.parametrize(
1793+
("status_code", "expected_error"),
1794+
[(308, APIConnectionError), (403, PermissionDeniedError)],
1795+
)
1796+
async def test_async_direct_vm_failure_evicts_originating_cache_when_http_client_is_shared(
17821797
monkeypatch: pytest.MonkeyPatch,
17831798
origin_first: bool,
1799+
status_code: int,
1800+
expected_error: type[Exception],
17841801
) -> None:
17851802
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
17861803
requests: list[tuple[httpx.URL, bytes]] = []
@@ -1791,7 +1808,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
17911808
body = b"".join([chunk async for chunk in cast(AsyncIterator[bytes], request.stream)])
17921809
requests.append((request.url, body))
17931810
return httpx.Response(
1794-
308,
1811+
status_code,
17951812
headers={"location": "http://other-vm.test/browser/kernel/fs/write_file"},
17961813
)
17971814

@@ -1811,10 +1828,11 @@ def make_client() -> AsyncKernel:
18111828
other_client, client = make_client(), make_client()
18121829

18131830
try:
1831+
assert len(http_client.event_hooks["response"]) == 1
18141832
route = browser_route_from_browser(_fake_browser())
18151833
assert route is not None
18161834
client.browser_route_cache.set(route)
1817-
with pytest.raises(APIConnectionError):
1835+
with pytest.raises(expected_error):
18181836
await client.browsers.fs.write_file(
18191837
"sess-1",
18201838
cast(Any, _UnreplayableAsyncBody(b"payload")),

0 commit comments

Comments
 (0)