@camera.ui/server 0.0.104 → 0.0.106
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.
- package/dist/interface/assets/{404-CJ99-HuF.js → 404-BAYYYl25.js} +1 -1
- package/dist/interface/assets/{About-B0RkqPCF.js → About-CF96M6HY.js} +1 -1
- package/dist/interface/assets/{Adminpanel-BzjRR4E-.js → Adminpanel-DOaJyAyb.js} +1 -1
- package/dist/interface/assets/{Automation-BxZOk7_I.js → Automation-CKXlldCc.js} +2 -2
- package/dist/interface/assets/{Automations-DCnYU4ec.js → Automations-Bly4du8g.js} +1 -1
- package/dist/interface/assets/{Camera-uZPZMDm6.js → Camera-B_iqc3Zu.js} +2 -2
- package/dist/interface/assets/{CameraConsole-C_TUwy4O.js → CameraConsole-DmkR-ATY.js} +1 -1
- package/dist/interface/assets/{Cameras-CCjDWbDk.js → Cameras-CBJqTSnN.js} +1 -1
- package/dist/interface/assets/{Camview-DGtE5Z3s.js → Camview-CF_Fa0E1.js} +1 -1
- package/dist/interface/assets/{Config-QQ7HALtt.js → Config-B10TESNa.js} +1 -1
- package/dist/interface/assets/{ConfigActionCameraControl-C7ZfjHnE.js → ConfigActionCameraControl-BWpRxipc.js} +1 -1
- package/dist/interface/assets/{ConfigActionDelay-C9zOKCqa.js → ConfigActionDelay-NBOa8ulm.js} +1 -1
- package/dist/interface/assets/{ConfigActionHttp-zeFP7jW9.js → ConfigActionHttp-CpeMles1.js} +1 -1
- package/dist/interface/assets/{ConfigActionImageInput-D19IiOQh.js → ConfigActionImageInput-He5suR1p.js} +1 -1
- package/dist/interface/assets/{ConfigActionNotification-vKL_FJi6.js → ConfigActionNotification-BrYGUn_B.js} +1 -1
- package/dist/interface/assets/{ConfigActionOutput-C9P9NVNR.js → ConfigActionOutput-Dy32ypu0.js} +1 -1
- package/dist/interface/assets/{ConfigActionPlugin-BfjaRyTe.js → ConfigActionPlugin-DZgDVj2J.js} +1 -1
- package/dist/interface/assets/{ConfigActionSensor-BGIRJ1VF.js → ConfigActionSensor-B9aJwfb2.js} +1 -1
- package/dist/interface/assets/{ConfigActionSnapshot-ClUBwM1R.js → ConfigActionSnapshot-Bisnu1Va.js} +1 -1
- package/dist/interface/assets/{ConfigActionVariable-COV3XNCP.js → ConfigActionVariable-BcQ-pUs-.js} +1 -1
- package/dist/interface/assets/{ConfigConditionIfElse-CRzCscc-.js → ConfigConditionIfElse-DKyGnLRO.js} +1 -1
- package/dist/interface/assets/{ConfigConditionSensorState-DZJkEOJX.js → ConfigConditionSensorState-mKh6F8lJ.js} +1 -1
- package/dist/interface/assets/{ConfigConditionSwitch-CY52JGdW.js → ConfigConditionSwitch-CSZTkyIE.js} +1 -1
- package/dist/interface/assets/{ConfigConditionTime-o7CY--td.js → ConfigConditionTime-C8_R0ITE.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerDetection-DRM0HbdD.js → ConfigTriggerDetection-a2NV6Ke1.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerGeofence-CJi5c7cN.js → ConfigTriggerGeofence-DnS6ONJC.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerManual-B8iZq1a7.js → ConfigTriggerManual-CrKYDCVp.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSchedule-sG8s23os.js → ConfigTriggerSchedule-Dc6k-hJ2.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSensor-Dc9qP7jS.js → ConfigTriggerSensor-lmBAbpAF.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerSystem-wZjLxEWq.js → ConfigTriggerSystem-BibBSkft.js} +1 -1
- package/dist/interface/assets/{ConfigTriggerWebhook-BpWO2kXx.js → ConfigTriggerWebhook-CcMI4u82.js} +1 -1
- package/dist/interface/assets/{Console-B79GknLp.js → Console-CiqXek8C.js} +1 -1
- package/dist/interface/assets/{CuiActionButton-DaeBGbij.js → CuiActionButton-DmV0jhr5.js} +1 -1
- package/dist/interface/assets/{CuiAvatar-CMR4MZZv.js → CuiAvatar-DMZqcLoE.js} +1 -1
- package/dist/interface/assets/{CuiBBoxPlayground-BJxrh8Hk.js → CuiBBoxPlayground-BakfNGCT.js} +1 -1
- package/dist/interface/assets/CuiBottomSheet-BJl4lLzC.js +1 -0
- package/dist/interface/assets/{CuiBottomSheet-DmanZWbm.js → CuiBottomSheet-CkJEMXQy.js} +1 -1
- package/dist/interface/assets/CuiCameraCard-jWlEnJAQ.js +1 -0
- package/dist/interface/assets/{CuiCameraPipCard-BvJJFMV2.js → CuiCameraPipCard-ClOJg9E-.js} +1 -1
- package/dist/interface/assets/CuiCameraRecordings-BhM5UivW.js +1 -0
- package/dist/interface/assets/{CuiCameraShares-BNyn9lMv.js → CuiCameraShares-CktUxrlR.js} +1 -1
- package/dist/interface/assets/{CuiCameraSnapshot-DzweaAbD.js → CuiCameraSnapshot-BE1xeM4q.js} +1 -1
- package/dist/interface/assets/{CuiCameraTable-CCu87mix.js → CuiCameraTable-D87v9LhC.js} +1 -1
- package/dist/interface/assets/{CuiDataTable-Q0cMKDSG.js → CuiDataTable-C5toN4L-.js} +1 -1
- package/dist/interface/assets/{CuiFloatingButton-CLb4OXWq.js → CuiFloatingButton-DvdpuxUr.js} +1 -1
- package/dist/interface/assets/{CuiImage-OMrwpQrB.js → CuiImage-C-DSHyf2.js} +1 -1
- package/dist/interface/assets/{CuiMenu-BXurM_rM.js → CuiMenu-CwumqO_L.js} +1 -1
- package/dist/interface/assets/{CuiPluginCard-o4XyKYKV.js → CuiPluginCard-DIqaLKcf.js} +2 -2
- package/dist/interface/assets/{CuiPluginOAuthButton-CT_taZrW.js → CuiPluginOAuthButton-CBTeQEb9.js} +2 -2
- package/dist/interface/assets/{CuiQRCode-Dv03yW5H.js → CuiQRCode-ChJuYXmm.js} +1 -1
- package/dist/interface/assets/{CuiRecordingsGrid-34f2-J9w.js → CuiRecordingsGrid-D1mYCCcE.js} +1 -1
- package/dist/interface/assets/{CuiSchema-DPegn_DL.js → CuiSchema-BIGweVJy.js} +1 -1
- package/dist/interface/assets/{CuiTopNavbarItem-D1yZXVK4.js → CuiTopNavbarItem-FXY4aBoC.js} +1 -1
- package/dist/interface/assets/{CuiTopbarSlot-D8e3DC_V.js → CuiTopbarSlot-DCeAqmkQ.js} +1 -1
- package/dist/interface/assets/{Faces-CNCw8AXF.js → Faces-B6JlzZ5i.js} +1 -1
- package/dist/interface/assets/{FirstSteps-Dcr9xd_H.js → FirstSteps-CH1iro2p.js} +1 -1
- package/dist/interface/assets/{Instances-DCrJqqdk.js → Instances-BNNA4Z6H.js} +1 -1
- package/dist/interface/assets/{Logs-9KBZQ4SH.js → Logs-BkF4EFHV.js} +1 -1
- package/dist/interface/assets/{Menu-D82CRc95.js → Menu-DYejNhsi.js} +1 -1
- package/dist/interface/assets/{OAuthDeviceFlow-C6sk2XbU.js → OAuthDeviceFlow-BVeSCJiJ.js} +1 -1
- package/dist/interface/assets/{Plugin-DY1-hTa-.js → Plugin-zvdoGW2r.js} +1 -1
- package/dist/interface/assets/{PluginConsole-D8K2fOf6.js → PluginConsole-CQA2A5Wc.js} +1 -1
- package/dist/interface/assets/{PluginSchema-JKttwvkD.js → PluginSchema-xof7AYIK.js} +1 -1
- package/dist/interface/assets/{Plugins-5y2A-S9R.js → Plugins-DAwDjr_I.js} +2 -2
- package/dist/interface/assets/Recordings-D7UhoAKi.js +1 -0
- package/dist/interface/assets/{Settings-BQSZwEux.js → Settings-CnU98cZv.js} +1 -1
- package/dist/interface/assets/{SettingsAccount-7HSGeh_G.js → SettingsAccount-BVIztMFL.js} +1 -1
- package/dist/interface/assets/SettingsAccount-BmuJLRLH.js +1 -0
- package/dist/interface/assets/{SettingsAppearance-DAtwb17b.js → SettingsAppearance-CW8mbN9Q.js} +1 -1
- package/dist/interface/assets/SettingsAppearance-CiVRn_RM.js +1 -0
- package/dist/interface/assets/{SettingsBackup-Bj2pUzxN.js → SettingsBackup-DDNiOgvA.js} +1 -1
- package/dist/interface/assets/{SettingsNotifications-Bk40tvnT.js → SettingsNotifications-DLMT1o4I.js} +1 -1
- package/dist/interface/assets/{SettingsRecordings-BfbVsR6a.js → SettingsRecordings-DirvHeKB.js} +1 -1
- package/dist/interface/assets/{SettingsRemote-CPq1K5z8.js → SettingsRemote-CKWsHa5R.js} +1 -1
- package/dist/interface/assets/{SettingsSystem-C9qSkCyX.js → SettingsSystem-D9ZC11Mh.js} +2 -2
- package/dist/interface/assets/{SettingsUsers-BpwM-HIU.js → SettingsUsers-pcJeXZKp.js} +1 -1
- package/dist/interface/assets/{Terminal-BB9MM3FG.js → Terminal-Cu3uC8EZ.js} +1 -1
- package/dist/interface/assets/{VariableInput-YR8KBCWL.js → VariableInput-Bwu9vChK.js} +1 -1
- package/dist/interface/assets/{VariableSuggestions-BF2MDSW8.js → VariableSuggestions-l2MLjZre.js} +1 -1
- package/dist/interface/assets/{VersionsHandler-D2lhMYGG.js → VersionsHandler-BTxJHwdS.js} +1 -1
- package/dist/interface/assets/{Workers-Cn-kFp5K.js → Workers-BsEDKzmG.js} +1 -1
- package/dist/interface/assets/{api-BSSGwZUW.js → api-C3OjKngj.js} +1 -1
- package/dist/interface/assets/api-CFbWrm1k.js +2 -0
- package/dist/interface/assets/{auth-DuxuZzUv.js → auth-C5IUhkWS.js} +1 -1
- package/dist/interface/assets/{automations-BjPRCiP3.js → automations-DD8bCSnT.js} +1 -1
- package/dist/interface/assets/{automations-D4BRxXp2.js → automations-DusabINi.js} +1 -1
- package/dist/interface/assets/{backup-B01_Kkb-.js → backup-oG81xIPA.js} +1 -1
- package/dist/interface/assets/{cameras-B4_XIEvq.js → cameras-CmFEgULx.js} +1 -1
- package/dist/interface/assets/{cameras.schema-BJtNPf50.js → cameras.schema-iBaEWm0Y.js} +1 -1
- package/dist/interface/assets/cloudHandoff-BzwN18OZ.js +1 -0
- package/dist/interface/assets/{deviceId-Csqx7wv1.js → deviceId-opaLeE0R.js} +1 -1
- package/dist/interface/assets/{hasPermission-C2RWjXuT.js → hasPermission-ChIspc-L.js} +1 -1
- package/dist/interface/assets/{instances-Etl_yihq.js → instances-ykXfoA2u.js} +1 -1
- package/dist/interface/assets/{locale-BslWmL0t.js → locale-BliTvqss.js} +1 -1
- package/dist/interface/assets/main-DiUHKjqR.js +3 -0
- package/dist/interface/assets/{notifications-SVoDimTP.js → notifications-BQyPgfnE.js} +1 -1
- package/dist/interface/assets/{plugins-VMgnZZzG.js → plugins-BFzZB-8S.js} +1 -1
- package/dist/interface/assets/{round-restart-alt-D3SOXMVf.js → round-restart-alt-C4TFl1Rk.js} +1 -1
- package/dist/interface/assets/{router-BqJLYjxq.js → router-BzaHrqQ7.js} +2 -2
- package/dist/interface/assets/{routerState-g4QLPAT2.js → routerState-UESa7YsN.js} +1 -1
- package/dist/interface/assets/{server-8uNYIjzr.js → server-7nJbrqvt.js} +1 -1
- package/dist/interface/assets/{shares-DsjePQGf.js → shares-BokQ1O4D.js} +1 -1
- package/dist/interface/assets/{theme-DN8GOT_w.js → theme-iSxYm9Ao.js} +1 -1
- package/dist/interface/assets/{types-B39ppbYi.js → types-CVp3a4-E.js} +1 -1
- package/dist/interface/assets/{ui-CEzY0rCW.js → ui-CVUMjabl.js} +1 -1
- package/dist/interface/assets/{useBiometricCredentials-BWuRf0H0.js → useBiometricCredentials-BTSuyr45.js} +1 -1
- package/dist/interface/assets/{useCameraOptions-DLmEgMil.js → useCameraOptions-CuIFFMce.js} +1 -1
- package/dist/interface/assets/{useCuiCameraDrawer-PokkyjqH.js → useCuiCameraDrawer-DHbL2uYT.js} +1 -1
- package/dist/interface/assets/{useCuiDialog-BEerzsp3.js → useCuiDialog-DmnaFxId.js} +1 -1
- package/dist/interface/assets/{useFileDownload-BzheFQ77.js → useFileDownload-D7uRiFqP.js} +1 -1
- package/dist/interface/assets/{useLogsSocket-CeW070HG.js → useLogsSocket-CA2uq1kZ.js} +1 -1
- package/dist/interface/assets/{useSharedComposables-B5-Uy9Br.js → useSharedComposables-Dsn2uiyW.js} +1 -1
- package/dist/interface/assets/{useStreamStatus-BPixdI1Y.js → useStreamStatus-Dun63PvC.js} +1 -1
- package/dist/interface/assets/{users.schema-D443yTkQ.js → users.schema-COvF8XP0.js} +1 -1
- package/dist/interface/assets/vendor-cameraui-B1JU4jwS.js +1978 -0
- package/dist/interface/assets/{vendor-core-DSNqTlQH.js → vendor-core-BxjZmyZH.js} +1 -1
- package/dist/interface/assets/{vendor-primevue-CWF_imvX.js → vendor-primevue-gmNTz6qq.js} +1 -1
- package/dist/interface/assets/{view-grid-BE5Jf7kM.js → view-grid-C3peaC6C.js} +1 -1
- package/dist/interface/index.html +40 -40
- package/dist/interface/sw.js +1 -1
- package/dist/server/package.json +3 -3
- package/dist/server/requirements.txt +1 -1
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/channel.py +29 -23
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/chunking.py +52 -5
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/client.py +682 -231
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/codec.py +277 -18
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/handler.py +105 -17
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/service.py +21 -7
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/types.py +13 -0
- package/dist/server/src/_camera_ui_tools/camera_ui_rpc/utils.py +50 -15
- package/dist/server/src/api/plugins/natsSubFirewall.d.ts +1 -0
- package/dist/server/src/api/plugins/natsSubFirewall.js +9 -1
- package/dist/server/src/api/plugins/natsSubFirewall.js.map +1 -1
- package/dist/server/src/api/plugins/proxy.plugin.js +3 -0
- package/dist/server/src/api/plugins/proxy.plugin.js.map +1 -1
- package/dist/server/src/rpc/server.js +6 -3
- package/dist/server/src/rpc/server.js.map +1 -1
- package/package.json +3 -3
- package/dist/interface/assets/CuiBottomSheet-3-36plJ0.js +0 -1
- package/dist/interface/assets/CuiCameraCard-DYkArUIE.js +0 -1
- package/dist/interface/assets/CuiCameraRecordings-qS2sljLl.js +0 -1
- package/dist/interface/assets/Recordings-Bdii8y2w.js +0 -1
- package/dist/interface/assets/SettingsAccount-pc1G5uya.js +0 -1
- package/dist/interface/assets/SettingsAppearance-CDlFqm-u.js +0 -1
- package/dist/interface/assets/api-CPonLnGH.js +0 -2
- package/dist/interface/assets/cloudHandoff-DR4OiIia.js +0 -1
- package/dist/interface/assets/main-QYfiMUtP.js +0 -3
- package/dist/interface/assets/vendor-cameraui-D9I8uPRL.js +0 -1978
|
@@ -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
|
|
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.
|
|
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
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
# while we tear the transport down — the
|
|
276
|
-
# initiated
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
+
msg_task.add_done_callback(on_finish)
|
|
545
693
|
|
|
546
|
-
|
|
694
|
+
def _register_mux_entry(self) -> dict[str, Any]:
|
|
695
|
+
"""Register (idempotently) the muxed reply inbox subscription entry.
|
|
547
696
|
|
|
548
|
-
|
|
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
|
|
834
|
+
if handler_is_async:
|
|
552
835
|
await handler(data) # pyright: ignore[reportGeneralTypeIssues]
|
|
553
836
|
else:
|
|
554
|
-
|
|
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 =
|
|
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 =
|
|
880
|
+
error_data = decode_message(msg.data)
|
|
600
881
|
raise create_error(error_code, error_msg, error_data)
|
|
601
882
|
|
|
602
|
-
decoded =
|
|
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
|
-
|
|
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
|
-
#
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
#
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
#
|
|
666
|
-
|
|
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"].
|
|
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"].
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
897
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
1382
|
+
def on_no_responders(_err: Exception) -> None:
|
|
992
1383
|
nonlocal ended, error
|
|
993
1384
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
#
|
|
1068
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1491
|
+
if not isinstance(method, str):
|
|
1492
|
+
return
|
|
1493
|
+
fn = callbacks.get(method)
|
|
1080
1494
|
if fn is None:
|
|
1081
1495
|
return
|
|
1082
|
-
|
|
1496
|
+
cb_queue.put_nowait((fn, msg.get("args") or [], method, callback_is_async[method]))
|
|
1083
1497
|
|
|
1084
|
-
|
|
1498
|
+
cb_unsub = await self.subscribe(callback_subject, handle_cb_msg)
|
|
1085
1499
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
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=
|
|
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
|
|
1189
|
-
#
|
|
1190
|
-
#
|
|
1191
|
-
#
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
|
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,
|
|
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"]
|
|
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
|
-
|
|
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
|
|
1487
|
-
await
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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 =
|
|
1972
|
+
# Decode request (zero-copy binary views into msg.data)
|
|
1973
|
+
data = decode_message(msg.data)
|
|
1522
1974
|
|
|
1523
|
-
# Call handler with subject
|
|
1524
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
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:
|