tigrcorn-protocols 0.3.18.dev2__py3-none-any.whl → 0.3.18.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. tigrcorn_protocols/http2/handler/connect.py +8 -0
  2. tigrcorn_protocols/http2/handler/core.py +6 -0
  3. tigrcorn_protocols/http2/handler/frames.py +1 -0
  4. tigrcorn_protocols/http2/handler/imports.py +1 -0
  5. tigrcorn_protocols/http2/handler/inventory.py +56 -0
  6. tigrcorn_protocols/http2/handler/streams.py +12 -0
  7. tigrcorn_protocols/http2/handler/websocket_support.py +10 -1
  8. tigrcorn_protocols/http3/codec.py +2 -0
  9. tigrcorn_protocols/http3/handler/connect.py +15 -2
  10. tigrcorn_protocols/http3/handler/core.py +10 -1
  11. tigrcorn_protocols/http3/handler/custom.py +1 -1
  12. tigrcorn_protocols/http3/handler/imports.py +6 -0
  13. tigrcorn_protocols/http3/handler/lifecycle.py +77 -1
  14. tigrcorn_protocols/http3/handler/outbound.py +113 -19
  15. tigrcorn_protocols/http3/handler/packet.py +118 -21
  16. tigrcorn_protocols/http3/handler/priority_lock.py +107 -0
  17. tigrcorn_protocols/http3/handler/requests.py +15 -2
  18. tigrcorn_protocols/http3/handler/responses.py +5 -5
  19. tigrcorn_protocols/http3/handler/session.py +16 -1
  20. tigrcorn_protocols/http3/handler/websocket_runtime.py +32 -3
  21. tigrcorn_protocols/http3/handler/webtransport.py +232 -74
  22. tigrcorn_protocols/http3/handler/webtransport_datagrams.py +1 -1
  23. tigrcorn_protocols/http3/handler/webtransport_send.py +166 -0
  24. tigrcorn_protocols/http3/handler/webtransport_stream_flow.py +104 -0
  25. tigrcorn_protocols/http3/handler/webtransport_streams.py +100 -5
  26. tigrcorn_protocols/http3/handler/webtransport_support.py +155 -0
  27. tigrcorn_protocols/sessions/__init__.py +27 -1
  28. tigrcorn_protocols/sessions/inventory.py +317 -0
  29. tigrcorn_protocols/webtransport/__init__.py +44 -0
  30. tigrcorn_protocols/webtransport/governance.py +5 -0
  31. tigrcorn_protocols/webtransport/negotiation.py +107 -0
  32. tigrcorn_protocols/webtransport/profiles.py +179 -0
  33. {tigrcorn_protocols-0.3.18.dev2.dist-info → tigrcorn_protocols-0.3.18.dev3.dist-info}/METADATA +7 -7
  34. {tigrcorn_protocols-0.3.18.dev2.dist-info → tigrcorn_protocols-0.3.18.dev3.dist-info}/RECORD +37 -30
  35. {tigrcorn_protocols-0.3.18.dev2.dist-info → tigrcorn_protocols-0.3.18.dev3.dist-info}/WHEEL +1 -1
  36. {tigrcorn_protocols-0.3.18.dev2.dist-info → tigrcorn_protocols-0.3.18.dev3.dist-info}/licenses/LICENSE +0 -0
  37. {tigrcorn_protocols-0.3.18.dev2.dist-info → tigrcorn_protocols-0.3.18.dev3.dist-info}/top_level.txt +0 -0
@@ -67,6 +67,7 @@ class _HTTP2ConnectTunnel:
67
67
  if self.work_lease is not None:
68
68
  self.work_lease.release()
69
69
  await close_tcp_writer(self.upstream_writer)
70
+ self.handler._close_inventory_session(self.stream_id, reason="connect-complete")
70
71
  self.handler._finalize_stream_if_complete(self.stream_id)
71
72
 
72
73
  async def _relay_upstream_to_client(self) -> None:
@@ -108,6 +109,7 @@ class HTTP2ConnectMixin:
108
109
  await self._write_raw(serialize_rst_stream(stream_id, H2_CONNECT_ERROR))
109
110
  state.mark_reset_sent()
110
111
  self._cancel_stream(stream_id)
112
+ self._close_inventory_session(stream_id, reason="connect-reset")
111
113
  self.streams.close(stream_id)
112
114
  self._maybe_finish_after_goaway()
113
115
 
@@ -163,11 +165,17 @@ class HTTP2ConnectMixin:
163
165
  work_lease=self.stream_work_leases.get(stream_id),
164
166
  )
165
167
  state.connect_tunnel = tunnel
168
+ self._open_inventory_session(
169
+ stream_id,
170
+ kind="connect-tunnel",
171
+ metadata={"authority": request.target},
172
+ )
166
173
  self.state.last_stream_id = max(self.state.last_stream_id, stream_id)
167
174
  try:
168
175
  await tunnel.start()
169
176
  except Exception:
170
177
  state.connect_tunnel = None
178
+ self._close_inventory_session(stream_id, reason="connect-start-failed")
171
179
  await close_tcp_writer(upstream_writer)
172
180
  raise
173
181
  if state.end_stream_received:
@@ -5,6 +5,7 @@ from .connect import HTTP2ConnectMixin
5
5
  from .flow_control import HTTP2FlowControlMixin
6
6
  from .frames import HTTP2FrameDispatchMixin
7
7
  from .headers import HTTP2HeadersMixin
8
+ from .inventory import HTTP2InventoryMixin
8
9
  from .io import HTTP2IOMixin
9
10
  from .requests import HTTP2RequestsMixin
10
11
  from .responses import HTTP2ResponsesMixin
@@ -22,6 +23,7 @@ class HTTP2ConnectionHandler(
22
23
  HTTP2ResponsesMixin,
23
24
  HTTP2ConnectMixin,
24
25
  HTTP2WebSocketSupportMixin,
26
+ HTTP2InventoryMixin,
25
27
  ):
26
28
  def __init__(
27
29
  self,
@@ -38,6 +40,8 @@ class HTTP2ConnectionHandler(
38
40
  scheme: str,
39
41
  prebuffer: bytes = b"",
40
42
  scope_extensions: dict | None = None,
43
+ connection_id: str | None = None,
44
+ connection_inventory: RuntimeConnectionInventory | None = None,
41
45
  ) -> None:
42
46
  self.app = app
43
47
  self.config = config
@@ -51,6 +55,8 @@ class HTTP2ConnectionHandler(
51
55
  self.scheme = scheme
52
56
  self.prebuffer = prebuffer
53
57
  self.scope_extensions = dict(scope_extensions or {})
58
+ self.connection_id = connection_id
59
+ self.connection_inventory = connection_inventory
54
60
  self.state = H2ConnectionState()
55
61
  self.state.local_settings[SETTING_MAX_CONCURRENT_STREAMS] = self.config.http.http2_max_concurrent_streams
56
62
  self.state.local_settings[SETTING_MAX_HEADER_LIST_SIZE] = self.config.http.http2_max_headers_size
@@ -116,6 +116,7 @@ class HTTP2FrameDispatchMixin:
116
116
  await state.connect_tunnel.abort()
117
117
  self._cancel_stream(frame.stream_id)
118
118
  state.mark_reset_received()
119
+ self._close_inventory_session(frame.stream_id, reason="rst-stream")
119
120
  self.streams.close(frame.stream_id)
120
121
  self._notify_waiter(frame.stream_id)
121
122
  self._maybe_finish_after_goaway()
@@ -63,6 +63,7 @@ from tigrcorn_protocols.scheduler.runtime import ProductionScheduler, WorkLease
63
63
  from tigrcorn_protocols.http2.streams import H2StreamRegistry
64
64
  from tigrcorn_protocols.connect import close_tcp_writer, half_close_tcp_writer, is_connect_allowed, parse_connect_authority
65
65
  from tigrcorn_protocols.http2.websocket import H2WebSocketSession
66
+ from tigrcorn_protocols.sessions import RuntimeConnectionInventory
66
67
  from tigrcorn_core.types import ASGIApp
67
68
  from tigrcorn_core.utils.authority import authority_allowed
68
69
  from tigrcorn_core.utils.headers import apply_response_header_policy, sanitize_early_hints_headers, strip_connection_specific_headers
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from .imports import *
4
+
5
+
6
+ class HTTP2InventoryMixin:
7
+ def _inventory_session_id(self, stream_id: int) -> str | None:
8
+ if self.connection_inventory is None or self.connection_id is None:
9
+ return None
10
+ return f"h2:{self.connection_id}:{stream_id}"
11
+
12
+ def _open_inventory_session(
13
+ self,
14
+ stream_id: int,
15
+ *,
16
+ kind: str,
17
+ metadata: dict | None = None,
18
+ ) -> str | None:
19
+ session_id = self._inventory_session_id(stream_id)
20
+ if session_id is None:
21
+ return None
22
+ payload = {"protocol": "http2", "stream_id": stream_id}
23
+ payload.update(metadata or {})
24
+ self.connection_inventory.open_session(
25
+ session_id,
26
+ connection_id=self.connection_id,
27
+ kind=kind,
28
+ stream_ids=(str(stream_id),),
29
+ metadata=payload,
30
+ )
31
+ self.connection_inventory.increment_connection_counter(self.connection_id, "streams")
32
+ if kind == "http-request":
33
+ self.connection_inventory.increment_connection_counter(self.connection_id, "requests")
34
+ elif kind == "websocket":
35
+ self.connection_inventory.increment_connection_counter(self.connection_id, "websockets")
36
+ elif kind == "connect-tunnel":
37
+ self.connection_inventory.increment_connection_counter(self.connection_id, "connect_tunnels")
38
+ return session_id
39
+
40
+ def _update_inventory_session(
41
+ self,
42
+ stream_id: int,
43
+ *,
44
+ state: str | None = None,
45
+ metadata: dict | None = None,
46
+ ) -> None:
47
+ session_id = self._inventory_session_id(stream_id)
48
+ if session_id is None:
49
+ return
50
+ self.connection_inventory.update_session(session_id, state=state, metadata=metadata)
51
+
52
+ def _close_inventory_session(self, stream_id: int, *, reason: str | None = None) -> None:
53
+ session_id = self._inventory_session_id(stream_id)
54
+ if session_id is None:
55
+ return
56
+ self.connection_inventory.close_session(session_id, reason=reason)
@@ -51,6 +51,12 @@ class HTTP2StreamsMixin:
51
51
  await self._start_connect_tunnel(stream_id)
52
52
  return
53
53
  self.state.last_stream_id = max(self.state.last_stream_id, stream_id)
54
+ request = self._build_request(state)
55
+ self._open_inventory_session(
56
+ stream_id,
57
+ kind="http-request",
58
+ metadata={"method": request.method, "path": request.path},
59
+ )
54
60
  task = asyncio.create_task(self._run_stream(stream_id), name=f"tigrcorn-h2-stream-{stream_id}")
55
61
  self.stream_tasks[stream_id] = task
56
62
 
@@ -66,6 +72,7 @@ class HTTP2StreamsMixin:
66
72
  if state is not None:
67
73
  state.websocket_session = None
68
74
  self._release_stream_work_lease(stream_id)
75
+ self._close_inventory_session(stream_id, reason="websocket-complete")
69
76
  self._finalize_stream_if_complete(stream_id)
70
77
 
71
78
 
@@ -87,6 +94,7 @@ class HTTP2StreamsMixin:
87
94
  if state is None or state.websocket_session is not None or state.connect_tunnel is not None:
88
95
  return
89
96
  if state.local_closed and state.end_stream_received:
97
+ self._close_inventory_session(stream_id, reason="stream-complete")
90
98
  self._release_stream_work_lease(stream_id)
91
99
  self._cancel_stream(stream_id)
92
100
  self.streams.close(stream_id)
@@ -125,6 +133,7 @@ class HTTP2StreamsMixin:
125
133
  self._finalize_stream_if_complete(stream_id)
126
134
  self._maybe_finish_after_goaway()
127
135
  finally:
136
+ self._close_inventory_session(stream_id, reason="request-complete")
128
137
  self._release_stream_work_lease(stream_id)
129
138
 
130
139
 
@@ -141,14 +150,17 @@ class HTTP2StreamsMixin:
141
150
  if state.websocket_session is not None:
142
151
  with suppress(Exception):
143
152
  await state.websocket_session.abort()
153
+ self._close_inventory_session(state.stream_id, reason="shutdown")
144
154
  if state.connect_tunnel is not None:
145
155
  with suppress(Exception):
146
156
  await state.connect_tunnel.abort()
157
+ self._close_inventory_session(state.stream_id, reason="shutdown")
147
158
  for stream_id, task in list(self.stream_tasks.items()):
148
159
  task.cancel()
149
160
  with suppress(asyncio.CancelledError):
150
161
  await task
151
162
  self.stream_tasks.pop(stream_id, None)
163
+ self._close_inventory_session(stream_id, reason="shutdown")
152
164
  if not self.state.local_goaway_sent:
153
165
  self.state.local_goaway_sent = True
154
166
  self.state.local_goaway_last_stream_id = self.state.last_stream_id
@@ -41,6 +41,15 @@ class HTTP2WebSocketSupportMixin:
41
41
  on_close=lambda stream_id=stream_id: self._on_websocket_stream_closed(stream_id),
42
42
  )
43
43
  state.websocket_session = session
44
+ self._open_inventory_session(
45
+ stream_id,
46
+ kind="websocket",
47
+ metadata={"path": request.path},
48
+ )
44
49
  self.state.last_stream_id = max(self.state.last_stream_id, stream_id)
45
- await session.start()
50
+ try:
51
+ await session.start()
52
+ except Exception:
53
+ self._close_inventory_session(stream_id, reason="websocket-start-failed")
54
+ raise
46
55
 
@@ -25,6 +25,7 @@ SETTING_ENABLE_CONNECT_PROTOCOL = 0x08
25
25
  SETTING_H3_DATAGRAM = 0x33
26
26
  SETTING_ENABLE_WEBTRANSPORT = 0x2B603742
27
27
  SETTING_WT_MAX_SESSIONS = 0x14E9CD29
28
+ SETTING_WT_ENABLED = 0x2C7CF000
28
29
 
29
30
  H3_DATAGRAM_ERROR = 0x33
30
31
  H3_NO_ERROR = 0x0100
@@ -69,6 +70,7 @@ HTTP3_IANA_SETTINGS = {
69
70
  "SETTINGS_ENABLE_CONNECT_PROTOCOL": SETTING_ENABLE_CONNECT_PROTOCOL,
70
71
  "SETTINGS_H3_DATAGRAM": SETTING_H3_DATAGRAM,
71
72
  "SETTINGS_WT_MAX_SESSIONS": SETTING_WT_MAX_SESSIONS,
73
+ "SETTINGS_WT_ENABLED": SETTING_WT_ENABLED,
72
74
  }
73
75
 
74
76
  HTTP3_IANA_ERROR_CODES = {
@@ -70,6 +70,11 @@ class _HTTP3ConnectTunnel:
70
70
  with suppress(asyncio.CancelledError):
71
71
  await self.relay_task
72
72
  self.session.connect_tunnels.pop(self.stream_id, None)
73
+ if self.handler.connection_inventory is not None:
74
+ self.handler.connection_inventory.close_session(
75
+ f"{self.handler._connection_id_for_session(self.session)}:connect:{self.stream_id}",
76
+ reason='connect-abort',
77
+ )
73
78
  lease = self.session.stream_work_leases.pop(self.stream_id, None)
74
79
  if lease is not None:
75
80
  lease.release()
@@ -128,7 +133,7 @@ class HTTP3ConnectMixin:
128
133
  already_locked: bool = False,
129
134
  ) -> None:
130
135
  if not already_locked:
131
- async with self._lock:
136
+ async with session.lock:
132
137
  await self._send_http3_tunnel_data(
133
138
  session,
134
139
  stream_id,
@@ -154,7 +159,7 @@ class HTTP3ConnectMixin:
154
159
  already_locked: bool = False,
155
160
  ) -> None:
156
161
  if not already_locked:
157
- async with self._lock:
162
+ async with session.lock:
158
163
  await self._reset_http3_tunnel_stream(
159
164
  session,
160
165
  stream_id,
@@ -252,6 +257,14 @@ class HTTP3ConnectMixin:
252
257
  work_lease=session.stream_work_leases.get(stream_id),
253
258
  )
254
259
  session.connect_tunnels[stream_id] = tunnel
260
+ if self.connection_inventory is not None:
261
+ self.connection_inventory.open_session(
262
+ f"{self._connection_id_for_session(session)}:connect:{stream_id}",
263
+ connection_id=self._connection_id_for_session(session),
264
+ kind='connect-tunnel',
265
+ stream_ids=(str(stream_id),),
266
+ metadata={'authority': authority, 'protocol': 'http3'},
267
+ )
255
268
  tunnel.start()
256
269
  self.access_logger.log_http(session.addr, 'CONNECT', authority, 200, 'HTTP/3')
257
270
  return self._build_http3_response_datagrams_locked(session, stream_id, 200, [], b'', end_stream=False)
@@ -6,16 +6,19 @@ from .custom import HTTP3CustomQuicMixin
6
6
  from .lifecycle import HTTP3LifecycleMixin
7
7
  from .outbound import HTTP3OutboundMixin
8
8
  from .packet import HTTP3PacketMixin
9
+ from .priority_lock import PriorityLock
9
10
  from .requests import HTTP3RequestsMixin
10
11
  from .responses import HTTP3ResponsesMixin
11
12
  from .websocket_runtime import HTTP3WebSocketRuntimeMixin
12
13
  from .webtransport_datagrams import HTTP3WebTransportDatagramsMixin
14
+ from .webtransport_stream_flow import HTTP3WebTransportStreamFlowMixin
13
15
  from .webtransport_streams import HTTP3WebTransportStreamsMixin
14
16
  from .webtransport_support import HTTP3WebTransportSupportMixin
15
17
 
16
18
 
17
19
  class HTTP3DatagramHandler(
18
20
  HTTP3PacketMixin,
21
+ HTTP3WebTransportStreamFlowMixin,
19
22
  HTTP3WebTransportStreamsMixin,
20
23
  HTTP3WebTransportDatagramsMixin,
21
24
  HTTP3WebTransportSupportMixin,
@@ -41,6 +44,9 @@ class HTTP3DatagramHandler(
41
44
  scheduler: ProductionScheduler | None = None,
42
45
  metrics: Metrics | None = None,
43
46
  webtransport_governance: Any | None = None,
47
+ connection_inventory: RuntimeConnectionInventory | None = None,
48
+ congestion_controller_factory: Any | None = None,
49
+ congestion_controller_options: Any | None = None,
44
50
  ) -> None:
45
51
  self.app = app
46
52
  self.config = config
@@ -49,10 +55,13 @@ class HTTP3DatagramHandler(
49
55
  self.scheduler = scheduler
50
56
  self.metrics = metrics
51
57
  self.webtransport_governance = webtransport_governance
58
+ self.connection_inventory = connection_inventory
59
+ self.congestion_controller_factory = congestion_controller_factory
60
+ self.congestion_controller_options = dict(congestion_controller_options or {})
52
61
  self.sessions: dict[tuple[str, int], HTTP3Session] = {}
53
62
  self.sessions_by_local_cid: dict[bytes, HTTP3Session] = {}
54
63
  self._session_sequence = 0
55
- self._lock = asyncio.Lock()
64
+ self._lock = PriorityLock()
56
65
  self.webtransport_trace: list[dict[str, object]] = []
57
66
 
58
67
 
@@ -47,7 +47,7 @@ class _CustomQuicSend:
47
47
  raise RuntimeError(f'unexpected custom quic send event: {typ!r}')
48
48
  data = bytes(message.get('data', b''))
49
49
  fin = not bool(message.get('more_data', False))
50
- self.messages.append(self.session.quic.send_stream_data(self.stream_id, data, fin=fin))
50
+ self.messages.extend(self.session.quic.send_stream_data_packets(self.stream_id, data, fin=fin))
51
51
 
52
52
  def flush(self) -> list[bytes]:
53
53
  return list(self.messages)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import logging
5
+ import time
4
6
  from contextlib import suppress
5
7
  from dataclasses import dataclass, field
6
8
  from typing import Any
@@ -31,6 +33,7 @@ from tigrcorn_protocols.http3.codec import (
31
33
  SETTING_MAX_FIELD_SECTION_SIZE,
32
34
  SETTING_QPACK_MAX_TABLE_CAPACITY,
33
35
  SETTING_WT_MAX_SESSIONS,
36
+ SETTING_WT_ENABLED,
34
37
  HTTP3ConnectionError,
35
38
  HTTP3StreamError,
36
39
  encode_frame,
@@ -42,7 +45,10 @@ from tigrcorn_protocols.http3.streams import (
42
45
  HTTP3ConnectionCore,
43
46
  )
44
47
  from tigrcorn_protocols.http3.websocket import H3WebSocketSession
48
+ from tigrcorn_protocols.sessions import RuntimeConnectionInventory, peer_id_from_address
45
49
  from tigrcorn_protocols.webtransport.governance import WebTransportGovernanceError
50
+ from tigrcorn_protocols.webtransport.negotiation import negotiate_profiles, settings_for_profiles
51
+ from tigrcorn_protocols.webtransport.profiles import conflicting_request_profile, missing_peer_requirement, missing_request_requirement, profile_spec
46
52
  from tigrcorn_transports.quic.connection import QuicConnection
47
53
  from tigrcorn_transports.quic.handshake import QuicTlsHandshakeDriver, TransportParameters
48
54
  from tigrcorn_transports.quic.packets import QuicLongHeaderPacket, QuicLongHeaderType, QuicRetryPacket, QuicShortHeaderPacket, QuicVersionNegotiationPacket, decode_packet
@@ -25,6 +25,62 @@ class HTTP3LifecycleMixin:
25
25
  def _webtransport_address(addr: tuple[str, int]) -> str:
26
26
  return f"{addr[0]}:{addr[1]}"
27
27
 
28
+ def _connection_id_for_session(self, session: HTTP3Session) -> str:
29
+ return f"conn:h3:{session.runtime_id or session.quic.local_cid.hex() or 'session'}"
30
+
31
+ def _listener_id(self) -> str:
32
+ return self.listener.label or f"{self.listener.kind}:{self.listener.host}:{self.listener.port}"
33
+
34
+ def _register_h3_connection(self, session: HTTP3Session) -> None:
35
+ inventory = self.connection_inventory
36
+ if inventory is None:
37
+ return
38
+ address = self._webtransport_address(session.addr)
39
+ handshake = session.quic.handshake_driver
40
+ inventory.open_connection(
41
+ self._connection_id_for_session(session),
42
+ transport='quic',
43
+ protocols=tuple(sorted(self.listener.enabled_protocols)),
44
+ listener_id=self._listener_id(),
45
+ peer_id=peer_id_from_address(address),
46
+ remote_address=address,
47
+ local_address=self.listener.label,
48
+ security={
49
+ 'alpn': getattr(handshake, 'selected_alpn', None) if handshake is not None else None,
50
+ 'local_cid': session.quic.local_cid.hex(),
51
+ 'remote_cid': session.quic.remote_cid.hex() if isinstance(session.quic.remote_cid, bytes) else None,
52
+ 'tls': bool(handshake is not None),
53
+ },
54
+ )
55
+
56
+ def _update_h3_connection(self, session: HTTP3Session) -> None:
57
+ inventory = self.connection_inventory
58
+ if inventory is None:
59
+ return
60
+ inventory.update_connection(
61
+ self._connection_id_for_session(session),
62
+ remote_address=self._webtransport_address(session.addr),
63
+ counters={
64
+ 'bytes_received': session.bytes_received,
65
+ 'bytes_sent': session.bytes_sent,
66
+ 'requests': len(session.responded_streams),
67
+ 'streams': len(session.h3.requests) + len(session.webtransport_streams),
68
+ 'webtransport_sessions': len(session.webtransport_sessions),
69
+ 'websocket_sessions': len(session.websocket_sessions),
70
+ },
71
+ security={
72
+ 'local_cid': session.quic.local_cid.hex(),
73
+ 'remote_cid': session.quic.remote_cid.hex() if isinstance(session.quic.remote_cid, bytes) else None,
74
+ },
75
+ )
76
+
77
+ def _close_h3_connection(self, session: HTTP3Session, *, reason: str) -> None:
78
+ inventory = self.connection_inventory
79
+ if inventory is None:
80
+ return
81
+ self._update_h3_connection(session)
82
+ inventory.close_connection(self._connection_id_for_session(session), reason=reason)
83
+
28
84
  def _webtransport_budget_snapshot(self) -> dict[str, Any] | None:
29
85
  manager = self.webtransport_governance
30
86
  if manager is None:
@@ -37,16 +93,29 @@ class HTTP3LifecycleMixin:
37
93
  return None
38
94
  return manager.open_session(
39
95
  webtransport.session_id,
40
- peer_id=self._webtransport_address(session.addr),
96
+ peer_id=peer_id_from_address(self._webtransport_address(session.addr)),
41
97
  address=self._webtransport_address(session.addr),
42
98
  )
43
99
 
44
100
  def _webtransport_register_stream(self, webtransport: _HTTP3WebTransportSession, stream_id: int | str) -> dict[str, Any] | None:
101
+ if self.connection_inventory is not None:
102
+ self.connection_inventory.update_session(
103
+ webtransport.session_id,
104
+ stream_ids=(str(stream_id),),
105
+ )
45
106
  manager = self.webtransport_governance
46
107
  if manager is None:
47
108
  return None
48
109
  return manager.open_stream(webtransport.session_id, str(stream_id))
49
110
 
111
+ def _webtransport_release_stream(
112
+ self, webtransport: _HTTP3WebTransportSession, stream_id: int | str
113
+ ) -> dict[str, Any] | None:
114
+ manager = self.webtransport_governance
115
+ if manager is None:
116
+ return None
117
+ return manager.close_stream(webtransport.session_id, str(stream_id))
118
+
50
119
  def _webtransport_register_datagram(
51
120
  self,
52
121
  webtransport: _HTTP3WebTransportSession,
@@ -107,6 +176,13 @@ class HTTP3LifecycleMixin:
107
176
  max_streams_uni=self.config.scheduler.max_streams or 128,
108
177
  idle_timeout=int(self.config.quic.idle_timeout * 1000),
109
178
  max_datagram_frame_size=self.listener.max_datagram_size,
179
+ reset_stream_at=(
180
+ 'webtransport' in self.listener.enabled_protocols
181
+ and any(
182
+ profile_spec(name, max_sessions=int(self.config.webtransport.max_sessions or 1)).requires_reset_stream_at
183
+ for name in self.config.webtransport.profiles
184
+ )
185
+ ),
110
186
  )
111
187
  session.quic.configure_handshake(
112
188
  QuicTlsHandshakeDriver(
@@ -2,12 +2,32 @@ from __future__ import annotations
2
2
 
3
3
  from .imports import *
4
4
 
5
+
5
6
  class HTTP3OutboundMixin:
6
- def _queue_or_send(self, session: HTTP3Session, raw: bytes, endpoint: UDPEndpoint, addr: tuple[str, int]) -> None:
7
+ def _queue_or_send(
8
+ self,
9
+ session: HTTP3Session,
10
+ raw: bytes,
11
+ endpoint: UDPEndpoint,
12
+ addr: tuple[str, int],
13
+ *,
14
+ priority: bool = False,
15
+ ) -> None:
7
16
  transport = getattr(endpoint, 'transport', None)
8
17
  if transport is not None and transport.is_closing():
9
18
  return
19
+ # Packet numbers are assigned during encoding. Drain every older
20
+ # datagram before admitting this one so a freshly encoded ACK, PTO
21
+ # probe, or control response cannot overtake queued stream data and
22
+ # trigger spurious packet-threshold loss at the peer.
23
+ if session.pending_outbound:
24
+ self._flush_pending_outbound(session, endpoint)
25
+ if session.pending_outbound:
26
+ session.quic.defer_datagram(raw)
27
+ session.pending_outbound.append(raw)
28
+ return
10
29
  if self._can_send_now(session, raw):
30
+ session.quic.confirm_datagram_sent(raw)
11
31
  endpoint.send(raw, addr)
12
32
  session.bytes_sent += len(raw)
13
33
  if self.metrics is not None:
@@ -27,6 +47,40 @@ class HTTP3OutboundMixin:
27
47
  while pto_total > session.last_quic_pto_expirations_total:
28
48
  self.metrics.quic_pto_expired()
29
49
  session.last_quic_pto_expirations_total += 1
50
+ recovery_totals = {
51
+ 'pto_probes': int(getattr(session.quic, 'pto_probes_total', 0)),
52
+ 'stream_bytes_retransmitted': int(getattr(session.quic, 'stream_bytes_retransmitted_total', 0)),
53
+ 'crypto_bytes_retransmitted': int(getattr(session.quic, 'crypto_bytes_retransmitted_total', 0)),
54
+ 'datagram_frames_abandoned': int(getattr(session.quic, 'datagram_frames_abandoned_total', 0)),
55
+ 'frames_regenerated': int(getattr(session.quic, 'frames_regenerated_total', 0)),
56
+ }
57
+ last_fields = {
58
+ name: f'last_quic_{name}_total' for name in recovery_totals
59
+ }
60
+ deltas = {
61
+ name: max(0, total - int(getattr(session, last_fields[name])))
62
+ for name, total in recovery_totals.items()
63
+ }
64
+ self.metrics.quic_recovery_observed(**deltas)
65
+ for name, total in recovery_totals.items():
66
+ setattr(session, last_fields[name], total)
67
+ self.metrics.quic_transport_state_observed(
68
+ bytes_in_flight=session.quic.recovery.bytes_in_flight,
69
+ congestion_window=session.quic.recovery.congestion_window,
70
+ smoothed_rtt=session.quic.recovery.rtt.smoothed_rtt,
71
+ pacing_rate=session.quic.recovery.pacing_rate,
72
+ pending_outbound=len(session.pending_outbound),
73
+ )
74
+ persistent_total = int(
75
+ getattr(session.quic.recovery, 'persistent_congestion_total', 0)
76
+ )
77
+ while persistent_total > session.last_quic_persistent_congestion_total:
78
+ self.metrics.quic_persistent_congestion_observed()
79
+ session.last_quic_persistent_congestion_total += 1
80
+ controller_failed = bool(session.quic.recovery.congestion.failed)
81
+ if controller_failed and not session.quic_congestion_controller_failure_reported:
82
+ self.metrics.quic_congestion_controller_failed()
83
+ session.quic_congestion_controller_failure_reported = True
30
84
 
31
85
  def _flush_pending_outbound(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
32
86
  if not session.pending_outbound:
@@ -34,17 +88,16 @@ class HTTP3OutboundMixin:
34
88
  transport = getattr(endpoint, 'transport', None)
35
89
  if transport is not None and transport.is_closing():
36
90
  return
37
- remaining: list[bytes] = []
38
- for raw in session.pending_outbound:
39
- if self._can_send_now(session, raw):
40
- session.quic.confirm_datagram_sent(raw)
41
- endpoint.send(raw, session.addr)
42
- session.bytes_sent += len(raw)
43
- if self.metrics is not None:
44
- self.metrics.quic_datagram_sent(len(raw))
45
- else:
46
- remaining.append(raw)
47
- session.pending_outbound = remaining
91
+ while session.pending_outbound:
92
+ raw = session.pending_outbound[0]
93
+ if not self._can_send_now(session, raw):
94
+ break
95
+ session.pending_outbound.pop(0)
96
+ session.quic.confirm_datagram_sent(raw)
97
+ endpoint.send(raw, session.addr)
98
+ session.bytes_sent += len(raw)
99
+ if self.metrics is not None:
100
+ self.metrics.quic_datagram_sent(len(raw))
48
101
 
49
102
  def _can_send_now(self, session: HTTP3Session, raw: bytes) -> bool:
50
103
  amplification_ok = session.address_validated or (session.bytes_sent + len(raw) <= (session.bytes_received * 3))
@@ -57,6 +110,11 @@ class HTTP3OutboundMixin:
57
110
 
58
111
  def _next_session_delay(self, session: HTTP3Session) -> float | None:
59
112
  delays: list[float] = []
113
+ idle_timeout = float(self.config.quic.idle_timeout)
114
+ if idle_timeout > 0:
115
+ delays.append(
116
+ max(0.0, idle_timeout - (time.monotonic() - session.last_activity_at))
117
+ )
60
118
  runtime_delay = session.quic.next_runtime_deadline()
61
119
  if runtime_delay is not None:
62
120
  delays.append(runtime_delay)
@@ -79,6 +137,7 @@ class HTTP3OutboundMixin:
79
137
  def _close_session(self, session: HTTP3Session) -> None:
80
138
  removed = self.sessions.pop(session.addr, None)
81
139
  if removed is session:
140
+ self._close_h3_connection(session, reason='quic-session-close')
82
141
  self.trace_webtransport('quic.session.close', **self._trace_session_fields(session))
83
142
  self.sessions_by_local_cid.pop(session.quic.local_cid, None)
84
143
  if self.metrics is not None:
@@ -108,16 +167,35 @@ class HTTP3OutboundMixin:
108
167
  loop.create_task(self._on_session_timer(session, endpoint))
109
168
 
110
169
  async def _on_session_timer(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
111
- async with self._lock:
170
+ async with session.lock:
112
171
  session.timer_handle = None
113
172
  transport = getattr(endpoint, 'transport', None)
114
173
  if transport is None or transport.is_closing():
115
174
  return
116
175
  if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
117
176
  return
177
+ idle_timeout = float(self.config.quic.idle_timeout)
178
+ if (
179
+ idle_timeout > 0
180
+ and time.monotonic() - session.last_activity_at >= idle_timeout
181
+ ):
182
+ self.trace_webtransport(
183
+ "quic.connection.idle_timeout",
184
+ **self._trace_session_fields(session),
185
+ idle_timeout=idle_timeout,
186
+ )
187
+ await self._abort_session_tunnels(session)
188
+ await self._abort_session_websockets(session)
189
+ await self._abort_session_webtransports(session)
190
+ self._close_session(session)
191
+ return
118
192
  outbound = session.quic.drain_scheduled_datagrams()
119
193
  for raw in outbound:
120
- self._queue_or_send(session, raw, endpoint, session.addr)
194
+ # ACKs, PTO probes, and loss retransmissions must not sit
195
+ # behind a large application-media queue.
196
+ self._queue_or_send(
197
+ session, raw, endpoint, session.addr, priority=True
198
+ )
121
199
  self._flush_pending_outbound(session, endpoint)
122
200
  self._arm_session_timer(session, endpoint)
123
201
  def _ensure_server_control_stream_locked(self, session: HTTP3Session) -> list[bytes]:
@@ -136,9 +214,12 @@ class HTTP3OutboundMixin:
136
214
  if self.listener.websocket:
137
215
  control_settings[SETTING_ENABLE_CONNECT_PROTOCOL] = 1
138
216
  if 'webtransport' in self.listener.enabled_protocols:
139
- control_settings[SETTING_ENABLE_CONNECT_PROTOCOL] = 1
140
- control_settings[SETTING_H3_DATAGRAM] = 1
141
- control_settings[SETTING_WT_MAX_SESSIONS] = int(self.config.webtransport.max_sessions or 1)
217
+ control_settings.update(
218
+ settings_for_profiles(
219
+ self.config.webtransport.profiles,
220
+ max_sessions=int(self.config.webtransport.max_sessions or 1),
221
+ )
222
+ )
142
223
  control_payload = session.h3.encode_control_stream(control_settings)
143
224
  session.server_control_stream_sent = True
144
225
  return [session.quic.send_stream_data(session.server_control_stream_id, control_payload, fin=False)]
@@ -163,9 +244,22 @@ class HTTP3OutboundMixin:
163
244
  outbound.append(session.quic.send_stream_data(session.server_qpack_decoder_stream_id, decoder_data, fin=False))
164
245
  return outbound
165
246
 
166
- def _queue_session_outbound_locked(self, session: HTTP3Session, outbound: list[bytes], endpoint: UDPEndpoint) -> None:
247
+ def _queue_session_outbound_locked(
248
+ self,
249
+ session: HTTP3Session,
250
+ outbound: list[bytes],
251
+ endpoint: UDPEndpoint,
252
+ *,
253
+ priority: bool = False,
254
+ ) -> None:
255
+ # QuicConnection records packets when they are encoded. Refund the
256
+ # complete batch before admitting it to the wire so later packets do
257
+ # not consume congestion credit ahead of earlier CRYPTO segments.
167
258
  for raw in outbound:
168
- self._queue_or_send(session, raw, endpoint, session.addr)
259
+ session.quic.defer_datagram(raw)
260
+ # Preserve QUIC packet-number order. Stream/lane priority must be
261
+ # resolved before packet encoding, never by reordering wire datagrams.
262
+ session.pending_outbound.extend(outbound)
169
263
  self._flush_pending_outbound(session, endpoint)
170
264
  if session.addr in self.sessions and self.sessions.get(session.addr) is session:
171
265
  self._arm_session_timer(session, endpoint)