@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
@@ -30,9 +30,11 @@ class Channel:
30
30
  self._closed: bool = False
31
31
  self._initialized: bool = False
32
32
  self._unsubscribe: Callable[[], Coroutine[Any, Any, None]] | None = None
33
- self.__handlers: dict[str, set[Callable[[Any], None | Awaitable[None]]]] = {}
34
- self.__close_handlers: set[Callable[[], None | Awaitable[None]]] = set()
35
- self.__error_handlers: set[Callable[[Exception], None | Awaitable[None]]] = set()
33
+ # Handler registries map handler -> is_async, so the async-ness is
34
+ # determined once at registration instead of per emitted message.
35
+ self.__handlers: dict[str, dict[Callable[[Any], None | Awaitable[None]], bool]] = {}
36
+ self.__close_handlers: dict[Callable[[], None | Awaitable[None]], bool] = {}
37
+ self.__error_handlers: dict[Callable[[Exception], None | Awaitable[None]], bool] = {}
36
38
  self.__subscriptions: list[Callable[[], Coroutine[Any, Any, None]]] = []
37
39
  self.__isolated_client: RPCClient | None = None
38
40
 
@@ -96,14 +98,15 @@ class Channel:
96
98
  def on(self, event: Literal["error"], handler: Callable[[Exception], None | Awaitable[None]]) -> None: ...
97
99
  def on(self, event: str, handler: Callable[..., None | Awaitable[None]]) -> None:
98
100
  """Listen for events."""
101
+ handler_is_async = is_async_function(handler)
99
102
  if event == "close":
100
- self.__close_handlers.add(handler)
103
+ self.__close_handlers[handler] = handler_is_async
101
104
  elif event == "error":
102
- self.__error_handlers.add(handler)
105
+ self.__error_handlers[handler] = handler_is_async
103
106
  else:
104
107
  if event not in self.__handlers:
105
- self.__handlers[event] = set()
106
- self.__handlers[event].add(handler)
108
+ self.__handlers[event] = {}
109
+ self.__handlers[event][handler] = handler_is_async
107
110
 
108
111
  @overload
109
112
  def off(self, event: Literal["message"], handler: Callable[[Any], None | Awaitable[None]]) -> None: ...
@@ -116,12 +119,12 @@ class Channel:
116
119
  def off(self, event: str, handler: Callable[..., None | Awaitable[None]]) -> None:
117
120
  """Remove event listener."""
118
121
  if event == "close":
119
- self.__close_handlers.discard(handler)
122
+ self.__close_handlers.pop(handler, None)
120
123
  elif event == "error":
121
- self.__error_handlers.discard(handler)
124
+ self.__error_handlers.pop(handler, None)
122
125
  else:
123
126
  if event in self.__handlers:
124
- self.__handlers[event].discard(handler)
127
+ self.__handlers[event].pop(handler, None)
125
128
 
126
129
  async def close(self) -> None:
127
130
  """Close the channel gracefully."""
@@ -142,15 +145,20 @@ class Channel:
142
145
  await self._cleanup()
143
146
 
144
147
  async def _emit(self, event: str, data: Any = None) -> None:
145
- """Emit an event to handlers."""
148
+ """Emit an event to handlers.
149
+
150
+ Sync handlers run inline on the event loop — the thread-pool hop
151
+ costs far more than the short callbacks used here and would break
152
+ per-channel message ordering.
153
+ """
146
154
  if event in self.__handlers:
147
- for handler in self.__handlers[event]:
155
+ # Snapshot: an inline handler may add/remove listeners re-entrantly.
156
+ for handler, handler_is_async in list(self.__handlers[event].items()):
148
157
  try:
149
- if is_async_function(handler):
158
+ if handler_is_async:
150
159
  await handler(data) # type: ignore[misc]
151
160
  else:
152
- loop = asyncio.get_event_loop()
153
- await loop.run_in_executor(self._client.io_pool, handler, data)
161
+ handler(data)
154
162
  except Exception as e:
155
163
  print(f"Error in channel handler: {e}")
156
164
 
@@ -161,25 +169,23 @@ class Channel:
161
169
 
162
170
  self._closed = True
163
171
 
164
- for handler in self.__close_handlers:
172
+ for handler, handler_is_async in list(self.__close_handlers.items()):
165
173
  with contextlib.suppress(Exception):
166
- if is_async_function(handler):
174
+ if handler_is_async:
167
175
  await handler() # type: ignore[misc]
168
176
  else:
169
- loop = asyncio.get_event_loop()
170
- await loop.run_in_executor(self._client.io_pool, handler)
177
+ handler()
171
178
 
172
179
  asyncio.create_task(self._cleanup())
173
180
 
174
181
  async def _handle_error(self, error: Exception) -> None:
175
182
  """Handle channel error."""
176
- for handler in self.__error_handlers:
183
+ for handler, handler_is_async in list(self.__error_handlers.items()):
177
184
  with contextlib.suppress(Exception):
178
- if is_async_function(handler):
185
+ if handler_is_async:
179
186
  await handler(error) # type: ignore[misc]
180
187
  else:
181
- loop = asyncio.get_event_loop()
182
- await loop.run_in_executor(self._client.io_pool, handler, error)
188
+ handler(error)
183
189
 
184
190
  async def _cleanup(self) -> None:
185
191
  """Clean up resources."""
@@ -1,9 +1,10 @@
1
1
  """Optimized chunking system for large message handling."""
2
2
 
3
+ import time
3
4
  from collections.abc import Callable, Generator
4
5
  from typing import Any
5
6
 
6
- from .codec import decode
7
+ from .codec import decode_message
7
8
  from .types import ChunkData
8
9
 
9
10
 
@@ -152,10 +153,13 @@ class ChunkAssembler:
152
153
  raise RuntimeError("Cannot get data: Not all chunks have been received.")
153
154
 
154
155
  if self.buffer is not None:
155
- # Optimal path: The buffer is already fully assembled.
156
- # We create a memoryview to avoid copying the buffer before decoding.
156
+ # Optimal path: The buffer is already fully assembled. Chunked
157
+ # payloads are always full wire messages, so this goes through
158
+ # decode_message: a reassembled CUIB frame yields zero-copy
159
+ # memoryview slices into the assembly buffer (the views keep the
160
+ # bytearray alive after the assembler is pooled/reset).
157
161
  data_view = memoryview(self.buffer)
158
- return decode(data_view)
162
+ return decode_message(data_view)
159
163
  else:
160
164
  # Fallback path: Manually combine chunks from temporary storage.
161
165
  # This path involves a final copy operation.
@@ -167,7 +171,7 @@ class ChunkAssembler:
167
171
  chunk_len = len(chunk)
168
172
  combined[offset : offset + chunk_len] = chunk
169
173
  offset += chunk_len
170
- return decode(combined)
174
+ return decode_message(combined)
171
175
 
172
176
  def get_progress(self) -> dict[str, int | float]:
173
177
  """Get the current progress of the chunk transfer."""
@@ -180,11 +184,22 @@ class ChunkAssembler:
180
184
  class ChunkingManager:
181
185
  """Manages multiple concurrent chunk transfers."""
182
186
 
187
+ # Incomplete transfers hold their full pre-allocated buffer. If a single
188
+ # chunk is lost (sender reconnected mid-transfer), the assembler would
189
+ # otherwise sit in memory until the whole client disconnects — so stale
190
+ # transfers are swept lazily on every start_receiving/process_chunk call.
191
+ _STALE_TRANSFER_TTL: float = 30.0
192
+ # Sweeping on every chunk of a large transfer is pure overhead — one
193
+ # sweep per second is more than enough for a 30s TTL.
194
+ _SWEEP_INTERVAL: float = 1.0
195
+
183
196
  def __init__(self) -> None:
184
197
  self.assemblers: dict[str, ChunkAssembler] = {}
185
198
  self.completed_callbacks: dict[str, Callable[[Any], None]] = {}
186
199
  self.error_callbacks: dict[str, Callable[[Exception], None]] = {}
187
200
  self._assembler_pool: list[ChunkAssembler] = []
201
+ self._last_activity: dict[str, float] = {}
202
+ self._last_sweep: float = 0.0
188
203
 
189
204
  def _get_assembler(self, transfer_id: str) -> ChunkAssembler:
190
205
  """Get a reusable assembler from a pool or create a new one."""
@@ -228,14 +243,42 @@ class ChunkingManager:
228
243
  on_error: Callback to execute if an error occurs.
229
244
  total_size: The total size of the final data for pre-allocation.
230
245
  """
246
+ self._sweep_stale()
231
247
  assembler = self._get_assembler(transfer_id)
232
248
  assembler.set_expected_chunks(total_chunks, total_size, chunk_size)
233
249
  self.assemblers[transfer_id] = assembler
234
250
  self.completed_callbacks[transfer_id] = on_complete
235
251
  self.error_callbacks[transfer_id] = on_error
252
+ self._last_activity[transfer_id] = time.monotonic()
253
+
254
+ def _sweep_stale(self) -> None:
255
+ """Drop incomplete transfers with no activity for _STALE_TRANSFER_TTL.
256
+
257
+ Rate-limited to at most one sweep per _SWEEP_INTERVAL — this runs on
258
+ every start_receiving/process_chunk call.
259
+ """
260
+ if not self._last_activity:
261
+ return
262
+ now = time.monotonic()
263
+ if now - self._last_sweep < self._SWEEP_INTERVAL:
264
+ return
265
+ self._last_sweep = now
266
+ cutoff = now - self._STALE_TRANSFER_TTL
267
+ stale = [tid for tid, ts in self._last_activity.items() if ts < cutoff]
268
+ for transfer_id in stale:
269
+ error_callback = self.error_callbacks.pop(transfer_id, None)
270
+ assembler = self.assemblers.pop(transfer_id, None)
271
+ if assembler:
272
+ self._return_assembler(assembler)
273
+ self.completed_callbacks.pop(transfer_id, None)
274
+ self._last_activity.pop(transfer_id, None)
275
+ if error_callback:
276
+ error_callback(RuntimeError("Transfer timed out"))
236
277
 
237
278
  def process_chunk(self, chunk: dict[str, Any]) -> None:
238
279
  """Process an incoming chunk for a specific transfer."""
280
+ self._sweep_stale()
281
+
239
282
  transfer_id = chunk.get("id") or chunk.get("transferId")
240
283
  if not transfer_id:
241
284
  return
@@ -245,6 +288,8 @@ class ChunkingManager:
245
288
  # Received a chunk for an unknown or completed transfer.
246
289
  return
247
290
 
291
+ self._last_activity[transfer_id] = time.monotonic()
292
+
248
293
  try:
249
294
  # Normalize keys in-place to avoid allocating a new dict.
250
295
  # Handles minor key differences between JS/Go/Python senders.
@@ -261,6 +306,7 @@ class ChunkingManager:
261
306
  # Cleanup and return assembler to the pool
262
307
  del self.assemblers[transfer_id]
263
308
  self.error_callbacks.pop(transfer_id, None)
309
+ self._last_activity.pop(transfer_id, None)
264
310
  self._return_assembler(assembler)
265
311
 
266
312
  if callback:
@@ -276,6 +322,7 @@ class ChunkingManager:
276
322
  if assembler:
277
323
  self._return_assembler(assembler)
278
324
  self.completed_callbacks.pop(transfer_id, None)
325
+ self._last_activity.pop(transfer_id, None)
279
326
 
280
327
  if error_callback:
281
328
  error_callback(error)