Files
orca/src/shared/browser-runtime-capabilities.ts
T
1038adb786 fix(browser): larger tunnel windows for high-latency links, bounded by connection credit (#38839)
* fix(browser): negotiate a larger tunnel flow-control window for high-latency links

Embedded-browser tabs routed through a paired runtime (e.g. another Mac over
Tailscale) were capped by a fixed 256 KB per-stream flow-control window. That
window also bounds per-stream throughput to window / RTT: on a ~90 ms link the
ceiling is ~2.9 MB/s per connection regardless of real bandwidth, while on a
sub-millisecond LAN the same window allows ~256 MB/s, so the limit is invisible
locally and dominant remotely.

The window value is validated symmetrically on both ends (each side rejects a
credit grant above its own constant), so simply raising the constant would break
browsing between mismatched Orca versions. Instead negotiate it: each side
advertises its maximum (client in the attach params, host in the ready event)
and both use min(own, peer), falling back to the legacy 256 KB window whenever
either side does not advertise. Advertising peers settle on a 2 MB window (~8x
the high-RTT ceiling). Retained buffers and the outbound memory budget scale to
cover the larger window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browser): track granted receive credit and scale the session retained limit

Review fixes for the window negotiation: both ends still initialized
receiveCredit to the legacy 256 KB constant while announcing the negotiated
window, so a peer legitimately using the announced 2 MB window tripped credit
validation — the client closed the tunnel and the session treated the traffic
as a protocol violation. Initialize credit to zero and assign it where the
grant is announced, so tracking always matches the announcement; a conformance
test now drives a full negotiated window in both directions unsettled.

Also scale the per-session retained-byte limit with the negotiated window
(16 windows, floored at the previous fixed 8 MB) so concurrent streams on a
slow link keep their stream-count headroom instead of being shed at four.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browser): scale the pending-chunk cap with the negotiated window

The 256-chunk pending caps paired with the legacy 256 KB window at 1 KiB
frames. Under a negotiated 2 MB window, more than 256 small unsettled frames
fit inside the byte credit, and the receiver treated that permitted traffic
as an overflow — the session closed on the destination write path and the
client fenced the whole tunnel on the receive path. Derive the cap from the
maximum window at the same 1 KiB-per-chunk pairing (2048), keeping the flood
bound for tinier frames. The conformance regression now drives a full
negotiated window of 1 KiB frames in both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browser): size claim capacity to the byte budget at chunk granularity

Each retained chunk consumes one claim, so the fixed 16,384-claim host limit
exhausted at eight stalled 2 MB windows of 1 KiB frames — 16 MiB of a 64 MiB
byte budget — and shed the next stream as a receive-buffer overflow. Derive
the claim caps from the byte budgets at the same 1 KiB-per-chunk pairing the
pending caps use, so claim exhaustion never precedes byte exhaustion for
in-protocol frames. Claims are counters, not stored entries, so the higher
caps retain no extra memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browser): bound larger tunnel windows with connection-level credit

Fixes #38053

Rework the window negotiation onto a window.v2 capability: the client
advertises network.browserTunnel.window.v2 and the host echoes it on
ready. v2 tunnels add a connection window per direction (16 MiB to the
client, 4 MiB to the host) acknowledged on receipt over stream 0, so the
queued bytes on either WebSocket stay well below its overflow cap however
many streams grow, and a stalled reader cannot starve other streams.
Older peers keep the legacy 256 KiB / 256-chunk bounds.

Streams start at the legacy 256 KiB window and grow toward 2 MiB by the
bytes their reader consumes, drawing on the connection window. Growth is
charged only while a stream holds unread data: it is released when the
reader drains and recharged when data arrives again, so idle keep-alive
sockets don't hold a fresh download at legacy speed. Connection
acknowledgements are sent after the Data frame is dispatched, so a
synchronous peer cannot reorder its next frame ahead.

Tradeoff: because idle streams are not charged, an idle grown stream that
wakes and then stalls can exceed the growth pool by its own growth. The
per-stream memory budgets still fail that stream rather than the tunnel.

Co-authored-by: jeky22 <38278199+jeky22@users.noreply.github.com>

* fix(browser): keep grown tunnel windows charged until a stream is genuinely idle

Fixes #38053

Growth was released whenever a reader drained, and Chromium drains at
once, so the connection pool barely bounded growth: 16 grown downloads
that stalled together reached the 32 MiB client budget and were reset,
and 8 stalled uploads passed the host's 12 MiB retained limit.

Growth now stays charged while a stream holds it. A stream gives its
charge up only once it is idle (drained, nothing received for 1 s) and
another stream needs the pool, so kept-alive sockets still don't slow a
fresh download. A woken idle stream is charged again; while that
overcommits the pool, consuming streams withhold credit to shrink back
toward the legacy window. Zero-credit updates are not sent.

* fix(browser): charge each tunnel frame a credit floor and complete empty writes

Fixes #38053

The WebSocket queues cap frames as well as bytes, so a connection window
spent on 1 KiB frames could queue 4,096 frames and close a paired
transport capped at 2,048. Each DATA frame now costs at least 8 KiB of
connection credit on both sides, bounding queued data frames to 2,048
toward the client and 512 toward the host.

An empty write found no credit to spend and waited forever, blocking
every later write; it now completes at once.

---------

Co-authored-by: jeky22 <38278199+jeky22@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-11-05 21:47:22 -08:00

41 lines
2.5 KiB
TypeScript

export const BROWSER_GOOGLE_COOKIE_CLEAR_CAPABILITY = 'browser.google-cookie-clear.v1' as const
// Advertise only with an available offscreen backend so remote-owned pages cannot fall back to local desktop tabs.
export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const
export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const
export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const
export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const
// Older hosts drop tabCreate.page; trust preallocated IDs only when advertised.
export const BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY =
'browser.tab-create-known-id.v1' as const
export const BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY = 'browser.clientHost.v1' as const
export const BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY =
'browser.clientHost.pageMetadata.v1' as const
export const BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY =
'browser.clientHost.automation.v1' as const
// Why: without it a client-placed browser.upload would resolve remote paths on the desktop filesystem, so uploads fail closed instead.
export const BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY =
'browser.clientHost.fileChannel.v1' as const
export const BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY = 'network.browserTunnel.v1' as const
export const BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY =
'network.browserTunnel.executionHosts.v1' as const
// Larger stream windows bounded by connection-level credit; the host echoes it on ready to opt in.
export const BROWSER_NETWORK_TUNNEL_WINDOW_V2_CAPABILITY =
'network.browserTunnel.window.v2' as const
// Older hosts drop expectUrl and would act unguarded, so the CLI refuses the flag without this.
export const BROWSER_EXPECT_URL_RUNTIME_CAPABILITY = 'browser.expect-url.v1' as const
// Headless, identity and certificate trust are host-conditional, so getStatus adds them itself.
export const BROWSER_RUNTIME_CAPABILITIES = [
BROWSER_EXPECT_URL_RUNTIME_CAPABILITY,
BROWSER_GOOGLE_COOKIE_CLEAR_CAPABILITY,
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY,
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY,
BROWSER_CLIENT_AUTOMATION_RUNTIME_CAPABILITY,
BROWSER_CLIENT_FILE_CHANNEL_RUNTIME_CAPABILITY,
BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY,
BROWSER_NETWORK_EXECUTION_HOSTS_RUNTIME_CAPABILITY
] as const