@@ -1405,12 +1405,12 @@ async def test_finalize_after_explicit_close(self, server_url: str):
14051405
14061406
14071407class TestProtocolConformance :
1408- """Tests verifying Python client matches TS protocol behavior ."""
1408+ """Tests verifying protocol-level conformance ."""
14091409
14101410 def test_handshake_stream_id_is_random (self ):
14111411 """Handshake streamId should be a random ID, not a fixed string.
14121412
1413- TS uses generateId() for handshake streamId; Python must match .
1413+ The protocol requires a random streamId for handshakes .
14141414 """
14151415 from river .codec import CodecMessageAdapter , NaiveJsonCodec
14161416 from river .session import Session
@@ -1446,7 +1446,7 @@ def test_readable_push_after_break_is_noop(self):
14461446 def test_writable_close_nullifies_callbacks (self ):
14471447 """After close(), write/close callbacks should not be invocable.
14481448
1449- TS nullifies callbacks after close to prevent reuse.
1449+ Callbacks should be nullified after close to prevent reuse.
14501450 """
14511451 from river .streams import Writable
14521452
@@ -1467,7 +1467,7 @@ def test_writable_close_nullifies_callbacks(self):
14671467 assert close_count [0 ] == 1
14681468
14691469 def test_heartbeat_stream_id_is_fixed (self ):
1470- """Heartbeat streamId should be 'heartbeat' (matching TS) ."""
1470+ """Heartbeat streamId should be the fixed string 'heartbeat'."""
14711471 from river .types import heartbeat_message
14721472
14731473 hb = heartbeat_message ()
@@ -1501,3 +1501,162 @@ def test_handshake_payload_omits_metadata_when_none(self):
15011501 metadata = None ,
15021502 )
15031503 assert "metadata" not in payload
1504+
1505+
1506+ class TestFatalErrorPaths :
1507+ """Regression tests for fatal error paths that must destroy the session.
1508+
1509+ Certain errors are not retryable and must immediately destroy
1510+ the session.
1511+ """
1512+
1513+ def test_failed_send_destroys_session (self ):
1514+ """Send failure on a connected session destroys it."""
1515+ from unittest .mock import AsyncMock
1516+
1517+ from river .codec import CodecMessageAdapter , NaiveJsonCodec
1518+ from river .session import Session , SessionState
1519+ from river .transport import WebSocketClientTransport
1520+
1521+ transport = WebSocketClientTransport (
1522+ ws_url = "ws://127.0.0.1:1" ,
1523+ client_id = "client" ,
1524+ server_id = "server" ,
1525+ codec = NaiveJsonCodec (),
1526+ )
1527+ codec = CodecMessageAdapter (NaiveJsonCodec ())
1528+ session = Session ("s1" , "client" , "server" , codec )
1529+ session .state = SessionState .CONNECTED
1530+ session ._ws = AsyncMock ()
1531+ transport .sessions ["server" ] = session
1532+
1533+ send_fn = transport .get_session_bound_send_fn ("server" , "s1" )
1534+
1535+ # A payload that can't be serialized (set is not JSON-serializable)
1536+ from river .types import PartialTransportMessage
1537+
1538+ try :
1539+ send_fn (
1540+ PartialTransportMessage (
1541+ payload = {"bad" : {1 , 2 }},
1542+ stream_id = "x" ,
1543+ control_flags = 0 ,
1544+ )
1545+ )
1546+ except RuntimeError :
1547+ pass
1548+
1549+ # Session must be destroyed
1550+ assert transport .sessions .get ("server" ) is None
1551+
1552+ def test_failed_send_seq_consumed (self ):
1553+ """Send failure does not roll back seq.
1554+
1555+ The seq is consumed and the session is destroyed instead.
1556+ """
1557+ from unittest .mock import AsyncMock
1558+
1559+ from river .codec import CodecMessageAdapter , NaiveJsonCodec
1560+ from river .session import Session , SessionState
1561+ from river .types import PartialTransportMessage
1562+
1563+ codec = CodecMessageAdapter (NaiveJsonCodec ())
1564+ session = Session ("s1" , "client" , "server" , codec )
1565+ session .state = SessionState .CONNECTED
1566+ session ._ws = AsyncMock ()
1567+
1568+ initial_seq = session .seq
1569+
1570+ ok , _ = session .send (
1571+ PartialTransportMessage (
1572+ payload = {"bad" : {1 , 2 }},
1573+ stream_id = "x" ,
1574+ control_flags = 0 ,
1575+ )
1576+ )
1577+
1578+ assert not ok
1579+ # seq was consumed (not rolled back)
1580+ assert session .seq == initial_seq + 1
1581+
1582+ def test_invalid_message_destroys_session (self ):
1583+ """Receiving a corrupt message destroys the session."""
1584+ from river .codec import CodecMessageAdapter , NaiveJsonCodec
1585+ from river .session import Session , SessionState
1586+ from river .transport import WebSocketClientTransport
1587+
1588+ transport = WebSocketClientTransport (
1589+ ws_url = "ws://127.0.0.1:1" ,
1590+ client_id = "client" ,
1591+ server_id = "server" ,
1592+ codec = NaiveJsonCodec (),
1593+ )
1594+ codec = CodecMessageAdapter (NaiveJsonCodec ())
1595+ session = Session ("s1" , "client" , "server" , codec )
1596+ session .state = SessionState .CONNECTED
1597+ transport .sessions ["server" ] = session
1598+
1599+ errors : list [dict ] = []
1600+ transport .add_event_listener ("protocolError" , lambda e : errors .append (e ))
1601+
1602+ # Feed garbage bytes
1603+ transport ._on_message_data (session , b"not valid json" , "server" )
1604+
1605+ # Session must be destroyed
1606+ assert transport .sessions .get ("server" ) is None
1607+ assert len (errors ) == 1
1608+ assert errors [0 ]["type" ] == "invalid_message"
1609+
1610+ def test_readable_broken_after_async_for_break (self ):
1611+ """Breaking out of async for marks readable as broken."""
1612+ from river .streams import Readable
1613+
1614+ r : Readable = Readable ()
1615+ r ._push_value ({"ok" : True , "payload" : 1 })
1616+
1617+ # Simulate what async for + break does: create iterator, get
1618+ # one value, then let the iterator be GC'd
1619+ it = r .__aiter__ ()
1620+ # The __del__ should mark broken
1621+ del it
1622+
1623+ assert r ._broken
1624+ # Subsequent pushes should be no-ops
1625+ r ._push_value ({"ok" : True , "payload" : 2 })
1626+ assert not r ._has_values_in_queue ()
1627+
1628+ def test_frozen_session_options (self ):
1629+ """SessionOptions is frozen — mutation raises."""
1630+ from river .session import SessionOptions
1631+
1632+ opts = SessionOptions ()
1633+ try :
1634+ opts .heartbeat_interval_ms = 999 # type: ignore[misc]
1635+ raise AssertionError ("should have raised FrozenInstanceError" )
1636+ except AttributeError :
1637+ pass # frozen dataclass raises AttributeError on mutation
1638+
1639+ def test_json_codec_large_int_encoding (self ):
1640+ """Large ints beyond JS safe integer range are encoded as $b."""
1641+ from river .codec import NaiveJsonCodec
1642+
1643+ codec = NaiveJsonCodec ()
1644+ large = 2 ** 53 + 1
1645+ buf = codec .to_buffer ({"n" : large })
1646+ decoded = codec .from_buffer (buf )
1647+ assert decoded ["n" ] == large
1648+
1649+ # Normal ints should NOT be encoded as $b
1650+ buf2 = codec .to_buffer ({"n" : 42 })
1651+ raw = buf2 .decode ("utf-8" )
1652+ assert "$b" not in raw
1653+
1654+ def test_json_codec_negative_large_int (self ):
1655+ """Negative large ints are also encoded as $b."""
1656+ from river .codec import NaiveJsonCodec
1657+
1658+ codec = NaiveJsonCodec ()
1659+ large_neg = - (2 ** 53 + 1 )
1660+ buf = codec .to_buffer ({"n" : large_neg })
1661+ decoded = codec .from_buffer (buf )
1662+ assert decoded ["n" ] == large_neg
0 commit comments