Skip to content

Commit 15fdc20

Browse files
committed
Deliver a bacloud response bigger than one SmartSocket message.
Three size limits along the bacloud path disagreed and nothing owned the disagreement: the client inherited websockets' 1 MiB receive default, the relay inherited aiohttp's 4 MB, and the relay gated a single send on its 4 MB resend-buffer cap -- a whole-direction budget rather than a per-message one. A message in that gap was accepted, buffered for resume, and retained and retried forever, since the relay never learns the far socket refused it. MAX_MESSAGE_BYTES and MAX_PAYLOAD_BYTES are now the single source for all three, and ChunkedResponse (bacloud v29) splits an over-cap response above the transport.
1 parent f8a5b10 commit 15fdc20

8 files changed

Lines changed: 157 additions & 37 deletions

File tree

.efrocachemap

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
### 1.8.0 (build 22995, api 9, 2026-08-21)
1+
### 1.8.0 (build 22996, api 9, 2026-08-21)
22
- Fully implemented asset packages (more on this soon)
33
- App-config committing (dirty-tracking, debounced disk writes, and
44
suspend/shutdown flushes) now lives fully in `babase` instead of routing

pconfig/projectconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"bauiv1lib": "buil"
2424
},
2525
"efrocache_repository_url": "https://files.ballistica.net/cache/ba1",
26-
"engine_build_number": 22995,
26+
"engine_build_number": 22996,
2727
"name": "BallisticaKit",
2828
"public": true,
2929
"python_paths": [
@@ -43,5 +43,5 @@
4343
"tests",
4444
"config"
4545
],
46-
"version": "1.8.0a98"
46+
"version": "1.8.0a99"
4747
}

src/assets/ba_data/python/baenv.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@
5555

5656
# Build number and version of the ballistica binary we expect to be
5757
# using.
58-
TARGET_BALLISTICA_BUILD = 22995
59-
TARGET_BALLISTICA_VERSION = '1.8.0a98'
58+
TARGET_BALLISTICA_BUILD = 22996
59+
TARGET_BALLISTICA_VERSION = '1.8.0a99'
6060

6161

6262
@dataclass

src/ballistica/shared/ballistica.cc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ auto main(int argc, char** argv) -> int {
5151
namespace ballistica {
5252

5353
// These are set automatically via script; don't modify them here.
54-
const int kEngineBuildNumber = 22995;
55-
const char* kEngineVersion = "1.8.0a98";
54+
const int kEngineBuildNumber = 22996;
55+
const char* kEngineVersion = "1.8.0a99";
5656
const int kEngineApiVersion = 9;
5757

5858
#if BA_MONOLITHIC_BUILD

tools/bacommon/bacloud.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,21 @@
208208
# hop and basn's streamcall WS fan-out. ``MIN_VERSION``
209209
# tracks ``BACLOUD_VERSION`` as usual; prod and
210210
# push-public land together.
211-
BACLOUD_VERSION = 28
211+
# 29 (2026-08): Responses larger than one SmartSocket message are
212+
# split across several. Adds ``ResponseTypeID.CHUNK``
213+
# and ``ChunkedResponse``: the session sender slices a
214+
# too-large serialized response into ordered pieces and
215+
# the client rejoins them before decoding. The transport
216+
# caps a single message deliberately (they are
217+
# load-bearing for the relay's resend-buffer and linger
218+
# math), so a big response has to be split *above* it --
219+
# previously nothing did, and a ~1.5 MB vendored asset
220+
# package was simply retained and retried by the relay
221+
# forever, presenting as a half-hour hang. ``MIN_VERSION``
222+
# tracks ``BACLOUD_VERSION`` as usual, so older clients
223+
# get the standard "please update" rejection rather than
224+
# an undecodable type id.
225+
BACLOUD_VERSION = 29
212226

213227

214228
def asset_file_cache_path(filehash: str) -> str:
@@ -281,6 +295,7 @@ class ResponseTypeID(Enum):
281295
STANDARD = 's'
282296
SESSION_HANDLE = 'sh'
283297
STREAM_OUTPUT = 'so'
298+
CHUNK = 'ch'
284299

285300

286301
class ResponseData(IOMultiType[ResponseTypeID]):
@@ -320,6 +335,9 @@ def get_type(cls, type_id: ResponseTypeID) -> type[ResponseData]:
320335
if type_id is ResponseTypeID.STREAM_OUTPUT:
321336
out = StreamOutputResponse
322337
return out
338+
if type_id is ResponseTypeID.CHUNK:
339+
out = ChunkedResponse
340+
return out
323341
raise ValueError(f'Unrecognized type-id {type_id}.')
324342

325343

@@ -962,6 +980,44 @@ def get_type_id(cls) -> ResponseTypeID:
962980
return ResponseTypeID.STREAM_OUTPUT
963981

964982

983+
@ioprepped
984+
@dataclass
985+
class ChunkedResponse(ResponseData):
986+
"""One ordered slice of a response too large for a single message.
987+
988+
The transport caps a single message on purpose -- that cap is what
989+
the relay's resend-buffer and linger math are sized against, so it
990+
does not grow to fit one caller's payload. A response past it is
991+
therefore split here, above the transport, and rejoined by the
992+
reader before it decodes anything.
993+
994+
No correlation id and no total-length field: the session delivers
995+
gaplessly and in order (that is its whole invariant), and a sender
996+
finishes one response before starting the next, so "collect until
997+
``index == count - 1``" is sufficient and cannot interleave. A
998+
reader that sees a gap has already lost the session.
999+
1000+
``data`` is a slice of the *serialized* response, not a serialized
1001+
slice -- rejoining is string concatenation, and the result decodes
1002+
exactly as it would have unsplit. That keeps every existing
1003+
response type carryable with no per-type awareness here.
1004+
"""
1005+
1006+
#: Position of this slice, from zero.
1007+
index: Annotated[int, IOAttrs('i')]
1008+
1009+
#: How many slices the whole response was split into.
1010+
count: Annotated[int, IOAttrs('n')]
1011+
1012+
#: This slice of the serialized response.
1013+
data: Annotated[str, IOAttrs('d')]
1014+
1015+
@override
1016+
@classmethod
1017+
def get_type_id(cls) -> ResponseTypeID:
1018+
return ResponseTypeID.CHUNK
1019+
1020+
9651021
@ioprepped
9661022
@dataclass
9671023
class SessionHandleResponse(ResponseData):

tools/bacommontools/bacloudsession.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@
5656

5757
from efro.error import CleanError
5858
from efro.smartsocket import (
59+
MAX_MESSAGE_BYTES,
5960
SmartSocketClosed,
6061
SmartSocketEndpoint,
6162
)
63+
from efro.dataclassio import dataclass_from_json
6264
from bacommon.bacloud import (
6365
BACLOUD_VERSION,
66+
ChunkedResponse,
6467
RequestData,
6568
ResponseData,
6669
SessionHandleResponse,
@@ -132,6 +135,12 @@ def __init__(self, ws_url: str, bearer: str | None) -> None:
132135
SmartSocketEndpoint[RequestData, ResponseData] | None
133136
) = None
134137
self._inbox: queue.Queue[ResponseData | None] = queue.Queue()
138+
139+
# Slices of a response being reassembled. The session is
140+
# gapless and in order and a sender finishes one response
141+
# before starting the next, so a plain list is enough -- there
142+
# is nothing to interleave with.
143+
self._chunks: list[str] = []
135144
#: Set once a connection has actually completed its hello.
136145
#: Until then a dial failure means 'no session here', not 'a
137146
#: session to recover'.
@@ -356,6 +365,21 @@ async def _await_connected(
356365
await asyncio.sleep(0.05)
357366

358367
async def _on_message(self, response: ResponseData) -> None:
368+
if isinstance(response, ChunkedResponse):
369+
# A response too large for one message, arriving in
370+
# ordered slices. Collect, and decode only once the last
371+
# one lands -- nothing above this layer ever learns the
372+
# response was split.
373+
self._chunks.append(response.data)
374+
if response.index + 1 < response.count:
375+
return
376+
joined = ''.join(self._chunks)
377+
self._chunks.clear()
378+
# Slices are of the serialized response, so rejoining
379+
# yields exactly what an unsplit send would have.
380+
self._inbox.put(dataclass_from_json(ResponseData, joined))
381+
return
382+
359383
if isinstance(response, SessionHandleResponse):
360384
# Not an answer to anything -- the node telling us how to
361385
# get back in. Hold it; don't hand it to a waiting caller.
@@ -426,6 +450,13 @@ async def _dial(self, headers: dict[str, str]) -> ClientConnection:
426450
# policy-driven interval; a second liveness mechanism
427451
# would only add ways to disagree.
428452
ping_interval=None,
453+
# Pin the receive limit to the protocol's own cap rather
454+
# than inheriting whatever this library defaults to. They
455+
# happened to match, but only by luck -- and the relay had
456+
# no matching per-message limit, so a response between this
457+
# and the relay's 4 MB buffer cap was sent and never
458+
# received, presenting as an unexplained hang.
459+
max_size=MAX_MESSAGE_BYTES,
429460
)
430461

431462

tools/efro/smartsocket.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,39 @@ def action_for_close_code(code: int) -> SmartSocketAction:
394394
#: every window to run the recovery matrix in seconds).
395395
LOSS_DETECTION_FLOOR_SECONDS = 15.0
396396

397+
#: Largest WebSocket message the protocol puts on the wire, in bytes.
398+
#:
399+
#: Every leg's socket-level receive limit is configured to exactly
400+
#: this, explicitly. Left to library defaults they disagree --
401+
#: ``websockets`` caps incoming messages at 1 MiB, ``aiohttp`` at 4 MB
402+
#: -- and a disagreement here does not surface as an error: the relay
403+
#: buffers each frame for resume, so a message the far socket refuses
404+
#: is *retained and retried*, not dropped. A ~1.5 MB bacloud response
405+
#: landed in exactly that gap and presented as a 30-minute hang with
406+
#: the server-side work done in 13 seconds.
407+
MAX_MESSAGE_BYTES = 1024 * 1024
408+
409+
#: Room reserved for the frame envelope around a payload (seq, type
410+
#: id, JSON punctuation). Tiny next to the cap; explicit so the
411+
#: subtraction below is not a mystery constant.
412+
_FRAME_OVERHEAD_BYTES = 1024
413+
414+
#: Largest app payload a single message may carry, in bytes.
415+
#:
416+
#: Smaller than :data:`MAX_MESSAGE_BYTES` because a payload is
417+
#: JSON-escaped *into* its frame, and the payloads here are themselves
418+
#: JSON -- every ``"`` becomes ``\\"``. Measured inflation is ~1.03x
419+
#: for typical bodies and ~1.0x for base64, but the worst case (a
420+
#: payload that is all quotes) is 2.02x, so the halving is what makes
421+
#: "this payload fits" true regardless of content rather than true for
422+
#: the bodies we happen to send today.
423+
#:
424+
#: This is the number a sender checks and a chunker splits on. It is
425+
#: load-bearing for the relay's resend-buffer and linger math, so it
426+
#: does not grow to fit one caller's message: anything larger must be
427+
#: split *above* this layer.
428+
MAX_PAYLOAD_BYTES = MAX_MESSAGE_BYTES // 2 - _FRAME_OVERHEAD_BYTES
429+
397430

398431
class SmartSocketClosed(Exception):
399432
"""Raised by a transport when its connection has closed.
@@ -494,7 +527,7 @@ def __init__(
494527
recv_type: type[RecvT],
495528
on_message: Callable[[RecvT], Awaitable[None]] | None = None,
496529
refresh: Callable[[], Awaitable[None]] | None = None,
497-
in_flight_cap_bytes: int = 1024 * 1024,
530+
in_flight_cap_bytes: int = MAX_PAYLOAD_BYTES,
498531
attach_timeout_seconds: float = 10.0,
499532
loss_detection_floor_seconds: float = (LOSS_DETECTION_FLOOR_SECONDS),
500533
logger: logging.Logger | None = None,

0 commit comments

Comments
 (0)