@camera.ui/server 0.0.105 → 0.0.107

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 (141) hide show
  1. package/dist/interface/assets/{404-dD7prz4d.js → 404-BAYYYl25.js} +1 -1
  2. package/dist/interface/assets/{About-4w_Ij32a.js → About-CF96M6HY.js} +1 -1
  3. package/dist/interface/assets/{Adminpanel-BgxU0Yh6.js → Adminpanel-DOaJyAyb.js} +1 -1
  4. package/dist/interface/assets/{Automation-CYsN1p2P.js → Automation-CKXlldCc.js} +2 -2
  5. package/dist/interface/assets/{Automations-CRd6MuM2.js → Automations-Bly4du8g.js} +1 -1
  6. package/dist/interface/assets/{Camera-DCqFhkAt.js → Camera-B_iqc3Zu.js} +2 -2
  7. package/dist/interface/assets/{CameraConsole-DfIBG8ZV.js → CameraConsole-DmkR-ATY.js} +1 -1
  8. package/dist/interface/assets/{Cameras-BK67w1OJ.js → Cameras-CBJqTSnN.js} +1 -1
  9. package/dist/interface/assets/{Camview-B0go1EMt.js → Camview-CF_Fa0E1.js} +1 -1
  10. package/dist/interface/assets/{Config-CeEi3tKd.js → Config-B10TESNa.js} +1 -1
  11. package/dist/interface/assets/{ConfigActionCameraControl-rP-oa_3f.js → ConfigActionCameraControl-BWpRxipc.js} +1 -1
  12. package/dist/interface/assets/{ConfigActionDelay-BJeC4VAJ.js → ConfigActionDelay-NBOa8ulm.js} +1 -1
  13. package/dist/interface/assets/{ConfigActionHttp-F8Mpo56C.js → ConfigActionHttp-CpeMles1.js} +1 -1
  14. package/dist/interface/assets/{ConfigActionImageInput-Drtv5ElC.js → ConfigActionImageInput-He5suR1p.js} +1 -1
  15. package/dist/interface/assets/{ConfigActionNotification-DR8c22Y1.js → ConfigActionNotification-BrYGUn_B.js} +1 -1
  16. package/dist/interface/assets/{ConfigActionOutput-CbT2-VnU.js → ConfigActionOutput-Dy32ypu0.js} +1 -1
  17. package/dist/interface/assets/{ConfigActionPlugin-DE-S6S0y.js → ConfigActionPlugin-DZgDVj2J.js} +1 -1
  18. package/dist/interface/assets/{ConfigActionSensor-Dw_V9cYY.js → ConfigActionSensor-B9aJwfb2.js} +1 -1
  19. package/dist/interface/assets/{ConfigActionSnapshot-5xUvGsae.js → ConfigActionSnapshot-Bisnu1Va.js} +1 -1
  20. package/dist/interface/assets/{ConfigActionVariable-Bub1V_a3.js → ConfigActionVariable-BcQ-pUs-.js} +1 -1
  21. package/dist/interface/assets/{ConfigConditionIfElse-CAt9ubD2.js → ConfigConditionIfElse-DKyGnLRO.js} +1 -1
  22. package/dist/interface/assets/{ConfigConditionSensorState-DhP32uo8.js → ConfigConditionSensorState-mKh6F8lJ.js} +1 -1
  23. package/dist/interface/assets/{ConfigConditionSwitch-DB2r6i-I.js → ConfigConditionSwitch-CSZTkyIE.js} +1 -1
  24. package/dist/interface/assets/{ConfigConditionTime-zqIQQ-tD.js → ConfigConditionTime-C8_R0ITE.js} +1 -1
  25. package/dist/interface/assets/{ConfigTriggerDetection-CVW5HXeG.js → ConfigTriggerDetection-a2NV6Ke1.js} +1 -1
  26. package/dist/interface/assets/{ConfigTriggerGeofence-BsPsPO4j.js → ConfigTriggerGeofence-DnS6ONJC.js} +1 -1
  27. package/dist/interface/assets/{ConfigTriggerManual-DhafkyQd.js → ConfigTriggerManual-CrKYDCVp.js} +1 -1
  28. package/dist/interface/assets/{ConfigTriggerSchedule-Bx3qlITv.js → ConfigTriggerSchedule-Dc6k-hJ2.js} +1 -1
  29. package/dist/interface/assets/{ConfigTriggerSensor-FVgMXGty.js → ConfigTriggerSensor-lmBAbpAF.js} +1 -1
  30. package/dist/interface/assets/{ConfigTriggerSystem-BJo-wIMS.js → ConfigTriggerSystem-BibBSkft.js} +1 -1
  31. package/dist/interface/assets/{ConfigTriggerWebhook-BaUrOAZB.js → ConfigTriggerWebhook-CcMI4u82.js} +1 -1
  32. package/dist/interface/assets/{Console-B23nM1WG.js → Console-CiqXek8C.js} +1 -1
  33. package/dist/interface/assets/{CuiActionButton-Dywht3_D.js → CuiActionButton-DmV0jhr5.js} +1 -1
  34. package/dist/interface/assets/{CuiAvatar-CdoyvPP1.js → CuiAvatar-DMZqcLoE.js} +1 -1
  35. package/dist/interface/assets/{CuiBBoxPlayground-DcAhwW8k.js → CuiBBoxPlayground-BakfNGCT.js} +1 -1
  36. package/dist/interface/assets/CuiBottomSheet-BJl4lLzC.js +1 -0
  37. package/dist/interface/assets/{CuiBottomSheet-CvkbklC_.js → CuiBottomSheet-CkJEMXQy.js} +1 -1
  38. package/dist/interface/assets/{CuiCameraCard-BxWf13Ct.js → CuiCameraCard-jWlEnJAQ.js} +1 -1
  39. package/dist/interface/assets/{CuiCameraPipCard-CR5a-MkM.js → CuiCameraPipCard-ClOJg9E-.js} +1 -1
  40. package/dist/interface/assets/{CuiCameraRecordings-BXfAlbHm.js → CuiCameraRecordings-BhM5UivW.js} +1 -1
  41. package/dist/interface/assets/{CuiCameraShares-CJpSVfdl.js → CuiCameraShares-CktUxrlR.js} +1 -1
  42. package/dist/interface/assets/{CuiCameraSnapshot-Cg8Vjve3.js → CuiCameraSnapshot-BE1xeM4q.js} +1 -1
  43. package/dist/interface/assets/{CuiCameraTable-CfJNlsEQ.js → CuiCameraTable-D87v9LhC.js} +1 -1
  44. package/dist/interface/assets/{CuiDataTable-C9mtC73b.js → CuiDataTable-C5toN4L-.js} +1 -1
  45. package/dist/interface/assets/{CuiFloatingButton-Bi1jS25U.js → CuiFloatingButton-DvdpuxUr.js} +1 -1
  46. package/dist/interface/assets/{CuiImage-DJcQfDpa.js → CuiImage-C-DSHyf2.js} +1 -1
  47. package/dist/interface/assets/{CuiMenu-150v0xB6.js → CuiMenu-CwumqO_L.js} +1 -1
  48. package/dist/interface/assets/{CuiPluginCard-CHLd5hzA.js → CuiPluginCard-DIqaLKcf.js} +2 -2
  49. package/dist/interface/assets/{CuiPluginOAuthButton-B7JHhElr.js → CuiPluginOAuthButton-CBTeQEb9.js} +2 -2
  50. package/dist/interface/assets/{CuiQRCode-ZbDxg42W.js → CuiQRCode-ChJuYXmm.js} +1 -1
  51. package/dist/interface/assets/{CuiRecordingsGrid-Bl0vRmPG.js → CuiRecordingsGrid-D1mYCCcE.js} +1 -1
  52. package/dist/interface/assets/{CuiSchema-DvWek91_.js → CuiSchema-BIGweVJy.js} +1 -1
  53. package/dist/interface/assets/{CuiTopNavbarItem-Dwm5c6so.js → CuiTopNavbarItem-FXY4aBoC.js} +1 -1
  54. package/dist/interface/assets/{CuiTopbarSlot-DE7fTsSm.js → CuiTopbarSlot-DCeAqmkQ.js} +1 -1
  55. package/dist/interface/assets/{Faces-6nDCr24_.js → Faces-B6JlzZ5i.js} +1 -1
  56. package/dist/interface/assets/{FirstSteps-DB7hnFYh.js → FirstSteps-CH1iro2p.js} +1 -1
  57. package/dist/interface/assets/{Instances-KpdnZAmV.js → Instances-BNNA4Z6H.js} +1 -1
  58. package/dist/interface/assets/{Logs-Cq2AF2x-.js → Logs-BkF4EFHV.js} +1 -1
  59. package/dist/interface/assets/{Menu-CeDR7bvT.js → Menu-DYejNhsi.js} +1 -1
  60. package/dist/interface/assets/{OAuthDeviceFlow-BVOHNDTs.js → OAuthDeviceFlow-BVeSCJiJ.js} +1 -1
  61. package/dist/interface/assets/{Plugin-BB6k9AtE.js → Plugin-zvdoGW2r.js} +1 -1
  62. package/dist/interface/assets/{PluginConsole-Cbo8LA6t.js → PluginConsole-CQA2A5Wc.js} +1 -1
  63. package/dist/interface/assets/{PluginSchema-CCfTSjTD.js → PluginSchema-xof7AYIK.js} +1 -1
  64. package/dist/interface/assets/{Plugins-DpTVWC5S.js → Plugins-DAwDjr_I.js} +2 -2
  65. package/dist/interface/assets/{Recordings-B0_4e1tu.js → Recordings-D7UhoAKi.js} +1 -1
  66. package/dist/interface/assets/{Settings-Wz10iv8b.js → Settings-CnU98cZv.js} +1 -1
  67. package/dist/interface/assets/{SettingsAccount-BNSg5Wx5.js → SettingsAccount-BVIztMFL.js} +1 -1
  68. package/dist/interface/assets/SettingsAccount-BmuJLRLH.js +1 -0
  69. package/dist/interface/assets/{SettingsAppearance-VHR3IVCx.js → SettingsAppearance-CW8mbN9Q.js} +1 -1
  70. package/dist/interface/assets/SettingsAppearance-CiVRn_RM.js +1 -0
  71. package/dist/interface/assets/{SettingsBackup-DGhqvHHm.js → SettingsBackup-DDNiOgvA.js} +1 -1
  72. package/dist/interface/assets/{SettingsNotifications-BHcRDJAp.js → SettingsNotifications-DLMT1o4I.js} +1 -1
  73. package/dist/interface/assets/{SettingsRecordings-CrMGLPfU.js → SettingsRecordings-DirvHeKB.js} +1 -1
  74. package/dist/interface/assets/{SettingsRemote-B1m3_4lr.js → SettingsRemote-CKWsHa5R.js} +1 -1
  75. package/dist/interface/assets/{SettingsSystem-z5r89MJJ.js → SettingsSystem-D9ZC11Mh.js} +2 -2
  76. package/dist/interface/assets/{SettingsUsers-yReLqEQ1.js → SettingsUsers-pcJeXZKp.js} +1 -1
  77. package/dist/interface/assets/{Terminal-BAiV8ZVj.js → Terminal-Cu3uC8EZ.js} +1 -1
  78. package/dist/interface/assets/{VariableInput-Dw0KbkD9.js → VariableInput-Bwu9vChK.js} +1 -1
  79. package/dist/interface/assets/{VariableSuggestions-D5_Zu_un.js → VariableSuggestions-l2MLjZre.js} +1 -1
  80. package/dist/interface/assets/{VersionsHandler-BDWjtbp0.js → VersionsHandler-BTxJHwdS.js} +1 -1
  81. package/dist/interface/assets/{Workers-DbnD_Bpy.js → Workers-BsEDKzmG.js} +1 -1
  82. package/dist/interface/assets/{api-D6SjwBWd.js → api-C3OjKngj.js} +1 -1
  83. package/dist/interface/assets/{api-C-NlYhuP.js → api-CFbWrm1k.js} +1 -1
  84. package/dist/interface/assets/{auth-dyUBxkxf.js → auth-C5IUhkWS.js} +1 -1
  85. package/dist/interface/assets/{automations-CrRSdJyJ.js → automations-DD8bCSnT.js} +1 -1
  86. package/dist/interface/assets/{automations-BH3C3DOU.js → automations-DusabINi.js} +1 -1
  87. package/dist/interface/assets/{backup-C2-2-LD2.js → backup-oG81xIPA.js} +1 -1
  88. package/dist/interface/assets/{cameras-Co3xGKAG.js → cameras-CmFEgULx.js} +1 -1
  89. package/dist/interface/assets/{cameras.schema-y2YP50-k.js → cameras.schema-iBaEWm0Y.js} +1 -1
  90. package/dist/interface/assets/cloudHandoff-BzwN18OZ.js +1 -0
  91. package/dist/interface/assets/{deviceId-CiXrj9Di.js → deviceId-opaLeE0R.js} +1 -1
  92. package/dist/interface/assets/{hasPermission-DKTmzAJw.js → hasPermission-ChIspc-L.js} +1 -1
  93. package/dist/interface/assets/{instances-BgFvZ06K.js → instances-ykXfoA2u.js} +1 -1
  94. package/dist/interface/assets/{locale-CH0o3x5d.js → locale-BliTvqss.js} +1 -1
  95. package/dist/interface/assets/{main-DZb_R3L7.js → main-DiUHKjqR.js} +2 -2
  96. package/dist/interface/assets/{notifications-DEU1TXne.js → notifications-BQyPgfnE.js} +1 -1
  97. package/dist/interface/assets/{plugins-Bj4_KCp7.js → plugins-BFzZB-8S.js} +1 -1
  98. package/dist/interface/assets/{round-restart-alt-BMdTt76H.js → round-restart-alt-C4TFl1Rk.js} +1 -1
  99. package/dist/interface/assets/{router-CIvI3oge.js → router-BzaHrqQ7.js} +2 -2
  100. package/dist/interface/assets/{routerState-cQknQxNw.js → routerState-UESa7YsN.js} +1 -1
  101. package/dist/interface/assets/{server-DeMUCrCz.js → server-7nJbrqvt.js} +1 -1
  102. package/dist/interface/assets/{shares-B5r7Nr5g.js → shares-BokQ1O4D.js} +1 -1
  103. package/dist/interface/assets/{theme-kssRfqyJ.js → theme-iSxYm9Ao.js} +1 -1
  104. package/dist/interface/assets/{types-BK05hngw.js → types-CVp3a4-E.js} +1 -1
  105. package/dist/interface/assets/{ui-B2i8B4yr.js → ui-CVUMjabl.js} +1 -1
  106. package/dist/interface/assets/{useBiometricCredentials-I52MbNzM.js → useBiometricCredentials-BTSuyr45.js} +1 -1
  107. package/dist/interface/assets/{useCameraOptions-uXN6hbSO.js → useCameraOptions-CuIFFMce.js} +1 -1
  108. package/dist/interface/assets/{useCuiCameraDrawer-BuIrXxOt.js → useCuiCameraDrawer-DHbL2uYT.js} +1 -1
  109. package/dist/interface/assets/{useCuiDialog-C4xS4vrs.js → useCuiDialog-DmnaFxId.js} +1 -1
  110. package/dist/interface/assets/{useFileDownload-C_Am2BRz.js → useFileDownload-D7uRiFqP.js} +1 -1
  111. package/dist/interface/assets/{useLogsSocket-PUFDb8MH.js → useLogsSocket-CA2uq1kZ.js} +1 -1
  112. package/dist/interface/assets/{useSharedComposables-Ca_A3w0h.js → useSharedComposables-Dsn2uiyW.js} +1 -1
  113. package/dist/interface/assets/{useStreamStatus-CIPBILoa.js → useStreamStatus-Dun63PvC.js} +1 -1
  114. package/dist/interface/assets/{users.schema-DGYmUjqk.js → users.schema-COvF8XP0.js} +1 -1
  115. package/dist/interface/assets/{vendor-cameraui-BABuGvhg.js → vendor-cameraui-B1JU4jwS.js} +38 -38
  116. package/dist/interface/assets/{vendor-core-C6lCucBy.js → vendor-core-BxjZmyZH.js} +1 -1
  117. package/dist/interface/assets/{vendor-primevue-CsC6U_Xu.js → vendor-primevue-gmNTz6qq.js} +1 -1
  118. package/dist/interface/assets/{view-grid-DM5fFhOq.js → view-grid-C3peaC6C.js} +1 -1
  119. package/dist/interface/index.html +40 -40
  120. package/dist/interface/sw.js +1 -1
  121. package/dist/server/package.json +2 -2
  122. package/dist/server/requirements.txt +1 -1
  123. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/channel.py +29 -23
  124. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/chunking.py +52 -5
  125. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/client.py +682 -231
  126. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/codec.py +277 -18
  127. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/handler.py +105 -17
  128. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/service.py +21 -7
  129. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/types.py +13 -0
  130. package/dist/server/src/_camera_ui_tools/camera_ui_rpc/utils.py +50 -15
  131. package/dist/server/src/api/plugins/natsSubFirewall.js +0 -6
  132. package/dist/server/src/api/plugins/natsSubFirewall.js.map +1 -1
  133. package/dist/server/src/api/plugins/proxy.plugin.js +3 -0
  134. package/dist/server/src/api/plugins/proxy.plugin.js.map +1 -1
  135. package/dist/server/src/rpc/server.js +6 -3
  136. package/dist/server/src/rpc/server.js.map +1 -1
  137. package/package.json +2 -2
  138. package/dist/interface/assets/CuiBottomSheet-CU8irFoy.js +0 -1
  139. package/dist/interface/assets/SettingsAccount-DlGKfeGf.js +0 -1
  140. package/dist/interface/assets/SettingsAppearance-N4r7TCgr.js +0 -1
  141. package/dist/interface/assets/cloudHandoff-C9HXr7Oq.js +0 -1
@@ -21,7 +21,7 @@ from nats.js.api import Header
21
21
 
22
22
  from .channel import Channel, PrivateChannel
23
23
  from .chunking import ChunkingManager, create_chunks
24
- from .codec import decode, encode
24
+ from .codec import decode, decode_message, encode, encode_message
25
25
  from .decorators import extract_nested_methods_with_decorators, extract_nested_methods_without_decorators
26
26
  from .errors import RPCException, create_error
27
27
  from .executor import get_executor
@@ -52,7 +52,13 @@ from .types import (
52
52
  )
53
53
  from .types import ProxyWithClose as ProxyWithCloseProtocol
54
54
  from .types import RPCClient as RPCClientProtocol
55
- from .utils import create_proxy, create_service_proxy, generate_id, is_async_function
55
+ from .utils import (
56
+ create_proxy,
57
+ create_service_proxy,
58
+ generate_id,
59
+ generate_reply_prefix,
60
+ is_async_function,
61
+ )
56
62
 
57
63
  T = TypeVar("T")
58
64
 
@@ -89,20 +95,41 @@ class RPCClient(RPCClientProtocol):
89
95
  self.service: RPCService = RPCService(cast(Any, self))
90
96
 
91
97
  self.nc: NATSClient | None = None
92
- self.subscriptions: dict[str, list[Subscription]] = {}
98
+ self._subscription_seq: int = 0
99
+ self._subscription_entries: dict[int, dict[str, Any]] = {}
93
100
  self.chunking_manager: ChunkingManager = ChunkingManager()
94
101
  self._max_payload_size: int = 1024 * 1024 # Default 1MB
95
102
  self._connection_task: asyncio.Task[NATSClient] | None = None
96
103
 
104
+ # First dot-separated segment of every id this client generates.
105
+ # Equals the conn_id when one is configured (browser clients: the
106
+ # firewall allowlists `rpc.reply.<conn_id>.>`), otherwise a local
107
+ # random prefix. All reply subjects derived from those ids therefore
108
+ # fall under one wildcard: `rpc.reply.<reply_prefix>.>` — the muxed
109
+ # reply inbox.
110
+ self._reply_prefix: str = options.get("conn_id") or generate_reply_prefix()
111
+
112
+ # The single persistent reply-mux subscription entry (wildcard
113
+ # `rpc.reply.<reply_prefix>.>`). Lives in _subscription_entries so
114
+ # suspend()/connect() restore it like any other subscription.
115
+ self._mux_entry: dict[str, Any] | None = None
116
+
117
+ # 503/no-responder handlers keyed by reply-subject suffix (the part
118
+ # after `rpc.reply.`). Used by pull-iterator/stream paths whose
119
+ # per-message `reply` subject only ever carries no-responder statuses.
120
+ # One-shot: the mux dispatcher removes an entry when it fires
121
+ # (max_msgs=1 semantics).
122
+ self.status_handlers: dict[str, Callable[[Exception], None]] = {}
123
+
97
124
  self.pending_requests: dict[str, dict[str, Any]] = {}
98
125
  self.stream_handlers: dict[str, dict[str, Any]] = {}
99
126
  self.isolated_clients: list[RPCClient] = []
100
127
  self.pull_iterator_cleanups: dict[str, Callable[[], Coroutine[Any, Any, None]]] = {}
101
128
  self.callback_cleanups: dict[str, Callable[[], Coroutine[Any, Any, None]]] = {}
129
+ self._pull_iterator_settles: set[Callable[[], None]] = set()
102
130
 
103
131
  self._closed = False
104
132
  self._suspending = False
105
- self._subscription_meta: list[dict[str, Any]] = []
106
133
 
107
134
  self.io_pool: ThreadPoolExecutor = get_executor()
108
135
 
@@ -190,6 +217,11 @@ class RPCClient(RPCClientProtocol):
190
217
 
191
218
  self.nc = await connect(**connect_opts)
192
219
 
220
+ # A client that was disconnect()ed is revivable by an explicit
221
+ # connect(). Without this reset, auto-connect in _call_once and the
222
+ # no-responder retry loop stay permanently disabled.
223
+ self._closed = False
224
+
193
225
  # Initialize service
194
226
  self.service.init(self.nc)
195
227
 
@@ -199,27 +231,30 @@ class RPCClient(RPCClientProtocol):
199
231
  # Reserve 8KB for NATS protocol overhead and MsgPack envelope per message
200
232
  self._max_payload_size = self._max_payload_size - 8192
201
233
 
202
- # Restore subscriptions after reconnect (used by suspend/connect cycle)
203
- if self._subscription_meta:
204
- metas = list(self._subscription_meta)
205
- self._subscription_meta.clear()
206
- for meta in metas:
207
- await self.subscribe(meta["pattern"], meta["handler"], **(meta.get("options") or {}))
234
+ # Register the muxed reply inbox (idempotent). Registered as a normal
235
+ # subscription entry so the restore loop below (re-)subscribes it on
236
+ # first connect and after every suspend cycle alike.
237
+ self._register_mux_entry()
238
+
239
+ # Restore subscriptions after reconnect (from suspend). Entries keep
240
+ # their identity so unsubscribe closures held by callers stay valid
241
+ # across the restore.
242
+ for entry in list(self._subscription_entries.values()):
243
+ sub = entry.get("sub")
244
+ if sub is None or getattr(sub, "_closed", False):
245
+ await self._nats_subscribe(entry)
208
246
 
209
247
  return self.nc
210
248
 
211
249
  async def disconnect(self) -> None:
212
250
  """Disconnect from NATS server."""
213
251
  self._closed = True
214
- self._subscription_meta.clear()
215
252
 
216
- # Cleanup pending requests
217
- for pending in self.pending_requests.values():
218
- if timeout_handle := pending.get("timeout"):
219
- timeout_handle.cancel()
220
- if reject := pending.get("reject"):
221
- reject(create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed"))
222
- self.pending_requests.clear()
253
+ # Cleanup pending requests. RPC calls are muxed (no per-call
254
+ # subscription); service-path calls still hold a per-call reply entry
255
+ # which the cleanup hook drops.
256
+ await self._cleanup_pending_requests()
257
+ self.status_handlers.clear()
223
258
 
224
259
  # Cleanup stream handlers
225
260
  for handler in self.stream_handlers.values():
@@ -230,6 +265,8 @@ class RPCClient(RPCClientProtocol):
230
265
  pass
231
266
  self.stream_handlers.clear()
232
267
 
268
+ self._settle_pull_iterators()
269
+
233
270
  # Cleanup pull iterators
234
271
  await asyncio.gather(
235
272
  *[cleanup() for cleanup in self.pull_iterator_cleanups.values()], return_exceptions=True
@@ -243,11 +280,14 @@ class RPCClient(RPCClientProtocol):
243
280
  self.callback_cleanups.clear()
244
281
 
245
282
  # Unsubscribe all subscriptions
246
- for subs in self.subscriptions.values():
247
- for sub in subs:
283
+ for entry in list(self._subscription_entries.values()):
284
+ if sub := entry.get("sub"):
248
285
  with contextlib.suppress(Exception):
249
286
  await sub.unsubscribe()
250
- self.subscriptions.clear()
287
+ entry["sub"] = None
288
+ self._subscription_entries.clear()
289
+ # Entries were dropped — a revive-connect() must re-register the mux.
290
+ self._mux_entry = None
251
291
 
252
292
  # Clear chunking manager
253
293
  self.chunking_manager = ChunkingManager()
@@ -261,6 +301,7 @@ class RPCClient(RPCClientProtocol):
261
301
  *[client.disconnect() for client in self.isolated_clients],
262
302
  return_exceptions=True,
263
303
  )
304
+ self.isolated_clients.clear()
264
305
 
265
306
  # Close connection with timeout
266
307
  if self.nc:
@@ -271,9 +312,10 @@ class RPCClient(RPCClientProtocol):
271
312
 
272
313
  async def suspend(self) -> None:
273
314
  """Suspend the connection without marking as closed, preserving subscription metadata for reconnect."""
274
- # Block on_finish-driven unsubscribe from clearing _subscription_meta
275
- # while we tear the transport down — the task ending here is not a user-
276
- # initiated unsubscribe.
315
+ # Block on_finish-driven cleanup from dropping subscription entries
316
+ # while we tear the transport down — the msg tasks ending here are not
317
+ # user-initiated unsubscribes; connect() must be able to restore the
318
+ # entries.
277
319
  self._suspending = True
278
320
  try:
279
321
  await self._do_suspend()
@@ -281,13 +323,12 @@ class RPCClient(RPCClientProtocol):
281
323
  self._suspending = False
282
324
 
283
325
  async def _do_suspend(self) -> None:
284
- # Cleanup pending requests
285
- for pending in self.pending_requests.values():
286
- if timeout_handle := pending.get("timeout"):
287
- timeout_handle.cancel()
288
- if reject := pending.get("reject"):
289
- reject(create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed"))
290
- self.pending_requests.clear()
326
+ # Cleanup pending requests. RPC calls are muxed (no per-call
327
+ # subscription); service-path calls still hold a per-call reply entry
328
+ # which the cleanup hook drops — otherwise a suspended in-flight call
329
+ # would be restored as a dead reply subscription on the next connect().
330
+ await self._cleanup_pending_requests()
331
+ self.status_handlers.clear()
291
332
 
292
333
  # Cleanup stream handlers
293
334
  for handler in self.stream_handlers.values():
@@ -298,6 +339,8 @@ class RPCClient(RPCClientProtocol):
298
339
  pass
299
340
  self.stream_handlers.clear()
300
341
 
342
+ self._settle_pull_iterators()
343
+
301
344
  # Cleanup pull iterators
302
345
  await asyncio.gather(
303
346
  *[cleanup() for cleanup in self.pull_iterator_cleanups.values()], return_exceptions=True
@@ -310,12 +353,13 @@ class RPCClient(RPCClientProtocol):
310
353
  )
311
354
  self.callback_cleanups.clear()
312
355
 
313
- # Unsubscribe all subscriptions
314
- for subs in self.subscriptions.values():
315
- for sub in subs:
356
+ # Unsubscribe all subscriptions but keep the entries — connect()
357
+ # restores them on the fresh transport.
358
+ for entry in list(self._subscription_entries.values()):
359
+ if sub := entry.get("sub"):
316
360
  with contextlib.suppress(Exception):
317
361
  await sub.unsubscribe()
318
- self.subscriptions.clear()
362
+ entry["sub"] = None
319
363
 
320
364
  # Clear chunking manager
321
365
  self.chunking_manager = ChunkingManager()
@@ -334,6 +378,35 @@ class RPCClient(RPCClientProtocol):
334
378
  await asyncio.wait_for(self.nc.close(), timeout=timeout)
335
379
  self.nc = None
336
380
 
381
+ async def _cleanup_pending_requests(self) -> None:
382
+ """Settle and tear down all in-flight call() requests.
383
+
384
+ Runs on disconnect/suspend: cancels the timeout, invokes the per-call
385
+ cleanup hook (which removes the reply/inbox subscriptions and their
386
+ entries) and rejects the awaiting caller.
387
+ """
388
+ for pending in list(self.pending_requests.values()):
389
+ if timeout_handle := pending.get("timeout"):
390
+ timeout_handle.cancel()
391
+ if cleanup := pending.get("cleanup"):
392
+ with contextlib.suppress(Exception):
393
+ await cleanup()
394
+ future = pending.get("future")
395
+ if future is not None and not future.done():
396
+ future.set_exception(create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed"))
397
+ self.pending_requests.clear()
398
+
399
+ def _settle_pull_iterators(self) -> None:
400
+ """Force-settle every client-side pull iterator parked in a queue.get().
401
+
402
+ Runs on disconnect/suspend so consumers' `async for` loops terminate
403
+ with a connection error instead of hanging forever.
404
+ """
405
+ for settle in list(self._pull_iterator_settles):
406
+ with contextlib.suppress(Exception):
407
+ settle()
408
+ self._pull_iterator_settles.clear()
409
+
337
410
  def reconfigure(
338
411
  self,
339
412
  servers: list[str] | None = None,
@@ -396,7 +469,7 @@ class RPCClient(RPCClientProtocol):
396
469
  if not self.nc:
397
470
  raise RuntimeError("Not connected")
398
471
 
399
- encoded = encode(data)
472
+ encoded = encode_message(data)
400
473
 
401
474
  replyTo = reply if reply else ""
402
475
 
@@ -446,6 +519,72 @@ class RPCClient(RPCClientProtocol):
446
519
  if not self.nc:
447
520
  raise RuntimeError("Not connected")
448
521
 
522
+ self._subscription_seq += 1
523
+ key = self._subscription_seq
524
+ entry: dict[str, Any] = {
525
+ "key": key,
526
+ "pattern": pattern,
527
+ "handler": handler,
528
+ # Determined once here instead of per delivered message.
529
+ "handler_is_async": is_async_function(handler),
530
+ "queue": queue,
531
+ "sub": None,
532
+ }
533
+ self._subscription_entries[key] = entry
534
+
535
+ await self._nats_subscribe(entry)
536
+
537
+ async def unsubscribe() -> None:
538
+ # Drop bookkeeping first — the NATS-level unsubscribe below may be
539
+ # interrupted when it tears down the very message task calling us.
540
+ self._subscription_entries.pop(key, None)
541
+ sub: Subscription | None = entry.get("sub")
542
+ entry["sub"] = None
543
+ if sub is not None:
544
+ with contextlib.suppress(Exception):
545
+ await sub.unsubscribe()
546
+
547
+ return unsubscribe
548
+
549
+ async def _nats_subscribe(self, entry: dict[str, Any]) -> None:
550
+ """Create the NATS subscription for an entry.
551
+
552
+ Called from subscribe() and again from connect() when restoring
553
+ entries after a suspend cycle.
554
+ """
555
+ if not self.nc:
556
+ raise RuntimeError("Not connected")
557
+
558
+ pattern: str = entry["pattern"]
559
+ handler: Callable[[Any], None] | Callable[[Any], Coroutine[Any, Any, None]] = entry["handler"]
560
+ handler_is_async: bool = entry["handler_is_async"]
561
+
562
+ # Raw mode (muxed reply inbox): hand the undecoded Msg to the handler —
563
+ # it needs headers (503 status, chunk markers) and the subject, and
564
+ # does its own chunk reassembly and routing. The handler is
565
+ # synchronous; route inline, no task per message.
566
+ if entry.get("raw"):
567
+ raw_handler = cast(Callable[[Msg], None], handler)
568
+
569
+ async def raw_message_handler(msg: Msg) -> None:
570
+ try:
571
+ raw_handler(msg)
572
+ except Exception as e:
573
+ print(f"Error processing message for {pattern}:", e)
574
+ print(traceback.format_exc())
575
+
576
+ raw_sub = await self.nc.subscribe(pattern, queue=entry.get("queue") or "", cb=raw_message_handler)
577
+ entry["sub"] = raw_sub
578
+ self._watch_subscription_end(entry, raw_sub)
579
+ return
580
+
581
+ # Payloads assembled from chunks are queued here and drained inline in
582
+ # the message handler below, so they run inside the same sequential
583
+ # per-subscription message flow as regular messages. Dispatching them
584
+ # eagerly (create_task) would run handlers concurrently and out of
585
+ # order, breaking backpressure-sensitive callers.
586
+ assembled_queue: list[Any] = []
587
+
449
588
  async def message_handler(msg: Msg) -> None:
450
589
  try:
451
590
  chunk_type = msg.headers.get("x-chunked-transfer") if msg.headers else None
@@ -459,7 +598,7 @@ class RPCClient(RPCClientProtocol):
459
598
  return
460
599
 
461
600
  def on_complete(assembled_data: Any) -> None:
462
- asyncio.create_task(self._handle_assembled_data(handler, assembled_data))
601
+ assembled_queue.append(assembled_data)
463
602
 
464
603
  def on_error(error: Exception) -> None:
465
604
  print(f"Error assembling chunks for {pattern}: {error}")
@@ -490,70 +629,212 @@ class RPCClient(RPCClientProtocol):
490
629
  {"id": chunk_id, "chunkIndex": chunk_index, "data": msg.data, "isLast": False}
491
630
  )
492
631
 
632
+ # Drain any transfer completed by this chunk inline (see
633
+ # assembled_queue above).
634
+ while assembled_queue:
635
+ await self._handle_assembled_data(handler, assembled_queue.pop(0), handler_is_async)
636
+
493
637
  else:
494
- # Regular message - decode MessagePack data
495
- data = decode(msg.data)
496
- if is_async_function(handler):
638
+ # Regular message - decode wire message (zero-copy
639
+ # memoryview slices into msg.data for out-of-band
640
+ # binaries; nats-py delivers one bytes object per
641
+ # message, so the views are safe). Sync handlers run
642
+ # inline on the event loop — the thread-pool hop costs
643
+ # far more than the short callbacks used in this system.
644
+ data = decode_message(msg.data)
645
+ if handler_is_async:
497
646
  await handler(data) # type: ignore[misc]
498
647
  else:
499
- loop = asyncio.get_event_loop()
500
- await loop.run_in_executor(self.io_pool, handler, data)
648
+ handler(data)
501
649
 
502
650
  except Exception as e:
503
651
  print(f"Error processing message for {pattern}:", e)
504
652
  print(traceback.format_exc())
505
653
 
506
- sub = await self.nc.subscribe(pattern, queue=queue, cb=message_handler)
507
-
508
- # Ensure the subscription is ready
654
+ sub = await self.nc.subscribe(pattern, queue=entry.get("queue") or "", cb=message_handler)
655
+ # Yield once so nats-py's flusher pushes the SUB to the server before
656
+ # we return. Publishes from OTHER connections have no ordering
657
+ # guarantee against our pending SUB — without this yield a
658
+ # subscribe-then-peer-publishes pattern loses messages intermittently
659
+ # (reproduced via channel-native-request).
509
660
  await asyncio.sleep(0)
510
661
 
511
- if pattern not in self.subscriptions:
512
- self.subscriptions[pattern] = []
513
- self.subscriptions[pattern].append(sub)
514
-
515
- # Store subscription metadata for suspend/reconnect
516
- options: dict[str, Any] = {}
517
- if queue:
518
- options["queue"] = queue
519
- meta = {"pattern": pattern, "handler": handler, "options": options}
520
- self._subscription_meta.append(meta)
662
+ entry["sub"] = sub
663
+ self._watch_subscription_end(entry, sub)
521
664
 
522
- async def unsubscribe() -> None:
523
- with contextlib.suppress(Exception):
524
- await sub.unsubscribe()
665
+ def _watch_subscription_end(self, entry: dict[str, Any], sub: Subscription) -> None:
666
+ """Drop the entry when its msg task ends cleanly (user unsubscribe)."""
525
667
 
526
- if pattern in self.subscriptions:
527
- self.subscriptions[pattern] = [s for s in self.subscriptions[pattern] if s != sub]
528
- if not self.subscriptions[pattern]:
529
- del self.subscriptions[pattern]
668
+ def on_finish(fut: asyncio.Task[None]) -> None:
669
+ # Mirrors the previous watcher-task semantics: only a clean end of
670
+ # the msg task drops the entry (cancellation/errors leave it be).
671
+ if fut.cancelled() or fut.exception() is not None:
672
+ return
673
+ # The msg task also ends during suspend teardown and when the
674
+ # entry has already been restored onto a new subscription — in
675
+ # both cases the entry must survive so connect() can restore
676
+ # it (or keep the restored one alive).
677
+ if self._suspending or entry.get("sub") is not sub:
678
+ return
679
+ entry["sub"] = None
680
+ self._subscription_entries.pop(entry["key"], None)
681
+ if entry is self._mux_entry:
682
+ self._mux_entry = None
530
683
 
531
- # During suspend the NATS msg-task ends as a side-effect of the
532
- # transport teardown — we want to keep _subscription_meta so that
533
- # the next connect() can restore subscriptions.
534
- if not self._suspending:
535
- self._subscription_meta = [m for m in self._subscription_meta if m["pattern"] != pattern]
684
+ async def unsub() -> None:
685
+ with contextlib.suppress(Exception):
686
+ await sub.unsubscribe()
536
687
 
537
- async def on_finish(fut: asyncio.Task[None]) -> None:
538
- with contextlib.suppress(Exception):
539
- await asyncio.wait_for(fut, None)
540
- await unsubscribe()
688
+ asyncio.create_task(unsub())
541
689
 
542
690
  msg_task: asyncio.Task[None] | None = getattr(sub, "_wait_for_msgs_task", None)
543
691
  if msg_task:
544
- asyncio.create_task(on_finish(msg_task))
692
+ msg_task.add_done_callback(on_finish)
545
693
 
546
- return unsubscribe
694
+ def _register_mux_entry(self) -> dict[str, Any]:
695
+ """Register (idempotently) the muxed reply inbox subscription entry.
547
696
 
548
- async def _handle_assembled_data(self, handler: Callable[[Any], Any], data: Any) -> None:
697
+ The entry lives in _subscription_entries so connect()'s restore loop
698
+ (re-)subscribes it on first connect and after suspend cycles alike.
699
+ """
700
+ if self._mux_entry is None:
701
+ self._subscription_seq += 1
702
+ entry: dict[str, Any] = {
703
+ "key": self._subscription_seq,
704
+ "pattern": f"rpc.reply.{self._reply_prefix}.>",
705
+ "handler": self._handle_mux_message,
706
+ "handler_is_async": False,
707
+ "queue": "",
708
+ "sub": None,
709
+ "raw": True,
710
+ }
711
+ self._mux_entry = entry
712
+ self._subscription_entries[self._subscription_seq] = entry
713
+ return self._mux_entry
714
+
715
+ async def _ensure_mux_subscription(self) -> None:
716
+ """Ensure the single persistent reply-mux wildcard subscription exists.
717
+
718
+ Normally established by connect(); covers clients whose connection was
719
+ wired up out-of-band (tests). No-op when already subscribed.
720
+ """
721
+ entry = self._register_mux_entry()
722
+ sub: Subscription | None = entry.get("sub")
723
+ if self.nc and (sub is None or getattr(sub, "_closed", False)):
724
+ await self._nats_subscribe(entry)
725
+
726
+ def _handle_mux_message(self, msg: Msg) -> None:
727
+ """Dispatcher for the muxed reply inbox. Routes by message kind.
728
+
729
+ - 503/no-responder status (empty payload + status header 503): the
730
+ call/iterator is identified by the SUBJECT (`rpc.reply.<suffix>`) —
731
+ the server echoes the request's reply subject, there is no payload.
732
+ - chunked transfer header/chunk: reassemble via chunking_manager, then
733
+ route the assembled response by envelope id.
734
+ - regular response: decode and route by envelope id.
735
+ """
736
+ data = msg.data
737
+ headers = msg.headers
738
+
739
+ # No-responder status. Wire detail: the reply subject of an RPC call
740
+ # is exactly `rpc.reply.<call id>`, so the suffix IS the call id. For
741
+ # iterator/stream status inboxes the suffix is the registered token.
742
+ if (
743
+ (data is None or len(data) == 0)
744
+ and headers
745
+ and headers.get(Header.STATUS) == NO_RESPONDERS_STATUS
746
+ ): # pyright: ignore[reportUnnecessaryComparison]
747
+ suffix = msg.subject[len("rpc.reply.") :]
748
+
749
+ # One-shot (mirrors the previous per-iterator max_msgs=1 inboxes).
750
+ status_handler = self.status_handlers.pop(suffix, None)
751
+ if status_handler is not None:
752
+ try:
753
+ status_handler(errors.NoRespondersError(suffix))
754
+ except Exception as e:
755
+ print(f"Error in no-responder status handler: {e}")
756
+ return
757
+
758
+ pending = self.pending_requests.pop(suffix, None)
759
+ if pending is not None:
760
+ if timeout_handle := pending.get("timeout"):
761
+ timeout_handle.cancel()
762
+ future: asyncio.Future[Any] | None = pending.get("future")
763
+ if future is not None and not future.done():
764
+ future.set_exception(errors.NoRespondersError(pending.get("subject") or suffix))
765
+ return
766
+
767
+ chunk_type = headers.get("x-chunked-transfer") if headers else None
768
+
769
+ if chunk_type == "header":
770
+ decoded = decode(data)
771
+ chunk_id = headers.get("x-chunk-id") if headers else None
772
+ if not chunk_id or decoded.get("transferId") != chunk_id:
773
+ print("Invalid chunk header on reply mux")
774
+ return
775
+ self.chunking_manager.start_receiving(
776
+ decoded["transferId"],
777
+ decoded["totalChunks"],
778
+ self._route_mux_response,
779
+ lambda error: print(f"Error assembling chunked RPC response: {error}"),
780
+ decoded.get("totalSize"),
781
+ decoded.get("chunkSize"),
782
+ )
783
+ elif chunk_type == "chunk":
784
+ chunk_id = headers.get("x-chunk-id") if headers else None
785
+ if not chunk_id:
786
+ print("Chunk missing chunk ID on reply mux")
787
+ return
788
+ chunk_index = int(headers.get("x-chunk-index", "0")) if headers else 0
789
+ self.chunking_manager.process_chunk(
790
+ {"id": chunk_id, "chunkIndex": chunk_index, "data": data, "isLast": False}
791
+ )
792
+ else:
793
+ self._route_mux_response(decode_message(data))
794
+
795
+ def _route_mux_response(self, data: Any) -> None:
796
+ """Settle the pending request a (possibly reassembled) response belongs to.
797
+
798
+ Unknown ids are dropped silently — late replies after a timeout, or
799
+ traffic of another client sharing the same conn_id prefix.
800
+ """
801
+ if not isinstance(data, dict):
802
+ return
803
+ response = cast(dict[str, Any], data)
804
+ request_id = response.get("id")
805
+ if not request_id:
806
+ return
807
+
808
+ pending = self.pending_requests.pop(request_id, None)
809
+ if pending is None:
810
+ return
811
+ if timeout_handle := pending.get("timeout"):
812
+ timeout_handle.cancel()
813
+
814
+ future: asyncio.Future[Any] | None = pending.get("future")
815
+ if future is None or future.done():
816
+ return
817
+
818
+ if "error" in response:
819
+ future.set_exception(RPCException.from_dict(response["error"]))
820
+ else:
821
+ result = response.get("result")
822
+ # Attach __methods to result for proxy method discovery
823
+ if "__methods" in response and result is not None and isinstance(result, dict):
824
+ result["__methods"] = response["__methods"]
825
+ future.set_result(result)
826
+
827
+ async def _handle_assembled_data(
828
+ self, handler: Callable[[Any], Any], data: Any, handler_is_async: bool | None = None
829
+ ) -> None:
549
830
  """Handle assembled data from chunks."""
831
+ if handler_is_async is None:
832
+ handler_is_async = is_async_function(handler)
550
833
  try:
551
- if is_async_function(handler):
834
+ if handler_is_async:
552
835
  await handler(data) # pyright: ignore[reportGeneralTypeIssues]
553
836
  else:
554
- # Run sync handlers in thread pool to avoid blocking
555
- loop = asyncio.get_event_loop()
556
- await loop.run_in_executor(self.io_pool, handler, data)
837
+ handler(data)
557
838
  except Exception as e:
558
839
  print(f"Error in handler: {e}")
559
840
 
@@ -583,7 +864,7 @@ class RPCClient(RPCClientProtocol):
583
864
  raise RuntimeError("Not connected")
584
865
 
585
866
  timeout_sec = (timeout or 5000) / 1000 # Convert to seconds
586
- encoded = encode(data)
867
+ encoded = encode_message(data)
587
868
 
588
869
  try:
589
870
  msg = await self.nc.request(subject, encoded, timeout=timeout_sec, headers=headers)
@@ -596,10 +877,10 @@ class RPCClient(RPCClientProtocol):
596
877
  error_data = None
597
878
  if msg.data:
598
879
  with contextlib.suppress(Exception):
599
- error_data = decode(msg.data)
880
+ error_data = decode_message(msg.data)
600
881
  raise create_error(error_code, error_msg, error_data)
601
882
 
602
- decoded = decode(msg.data)
883
+ decoded = decode_message(msg.data)
603
884
 
604
885
  # Check if response contains an error field (for request handlers)
605
886
  if isinstance(decoded, dict) and "error" in decoded:
@@ -622,18 +903,23 @@ class RPCClient(RPCClientProtocol):
622
903
  subject: str,
623
904
  *args: Any,
624
905
  no_responder_retry: NoResponderRetryOptions | None = None,
906
+ discover: bool = False,
625
907
  ) -> Any:
626
908
  """Make an RPC call with no-responder retry.
627
909
 
628
910
  ``no_responder_retry`` overrides the client-wide retry config for this
629
911
  single call.
912
+
913
+ ``discover`` puts ``__discover: True`` on the request envelope, asking
914
+ the responder to attach its ``__methods`` list to the response.
915
+ Proxies request this only while their method cache is empty.
630
916
  """
631
917
  return await self._with_no_responder_retry(
632
- lambda: self._call_once(subject, *args),
918
+ lambda: self._call_once(subject, *args, discover=discover),
633
919
  override=no_responder_retry,
634
920
  )
635
921
 
636
- async def _call_once(self, subject: str, *args: Any) -> Any:
922
+ async def _call_once(self, subject: str, *args: Any, discover: bool = False) -> Any:
637
923
  """Make a single RPC call without retry."""
638
924
  if not self.is_connected and not self.is_closed:
639
925
  await self.connect()
@@ -641,29 +927,79 @@ class RPCClient(RPCClientProtocol):
641
927
  if not self.nc:
642
928
  raise RuntimeError("Not connected")
643
929
 
644
- request_id = generate_id()
930
+ # Service calls (`<subject>.reply.<id>`) keep the legacy per-call
931
+ # subscription flow — separate refactor later.
932
+ if not subject.startswith("rpc."):
933
+ return await self._call_once_service(subject, *args)
934
+
935
+ # Normally established by connect(); covers clients whose connection
936
+ # was wired up out-of-band (tests). No-op when already subscribed.
937
+ await self._ensure_mux_subscription()
938
+
939
+ request_id = generate_id(self._reply_prefix)
645
940
  timeout = self.options.get("timeout", 30000)
646
- # Use different reply patterns for RPC vs service calls
647
- reply_subject = (
648
- f"rpc.reply.{request_id}" if subject.startswith("rpc.") else f"{subject}.reply.{request_id}"
649
- )
941
+ # The reply subject is derived from the id by pure string
942
+ # concatenation — this is the wire contract with every responder
943
+ # implementation (Node, Go, Python): they publish the response to
944
+ # `rpc.reply.<msg id>` and treat the id as opaque. Because the id
945
+ # starts with our reply prefix, the muxed reply inbox
946
+ # (`rpc.reply.<reply_prefix>.>`) catches it.
947
+ reply_subject = f"rpc.reply.{request_id}"
650
948
 
651
- # Create future for the response
652
949
  future: asyncio.Future[Any] = asyncio.Future()
653
950
 
654
- # Setup timeout
951
+ # No per-call subscriptions: responses (plain or chunked) and
952
+ # no-responder statuses all arrive on the muxed reply inbox, which
953
+ # routes them back here via pending_requests. Cleanup only has to
954
+ # drop the map entry and the timer.
655
955
  def handle_timeout() -> None:
656
- if request_id in self.pending_requests:
657
- del self.pending_requests[request_id]
658
- if not future.done():
659
- future.set_exception(
660
- errors.TimeoutError(f"RPC call to {subject!r} timed out after {timeout}ms")
661
- )
956
+ if self.pending_requests.pop(request_id, None) is not None and not future.done():
957
+ future.set_exception(
958
+ errors.TimeoutError(f"RPC call to {subject!r} timed out after {timeout}ms")
959
+ )
662
960
 
663
961
  timeout_handle = asyncio.get_event_loop().call_later(timeout / 1000, handle_timeout)
664
962
 
665
- # Store pending request
666
- self.pending_requests[request_id] = {"future": future, "timeout": timeout_handle}
963
+ # `subject` feeds the NoRespondersError context when a 503 status
964
+ # arrives on the call's muxed reply subject. disconnect()/suspend()
965
+ # cancel the timer and settle the future via _cleanup_pending_requests.
966
+ self.pending_requests[request_id] = {
967
+ "future": future,
968
+ "timeout": timeout_handle,
969
+ "subject": subject,
970
+ }
971
+
972
+ # Send request. `reply` is set to the call's own reply subject so the
973
+ # NATS server delivers a no-responder 503 status to the SAME subject
974
+ # the real response would use — the mux catches both.
975
+ message: RPCMessage = {"id": request_id, "method": "call", "params": args}
976
+ if discover:
977
+ # Envelope marker (never in params — must not leak into handler
978
+ # args): ask the responder for its __methods list.
979
+ message["__discover"] = True
980
+ try:
981
+ await self.publish(subject, message, reply=reply_subject)
982
+ except Exception:
983
+ if self.pending_requests.pop(request_id, None) is not None:
984
+ timeout_handle.cancel()
985
+ raise
986
+
987
+ return await future
988
+
989
+ async def _call_once_service(self, subject: str, *args: Any) -> Any:
990
+ """Legacy single-attempt call for service subjects (reply pattern
991
+ `<subject>.reply.<id>`): per-call reply subscription + one-shot
992
+ no-responder inbox. The rpc.* path is muxed — see _call_once.
993
+ """
994
+ if not self.nc:
995
+ raise RuntimeError("Not connected")
996
+
997
+ request_id = generate_id(self._reply_prefix)
998
+ timeout = self.options.get("timeout", 30000)
999
+ reply_subject = f"{subject}.reply.{request_id}"
1000
+
1001
+ # Create future for the response
1002
+ future: asyncio.Future[Any] = asyncio.Future()
667
1003
 
668
1004
  # Handle no responders
669
1005
  sub: Subscription | None = None
@@ -681,6 +1017,27 @@ class RPCClient(RPCClientProtocol):
681
1017
  with contextlib.suppress(Exception):
682
1018
  await unsubscribe()
683
1019
 
1020
+ # Setup timeout. The subscriptions are torn down by the except-branch
1021
+ # below once the future's exception propagates out of `await future`.
1022
+ def handle_timeout() -> None:
1023
+ if request_id in self.pending_requests:
1024
+ del self.pending_requests[request_id]
1025
+ if not future.done():
1026
+ future.set_exception(
1027
+ errors.TimeoutError(f"RPC call to {subject!r} timed out after {timeout}ms")
1028
+ )
1029
+
1030
+ timeout_handle = asyncio.get_event_loop().call_later(timeout / 1000, handle_timeout)
1031
+
1032
+ # Store pending request. disconnect()/suspend() invoke the cleanup
1033
+ # hook so a suspended in-flight call doesn't leave its reply
1034
+ # subscription behind (it would be restored as a dead subscription).
1035
+ self.pending_requests[request_id] = {
1036
+ "future": future,
1037
+ "timeout": timeout_handle,
1038
+ "cleanup": unsubscribe_all,
1039
+ }
1040
+
684
1041
  # Subscribe to reply
685
1042
  async def handle_rpc_response(data: Any) -> None:
686
1043
  response = data # assuming data is already parsed as RPCResponse
@@ -690,16 +1047,22 @@ class RPCClient(RPCClientProtocol):
690
1047
  if pending:
691
1048
  del self.pending_requests[response["id"]]
692
1049
  pending["timeout"].cancel()
693
- await unsubscribe_all()
694
1050
 
1051
+ # Settle the caller BEFORE tearing down subscriptions:
1052
+ # unsubscribing the reply subscription cancels the message
1053
+ # task this handler runs in, which could abort us mid-way.
695
1054
  if "error" in response:
696
- pending["future"].set_exception(RPCException.from_dict(response["error"]))
1055
+ if not pending["future"].done():
1056
+ pending["future"].set_exception(RPCException.from_dict(response["error"]))
697
1057
  else:
698
1058
  result = response.get("result")
699
1059
  # Attach __methods to result for proxy method discovery
700
1060
  if "__methods" in response and result is not None and isinstance(result, dict):
701
1061
  result["__methods"] = response["__methods"]
702
- pending["future"].set_result(result)
1062
+ if not pending["future"].done():
1063
+ pending["future"].set_result(result)
1064
+
1065
+ await unsubscribe_all()
703
1066
 
704
1067
  async def request_callback(msg: Msg) -> None:
705
1068
  # Check for no responders status (empty message with 503 status)
@@ -803,7 +1166,9 @@ class RPCClient(RPCClientProtocol):
803
1166
  if not self.nc:
804
1167
  raise RuntimeError("Not connected")
805
1168
 
806
- request_id = generate_id()
1169
+ await self._ensure_mux_subscription()
1170
+
1171
+ request_id = generate_id(self._reply_prefix)
807
1172
  stream_subject = f"stream.{subject}.{request_id}"
808
1173
 
809
1174
  # Stream state
@@ -812,7 +1177,6 @@ class RPCClient(RPCClientProtocol):
812
1177
  error: Exception | None = None
813
1178
 
814
1179
  # Initialize variables
815
- sub: Subscription | None = None
816
1180
  unsubscribe: Callable[[], Coroutine[Any, Any, None]] | None = None
817
1181
 
818
1182
  def on_push(value: Any) -> None:
@@ -846,9 +1210,7 @@ class RPCClient(RPCClientProtocol):
846
1210
 
847
1211
  if request_id in self.stream_handlers:
848
1212
  del self.stream_handlers[request_id]
849
- if sub and not sub._closed: # pyright: ignore[reportPrivateUsage]
850
- with contextlib.suppress(Exception):
851
- await sub.unsubscribe()
1213
+ self.status_handlers.pop(request_id, None)
852
1214
  if unsubscribe:
853
1215
  with contextlib.suppress(Exception):
854
1216
  await unsubscribe()
@@ -879,22 +1241,17 @@ class RPCClient(RPCClientProtocol):
879
1241
  stream_handler["error"](RPCException.from_dict(error_data))
880
1242
  await unsubscribe_all()
881
1243
 
882
- async def request_callback(msg: Msg) -> None:
883
- # Check for no responders status (empty message with 503 status)
884
- if (
885
- (msg.data is None or len(msg.data) == 0) # pyright: ignore[reportUnnecessaryComparison]
886
- and msg.headers
887
- and msg.headers.get(Header.STATUS) == NO_RESPONDERS_STATUS
888
- ):
889
- stream_handler = self.stream_handlers.get(request_id)
890
- if stream_handler:
891
- stream_handler["error"](errors.NoRespondersError(subject))
892
- await unsubscribe_all()
893
-
894
1244
  unsubscribe = await self.subscribe(stream_subject, handle_stream_message)
895
1245
 
896
- inbox = self.nc.new_inbox()
897
- sub = await self.nc.subscribe(inbox, cb=request_callback, max_msgs=1)
1246
+ # No-responder detection via the muxed reply inbox: the request's
1247
+ # reply subject `rpc.reply.<id>` only ever carries a 503 status —
1248
+ # stream responders never publish a direct RPC response.
1249
+ def on_no_responders(_err: Exception) -> None:
1250
+ stream_handler = self.stream_handlers.get(request_id)
1251
+ if stream_handler:
1252
+ stream_handler["error"](errors.NoRespondersError(subject))
1253
+
1254
+ self.status_handlers[request_id] = on_no_responders
898
1255
 
899
1256
  try:
900
1257
  # Send request
@@ -904,7 +1261,7 @@ class RPCClient(RPCClientProtocol):
904
1261
  "args": args,
905
1262
  }
906
1263
  message: RPCMessage = {"id": request_id, "method": "stream", "params": stream_params}
907
- await self.publish(subject, message, reply=inbox)
1264
+ await self.publish(subject, message, reply=f"rpc.reply.{request_id}")
908
1265
 
909
1266
  # Generator implementation
910
1267
  while True:
@@ -938,25 +1295,60 @@ class RPCClient(RPCClientProtocol):
938
1295
  if not self.nc:
939
1296
  raise RuntimeError("Not connected")
940
1297
 
941
- iterator_id = generate_id()
1298
+ await self._ensure_mux_subscription()
1299
+
1300
+ iterator_id = generate_id(self._reply_prefix)
942
1301
  request_subject = f"_rpc.iterator.{iterator_id}.request"
943
1302
  response_subject = f"_rpc.iterator.{iterator_id}.response"
1303
+ # 503 status inbox for `next` requests, served by the muxed reply
1304
+ # inbox: iterator_id starts with the reply prefix, so this subject
1305
+ # falls under the mux wildcard. Real iterator responses keep arriving
1306
+ # on response_subject — only no-responder statuses land here.
1307
+ status_inbox = f"rpc.reply.{iterator_id}"
1308
+
1309
+ response_queue: asyncio.Queue[PullIteratorResponse] = asyncio.Queue()
1310
+ ended = False
1311
+ error: Exception | None = None
1312
+
1313
+ # Registered with the client for the iterator's lifetime: a consumer
1314
+ # parked in `await response_queue.get()` must be force-settled when
1315
+ # the connection tears down (disconnect/suspend), or its `async for`
1316
+ # hangs forever.
1317
+ def settle_on_disconnect() -> None:
1318
+ nonlocal ended, error
1319
+ if not ended:
1320
+ ended = True
1321
+ error = create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed")
1322
+ err = (
1323
+ error
1324
+ if isinstance(error, RPCException)
1325
+ else create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed")
1326
+ )
1327
+ settle_msg: PullIteratorResponse = {
1328
+ "type": "error",
1329
+ "id": iterator_id,
1330
+ "error": err.to_dict(),
1331
+ }
1332
+ response_queue.put_nowait(settle_msg)
1333
+
1334
+ self._pull_iterator_settles.add(settle_on_disconnect)
944
1335
 
945
1336
  # Initialize the pull iterator using regular call method
946
1337
  # We pass a special __pullIterator marker as the first argument
947
- init_response = await self.call(
948
- subject, {"__pullIterator": True, "__iteratorId": iterator_id, "args": args}
949
- )
1338
+ try:
1339
+ init_response = await self.call(
1340
+ subject, {"__pullIterator": True, "__iteratorId": iterator_id, "args": args}
1341
+ )
1342
+ except Exception:
1343
+ self._pull_iterator_settles.discard(settle_on_disconnect)
1344
+ raise
950
1345
 
951
1346
  # The response should contain the iterator ID (same as we sent)
952
1347
  if not init_response or init_response.get("iteratorId") != iterator_id:
1348
+ self._pull_iterator_settles.discard(settle_on_disconnect)
953
1349
  raise RuntimeError("Failed to initialize pull iterator")
954
1350
 
955
1351
  # Subscribe to responses
956
- response_queue: asyncio.Queue[PullIteratorResponse] = asyncio.Queue()
957
- ended = False
958
- error: Exception | None = None
959
-
960
1352
  async def handle_response(msg: PullIteratorResponse) -> None:
961
1353
  nonlocal ended, error
962
1354
 
@@ -972,13 +1364,12 @@ class RPCClient(RPCClientProtocol):
972
1364
  unsubscribe = await self.subscribe(response_subject, handle_response)
973
1365
 
974
1366
  async def cleanup() -> None:
1367
+ self._pull_iterator_settles.discard(settle_on_disconnect)
1368
+ self.status_handlers.pop(iterator_id, None)
1369
+
975
1370
  if unsubscribe is not None:
976
1371
  await unsubscribe()
977
1372
 
978
- if sub and not sub._closed: # pyright: ignore[reportPrivateUsage]
979
- with contextlib.suppress(Exception):
980
- await sub.unsubscribe()
981
-
982
1373
  # Send cancel request
983
1374
  if not ended:
984
1375
  with contextlib.suppress(Exception):
@@ -988,37 +1379,33 @@ class RPCClient(RPCClientProtocol):
988
1379
  }
989
1380
  await self.publish(request_subject, cancel_request)
990
1381
 
991
- async def request_callback(msg2: Msg) -> None:
1382
+ def on_no_responders(_err: Exception) -> None:
992
1383
  nonlocal ended, error
993
1384
 
994
- # Check for no responders status (empty message with 503 status)
995
- if (
996
- (msg2.data is None or len(msg2.data) == 0) # pyright: ignore[reportUnnecessaryComparison]
997
- and msg2.headers
998
- and msg2.headers.get(Header.STATUS) == NO_RESPONDERS_STATUS
999
- ):
1000
- ended = True
1001
- error = create_error("503", str(errors.NoRespondersError(subject)))
1002
-
1003
- msg: PullIteratorResponse = {
1004
- "type": "error",
1005
- "id": iterator_id,
1006
- "error": error.to_dict(),
1007
- }
1008
-
1009
- await response_queue.put(msg)
1385
+ ended = True
1386
+ error = create_error("503", str(errors.NoRespondersError(subject)))
1387
+ status_msg: PullIteratorResponse = {
1388
+ "type": "error",
1389
+ "id": iterator_id,
1390
+ "error": error.to_dict(),
1391
+ }
1392
+ response_queue.put_nowait(status_msg)
1010
1393
 
1011
- inbox = self.nc.new_inbox()
1012
- sub = await self.nc.subscribe(inbox, cb=request_callback, max_msgs=1)
1394
+ self.status_handlers[iterator_id] = on_no_responders
1013
1395
 
1014
1396
  try:
1397
+ # Every `next` carries the no-responder status inbox: the
1398
+ # registered status handler stays live until a 503 actually
1399
+ # arrives, so this is the liveness signal when the responder dies
1400
+ # MID-iteration (its subscription vanishes -> NATS 503s the reply
1401
+ # subject, which the mux routes here).
1015
1402
  while True:
1016
1403
  # Send next request
1017
1404
  next_request: PullIteratorRequest = {
1018
1405
  "id": iterator_id,
1019
1406
  "type": "next",
1020
1407
  }
1021
- await self.publish(request_subject, next_request, reply=inbox)
1408
+ await self.publish(request_subject, next_request, reply=status_inbox)
1022
1409
 
1023
1410
  # Wait for response
1024
1411
  response = await response_queue.get()
@@ -1057,47 +1444,83 @@ class RPCClient(RPCClientProtocol):
1057
1444
  if not self.nc:
1058
1445
  raise RuntimeError("Not connected")
1059
1446
 
1060
- iterator_id = generate_id()
1447
+ await self._ensure_mux_subscription()
1448
+
1449
+ iterator_id = generate_id(self._reply_prefix)
1061
1450
  request_subject = f"_rpc.iterator.{iterator_id}.request"
1062
1451
  response_subject = f"_rpc.iterator.{iterator_id}.response"
1063
1452
  callback_subject = f"_rpc.cb.{iterator_id}"
1453
+ # 503 status inbox for `next` requests — see call_pull_iterator.
1454
+ status_inbox = f"rpc.reply.{iterator_id}"
1064
1455
 
1065
1456
  callback_methods = list(callbacks.keys())
1066
1457
 
1067
- # Track a task-chain of pending callback handlers. The iterator loop
1068
- # awaits this chain before yielding, so a slow handler blocks the
1458
+ # A single long-lived consumer task drains callback messages from a
1459
+ # queue in order (no task per message). The iterator loop waits for
1460
+ # the queue to drain before yielding, so a slow handler blocks the
1069
1461
  # next `next()` request — which stalls the server at its own yield.
1070
1462
  # This is what gives true end-to-end backpressure.
1071
- callback_task: asyncio.Task[None] | None = None
1463
+ callback_is_async = {name: is_async_function(fn) for name, fn in callbacks.items()}
1464
+ cb_queue: asyncio.Queue[tuple[Callable[..., Any], list[Any], str, bool]] = asyncio.Queue()
1465
+
1466
+ async def consume_callbacks() -> None:
1467
+ while True:
1468
+ fn, fn_args, method, fn_is_async = await cb_queue.get()
1469
+ try:
1470
+ if fn_is_async:
1471
+ await fn(*fn_args)
1472
+ else:
1473
+ fn(*fn_args)
1474
+ except Exception as e:
1475
+ print(f"[rpc] Pull-callback handler '{method}' threw: {e}")
1476
+ finally:
1477
+ cb_queue.task_done()
1478
+
1479
+ consumer_task = asyncio.create_task(consume_callbacks())
1480
+
1481
+ async def stop_consumer() -> None:
1482
+ consumer_task.cancel()
1483
+ with contextlib.suppress(asyncio.CancelledError):
1484
+ await consumer_task
1072
1485
 
1073
1486
  # Subscribe to callback channel BEFORE init call.
1074
1487
  async def handle_cb_msg(msg: Any) -> None:
1075
- nonlocal callback_task
1076
1488
  if not isinstance(msg, dict):
1077
1489
  return
1078
1490
  method = msg.get("method")
1079
- fn = callbacks.get(method) if method else None
1491
+ if not isinstance(method, str):
1492
+ return
1493
+ fn = callbacks.get(method)
1080
1494
  if fn is None:
1081
1495
  return
1082
- fn_args = msg.get("args") or []
1496
+ cb_queue.put_nowait((fn, msg.get("args") or [], method, callback_is_async[method]))
1083
1497
 
1084
- prev = callback_task
1498
+ cb_unsub = await self.subscribe(callback_subject, handle_cb_msg)
1085
1499
 
1086
- async def run_chained() -> None:
1087
- if prev is not None:
1088
- with contextlib.suppress(Exception):
1089
- await prev
1090
- try:
1091
- if is_async_function(fn):
1092
- await fn(*fn_args)
1093
- else:
1094
- fn(*fn_args)
1095
- except Exception as e:
1096
- print(f"[rpc] Pull-callback handler '{method}' threw: {e}")
1500
+ response_queue: asyncio.Queue[PullIteratorResponse] = asyncio.Queue()
1501
+ ended = False
1502
+ error: Exception | None = None
1097
1503
 
1098
- callback_task = asyncio.create_task(run_chained())
1504
+ # See call_pull_iterator: force-settle a parked queue.get() on
1505
+ # connection teardown so the consumer's `async for` terminates.
1506
+ def settle_on_disconnect() -> None:
1507
+ nonlocal ended, error
1508
+ if not ended:
1509
+ ended = True
1510
+ error = create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed")
1511
+ err = (
1512
+ error
1513
+ if isinstance(error, RPCException)
1514
+ else create_error(ErrorCode.CONNECTION_CLOSED, "Connection closed")
1515
+ )
1516
+ settle_msg: PullIteratorResponse = {
1517
+ "type": "error",
1518
+ "id": iterator_id,
1519
+ "error": err.to_dict(),
1520
+ }
1521
+ response_queue.put_nowait(settle_msg)
1099
1522
 
1100
- cb_unsub = await self.subscribe(callback_subject, handle_cb_msg)
1523
+ self._pull_iterator_settles.add(settle_on_disconnect)
1101
1524
 
1102
1525
  # Init the pull-callback session via regular RPC call.
1103
1526
  init_params: PullCallbackParams = {
@@ -1112,17 +1535,17 @@ class RPCClient(RPCClientProtocol):
1112
1535
  try:
1113
1536
  init_response = await self.call(subject, init_params)
1114
1537
  except Exception:
1538
+ self._pull_iterator_settles.discard(settle_on_disconnect)
1115
1539
  await cb_unsub()
1540
+ await stop_consumer()
1116
1541
  raise
1117
1542
 
1118
1543
  if not init_response or init_response.get("iteratorId") != iterator_id:
1544
+ self._pull_iterator_settles.discard(settle_on_disconnect)
1119
1545
  await cb_unsub()
1546
+ await stop_consumer()
1120
1547
  raise RuntimeError("Failed to initialize pull-callback iterator")
1121
1548
 
1122
- response_queue: asyncio.Queue[PullIteratorResponse] = asyncio.Queue()
1123
- ended = False
1124
- error: Exception | None = None
1125
-
1126
1549
  async def handle_response(msg: PullIteratorResponse) -> None:
1127
1550
  nonlocal ended, error
1128
1551
  if msg.get("type") == "error":
@@ -1135,32 +1558,27 @@ class RPCClient(RPCClientProtocol):
1135
1558
 
1136
1559
  resp_unsub = await self.subscribe(response_subject, handle_response)
1137
1560
 
1138
- async def request_callback(msg2: Msg) -> None:
1561
+ def on_no_responders(_err: Exception) -> None:
1139
1562
  nonlocal ended, error
1140
- if (
1141
- (msg2.data is None or len(msg2.data) == 0) # pyright: ignore[reportUnnecessaryComparison]
1142
- and msg2.headers
1143
- and msg2.headers.get(Header.STATUS) == NO_RESPONDERS_STATUS
1144
- ):
1145
- ended = True
1146
- error = create_error("503", str(errors.NoRespondersError(subject)))
1147
- msg: PullIteratorResponse = {
1148
- "type": "error",
1149
- "id": iterator_id,
1150
- "error": error.to_dict(),
1151
- }
1152
- await response_queue.put(msg)
1153
1563
 
1154
- inbox = self.nc.new_inbox()
1155
- sub = await self.nc.subscribe(inbox, cb=request_callback, max_msgs=1)
1564
+ ended = True
1565
+ error = create_error("503", str(errors.NoRespondersError(subject)))
1566
+ status_msg: PullIteratorResponse = {
1567
+ "type": "error",
1568
+ "id": iterator_id,
1569
+ "error": error.to_dict(),
1570
+ }
1571
+ response_queue.put_nowait(status_msg)
1572
+
1573
+ self.status_handlers[iterator_id] = on_no_responders
1156
1574
 
1157
1575
  async def cleanup() -> None:
1576
+ self._pull_iterator_settles.discard(settle_on_disconnect)
1577
+ self.status_handlers.pop(iterator_id, None)
1158
1578
  await cb_unsub()
1579
+ await stop_consumer()
1159
1580
  if resp_unsub is not None:
1160
1581
  await resp_unsub()
1161
- if sub and not sub._closed: # pyright: ignore[reportPrivateUsage]
1162
- with contextlib.suppress(Exception):
1163
- await sub.unsubscribe()
1164
1582
  if not ended:
1165
1583
  with contextlib.suppress(Exception):
1166
1584
  cancel_request: PullIteratorRequest = {
@@ -1170,12 +1588,14 @@ class RPCClient(RPCClientProtocol):
1170
1588
  await self.publish(request_subject, cancel_request)
1171
1589
 
1172
1590
  try:
1591
+ # See call_pull_iterator: every `next` carries the no-responder
1592
+ # status inbox — it is the mid-iteration responder-death signal.
1173
1593
  while True:
1174
1594
  next_request: PullIteratorRequest = {
1175
1595
  "id": iterator_id,
1176
1596
  "type": "next",
1177
1597
  }
1178
- await self.publish(request_subject, next_request, reply=inbox)
1598
+ await self.publish(request_subject, next_request, reply=status_inbox)
1179
1599
 
1180
1600
  response = await response_queue.get()
1181
1601
 
@@ -1183,15 +1603,18 @@ class RPCClient(RPCClientProtocol):
1183
1603
  if error_data := response.get("error"):
1184
1604
  raise RPCException.from_dict(error_data)
1185
1605
  elif response.get("type") == "done":
1606
+ # Drain trailing frames delivered before the done signal —
1607
+ # the old task-chain would have run them to completion too.
1608
+ await cb_queue.join()
1186
1609
  break
1187
1610
  elif response.get("type") == "value":
1188
- # Wait for all callback handlers dispatched so far. If a
1189
- # handler awaits a bounded queue (backpressure), this
1190
- # stalls the iterator here until the handler releases,
1191
- # which suspends the server at its own yield.
1192
- if callback_task is not None:
1193
- with contextlib.suppress(Exception):
1194
- await callback_task
1611
+ # Wait until every callback message received so far has
1612
+ # been processed (batch boundary only after all frames of
1613
+ # the batch). If a handler awaits a bounded queue
1614
+ # (backpressure), this stalls the iterator here until the
1615
+ # handler releases, which suspends the server at its own
1616
+ # yield.
1617
+ await cb_queue.join()
1195
1618
  yield None
1196
1619
  finally:
1197
1620
  await cleanup()
@@ -1225,15 +1648,16 @@ class RPCClient(RPCClientProtocol):
1225
1648
  if not self.nc:
1226
1649
  raise RuntimeError("Not connected")
1227
1650
 
1228
- request_id = generate_id()
1651
+ request_id = generate_id(self._reply_prefix)
1229
1652
  callback_subject = f"rpc.cb.{request_id}"
1653
+ callback_is_async = is_async_function(callback)
1230
1654
 
1231
1655
  # Subscribe to callback messages
1232
1656
  async def handle_callback_msg(msg: Any) -> None:
1233
1657
  if isinstance(msg, dict):
1234
1658
  if msg.get("type") == "data":
1235
1659
  try:
1236
- if is_async_function(callback):
1660
+ if callback_is_async:
1237
1661
  await callback(msg.get("data"))
1238
1662
  else:
1239
1663
  callback(msg.get("data"))
@@ -1292,6 +1716,7 @@ class RPCClient(RPCClientProtocol):
1292
1716
 
1293
1717
  unsubscribers: list[Callable[[], Coroutine[Any, Any, None]]] = []
1294
1718
  pull_iterator_ids: list[str] = []
1719
+ callback_ids: list[str] = []
1295
1720
 
1296
1721
  # Extract methods based on option
1297
1722
  handlers_map = (
@@ -1303,12 +1728,26 @@ class RPCClient(RPCClientProtocol):
1303
1728
 
1304
1729
  for method, handler in handlers_map.items():
1305
1730
  subject = f"rpc.{namespace}.{method}"
1731
+ # Determined once per handler instead of per message.
1732
+ handler_is_async = is_async_function(handler)
1306
1733
 
1307
1734
  async def handle_message(
1308
- msg: Any, handler: Any = handler, method_names: list[str] = method_names
1735
+ msg: Any,
1736
+ handler: Any = handler,
1737
+ method_names: list[str] = method_names,
1738
+ handler_is_async: bool = handler_is_async,
1309
1739
  ) -> None:
1310
1740
  message = msg
1311
- response: RPCResponse = {"id": message["id"], "__methods": method_names}
1741
+ response: RPCResponse = {"id": message["id"]}
1742
+
1743
+ # Method discovery on demand: only a request whose envelope
1744
+ # carries __discover (a proxy with an empty method cache) pays
1745
+ # for the namespace's method list — attaching it to every
1746
+ # response would be dead wire weight once the proxy cache is
1747
+ # filled. Old clients never send __discover and never read
1748
+ # __methods on this path.
1749
+ if isinstance(message, dict) and message.get("__discover") is True:
1750
+ response["__methods"] = method_names
1312
1751
 
1313
1752
  try:
1314
1753
  # Handle stream request
@@ -1384,6 +1823,7 @@ class RPCClient(RPCClientProtocol):
1384
1823
  iterator_id,
1385
1824
  client,
1386
1825
  client.io_pool,
1826
+ on_finished=partial(client.pull_iterator_cleanups.pop, iterator_id, None),
1387
1827
  )
1388
1828
 
1389
1829
  # Store cleanup function for later
@@ -1409,6 +1849,7 @@ class RPCClient(RPCClientProtocol):
1409
1849
  pc_oneway_methods,
1410
1850
  client,
1411
1851
  client.io_pool,
1852
+ on_finished=partial(client.pull_iterator_cleanups.pop, pc_iterator_id, None),
1412
1853
  )
1413
1854
 
1414
1855
  client.pull_iterator_cleanups[pc_iterator_id] = pc_cleanup
@@ -1442,8 +1883,10 @@ class RPCClient(RPCClientProtocol):
1442
1883
  message["id"],
1443
1884
  client,
1444
1885
  client.io_pool,
1886
+ on_finished=partial(client.callback_cleanups.pop, message["id"], None),
1445
1887
  )
1446
1888
  client.callback_cleanups[message["id"]] = cb_cleanup
1889
+ callback_ids.append(message["id"])
1447
1890
 
1448
1891
  response["result"] = {"ok": True}
1449
1892
  reply_subject = f"rpc.reply.{message['id']}"
@@ -1454,6 +1897,7 @@ class RPCClient(RPCClientProtocol):
1454
1897
  handler,
1455
1898
  message.get("params", []),
1456
1899
  client.io_pool,
1900
+ handler_is_async,
1457
1901
  )
1458
1902
  response["result"] = result
1459
1903
 
@@ -1462,7 +1906,13 @@ class RPCClient(RPCClientProtocol):
1462
1906
  await client.publish(reply_subject, response)
1463
1907
 
1464
1908
  except Exception as e:
1465
- response["error"] = format_error_dict(e)
1909
+ error_dict = format_error_dict(e)
1910
+ response["error"] = error_dict
1911
+
1912
+ # Diagnostic aid: a METHOD_NOT_FOUND error always carries
1913
+ # the method list, discovery requested or not (rare, small).
1914
+ if error_dict["code"] == ErrorCode.METHOD_NOT_FOUND.value:
1915
+ response["__methods"] = method_names
1466
1916
 
1467
1917
  try:
1468
1918
  # If handler raised an exception, send error response
@@ -1482,23 +1932,23 @@ class RPCClient(RPCClientProtocol):
1482
1932
  # Unsubscribe all handlers
1483
1933
  await asyncio.gather(*(unsub() for unsub in unsubscribers), return_exceptions=True)
1484
1934
 
1935
+ # Cleanup pull iterators / callbacks — only the ones THIS
1936
+ # register_handler call created. The client maps are shared across
1937
+ # register_handler calls; sweeping them wholesale would kill the
1938
+ # live sessions of every other namespace.
1485
1939
  async def cleanup_iterator(iterator_id: str) -> None:
1486
- if cleanup := client.pull_iterator_cleanups.get(iterator_id):
1487
- await cleanup()
1488
- del client.pull_iterator_cleanups[iterator_id]
1940
+ if fn := client.pull_iterator_cleanups.pop(iterator_id, None):
1941
+ await fn()
1489
1942
 
1490
- # Cleanup pull iterators
1491
1943
  await asyncio.gather(
1492
1944
  *(cleanup_iterator(iterator_id) for iterator_id in pull_iterator_ids),
1493
1945
  return_exceptions=True,
1494
1946
  )
1495
1947
 
1496
- # Cleanup callbacks
1497
- callback_cleanups = list(client.callback_cleanups.items())
1498
- for cb_id, cb_cleanup in callback_cleanups:
1499
- with contextlib.suppress(Exception):
1500
- await cb_cleanup()
1501
- client.callback_cleanups.pop(cb_id, None)
1948
+ for cb_id in callback_ids:
1949
+ if cb_cleanup := client.callback_cleanups.pop(cb_id, None):
1950
+ with contextlib.suppress(Exception):
1951
+ await cb_cleanup()
1502
1952
 
1503
1953
  if isolated_connection:
1504
1954
  # Disconnect isolated client if it was created
@@ -1515,28 +1965,29 @@ class RPCClient(RPCClientProtocol):
1515
1965
  if not self.nc:
1516
1966
  raise RuntimeError("Not connected")
1517
1967
 
1968
+ handler_is_async = is_async_function(handler)
1969
+
1518
1970
  async def handle_request(msg: Msg) -> None:
1519
1971
  try:
1520
- # Decode request
1521
- data = decode(msg.data)
1972
+ # Decode request (zero-copy binary views into msg.data)
1973
+ data = decode_message(msg.data)
1522
1974
 
1523
- # Call handler with subject
1524
- if is_async_function(handler):
1975
+ # Call handler with subject. Sync handlers run inline —
1976
+ # cheaper than the thread-pool hop and preserves ordering.
1977
+ if handler_is_async:
1525
1978
  result = await handler(data) # pyright: ignore[reportGeneralTypeIssues]
1526
1979
  else:
1527
- loop = asyncio.get_event_loop()
1528
- func = partial(handler, data)
1529
- result = await loop.run_in_executor(self.io_pool, func)
1980
+ result = handler(data)
1530
1981
 
1531
1982
  # Send response
1532
1983
  if msg.reply:
1533
- response = encode(result)
1984
+ response = encode_message(result)
1534
1985
  await msg.respond(response)
1535
1986
 
1536
1987
  except Exception as e:
1537
1988
  # Send error response
1538
1989
  if msg.reply:
1539
- error_response = encode(
1990
+ error_response = encode_message(
1540
1991
  {
1541
1992
  "error": str(e),
1542
1993
  "code": e.code if isinstance(e, RPCException) else ErrorCode.INTERNAL_ERROR.value,
@@ -1545,8 +1996,8 @@ class RPCClient(RPCClientProtocol):
1545
1996
  await msg.respond(error_response)
1546
1997
 
1547
1998
  sub = await self.nc.subscribe(pattern, cb=handle_request)
1548
-
1549
- # Ensure the subscription is ready
1999
+ # See _nats_subscribe: yield so the flusher sends the SUB before we
2000
+ # return cross-connection publishes have no ordering guarantee.
1550
2001
  await asyncio.sleep(0)
1551
2002
 
1552
2003
  async def unsubscribe() -> None: