pulse-framework 0.1.64__py3-none-any.whl → 0.1.66a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pulse/__init__.py CHANGED
@@ -1134,16 +1134,16 @@ from pulse.env import env as env
1134
1134
  from pulse.env import mode as mode
1135
1135
 
1136
1136
  # Forms
1137
- from pulse.form import (
1137
+ from pulse.forms import (
1138
1138
  Form as Form,
1139
1139
  )
1140
- from pulse.form import (
1140
+ from pulse.forms import (
1141
1141
  FormData as FormData,
1142
1142
  )
1143
- from pulse.form import (
1143
+ from pulse.forms import (
1144
1144
  FormValue as FormValue,
1145
1145
  )
1146
- from pulse.form import (
1146
+ from pulse.forms import (
1147
1147
  ManualForm as ManualForm,
1148
1148
  )
1149
1149
 
@@ -1151,12 +1151,6 @@ from pulse.form import (
1151
1151
  from pulse.helpers import (
1152
1152
  CSSProperties as CSSProperties,
1153
1153
  )
1154
- from pulse.helpers import (
1155
- later as later,
1156
- )
1157
- from pulse.helpers import (
1158
- repeat as repeat,
1159
- )
1160
1154
 
1161
1155
  # Hooks - Core
1162
1156
  from pulse.hooks.core import (
@@ -1414,6 +1408,18 @@ from pulse.requirements import require as require
1414
1408
  from pulse.routing import Layout as Layout
1415
1409
  from pulse.routing import Route as Route
1416
1410
  from pulse.routing import RouteInfo as RouteInfo
1411
+ from pulse.scheduling import (
1412
+ TaskRegistry as TaskRegistry,
1413
+ )
1414
+ from pulse.scheduling import (
1415
+ TimerRegistry as TimerRegistry,
1416
+ )
1417
+ from pulse.scheduling import (
1418
+ later as later,
1419
+ )
1420
+ from pulse.scheduling import (
1421
+ repeat as repeat,
1422
+ )
1417
1423
  from pulse.serializer import deserialize as deserialize
1418
1424
 
1419
1425
  # Serializer
pulse/app.py CHANGED
@@ -5,7 +5,6 @@ This module provides the main App class that users instantiate in their main.py
5
5
  to define routes and configure their Pulse application.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import logging
10
9
  import os
11
10
  from collections import defaultdict
@@ -40,11 +39,9 @@ from pulse.env import (
40
39
  )
41
40
  from pulse.env import env as envvars
42
41
  from pulse.helpers import (
43
- create_task,
44
42
  find_available_port,
45
43
  get_client_address,
46
44
  get_client_address_socketio,
47
- later,
48
45
  )
49
46
  from pulse.hooks.core import hooks
50
47
  from pulse.messages import (
@@ -74,6 +71,7 @@ from pulse.proxy import ReactProxy
74
71
  from pulse.render_session import RenderSession
75
72
  from pulse.request import PulseRequest
76
73
  from pulse.routing import Layout, Route, RouteTree, ensure_absolute_path
74
+ from pulse.scheduling import TaskRegistry, TimerHandleLike, TimerRegistry
77
75
  from pulse.serializer import Serialized, deserialize, serialize
78
76
  from pulse.user_session import (
79
77
  CookieSessionStore,
@@ -109,8 +107,8 @@ PulseMode = Literal["subdomains", "single-server"]
109
107
  """Deployment mode for the application.
110
108
 
111
109
  Values:
112
- "single-server": Python and React served from the same origin (default).
113
- "subdomains": Python API on a subdomain (e.g., api.example.com).
110
+ "single-server": Python and React served from the same origin (default).
111
+ "subdomains": Python API on a subdomain (e.g., api.example.com).
114
112
  """
115
113
 
116
114
 
@@ -120,12 +118,12 @@ class ConnectionStatusConfig:
120
118
  Configuration for connection status message delays.
121
119
 
122
120
  Attributes:
123
- initial_connecting_delay: Delay in seconds before showing "Connecting..." message
124
- on initial connection attempt. Default: 2.0
125
- initial_error_delay: Additional delay in seconds before showing error message
126
- on initial connection attempt (after connecting message). Default: 8.0
127
- reconnect_error_delay: Delay in seconds before showing error message when
128
- reconnecting after losing connection. Default: 8.0
121
+ initial_connecting_delay: Delay in seconds before showing "Connecting..." message
122
+ on initial connection attempt. Default: 2.0
123
+ initial_error_delay: Additional delay in seconds before showing error message
124
+ on initial connection attempt (after connecting message). Default: 8.0
125
+ reconnect_error_delay: Delay in seconds before showing error message when
126
+ reconnecting after losing connection. Default: 8.0
129
127
  """
130
128
 
131
129
  initial_connecting_delay: float = 2.0
@@ -173,15 +171,15 @@ class App:
173
171
  import pulse as ps
174
172
 
175
173
  app = ps.App(
176
- routes=[
177
- ps.Route("/", render=home),
178
- ps.Route("/users/:id", render=user_detail),
179
- ],
180
- session_timeout=120.0,
174
+ routes=[
175
+ ps.Route("/", render=home),
176
+ ps.Route("/users/:id", render=user_detail),
177
+ ],
178
+ session_timeout=120.0,
181
179
  )
182
180
 
183
181
  if __name__ == "__main__":
184
- app.run(port=8000)
182
+ app.run(port=8000)
185
183
  ```
186
184
  """
187
185
 
@@ -209,7 +207,10 @@ class App:
209
207
  _render_to_user: dict[str, str]
210
208
  _sessions_in_request: dict[str, int]
211
209
  _socket_to_render: dict[str, str]
212
- _render_cleanups: dict[str, asyncio.TimerHandle]
210
+ _render_cleanups: dict[str, TimerHandleLike]
211
+ _tasks: TaskRegistry
212
+ _timers: TimerRegistry
213
+ _proxy: ReactProxy | None
213
214
  session_timeout: float
214
215
  connection_status: ConnectionStatusConfig
215
216
  render_loop_limit: int
@@ -283,6 +284,9 @@ class App:
283
284
  self._socket_to_render = {}
284
285
  # Map render_id -> cleanup timer handle for timeout-based expiry
285
286
  self._render_cleanups = {}
287
+ self._tasks = TaskRegistry(name="app")
288
+ self._timers = TimerRegistry(tasks=self._tasks, name="app")
289
+ self._proxy = None
286
290
  self.session_timeout = session_timeout
287
291
  self.detach_queue_timeout = detach_queue_timeout
288
292
  self.disconnect_queue_timeout = disconnect_queue_timeout
@@ -540,10 +544,21 @@ class App:
540
544
  paths = [ensure_absolute_path(path) for path in paths]
541
545
  payload["paths"] = paths
542
546
  route_info = payload.get("routeInfo")
547
+ debug = os.environ.get("PULSE_DEBUG_RENDER")
548
+ if debug:
549
+ logger.info(
550
+ "[PulseDebug][prerender] session=%s header_render_id=%s payload_render_id=%s paths=%s route_info=%s",
551
+ session.sid,
552
+ request.headers.get("x-pulse-render-id"),
553
+ payload.get("renderId"),
554
+ paths,
555
+ route_info,
556
+ )
543
557
 
544
558
  client_addr: str | None = get_client_address(request)
545
559
  # Reuse render session from header (set by middleware) or create new one
546
560
  render = PulseContext.get().render
561
+ reused = render is not None
547
562
  if render is not None:
548
563
  render_id = render.id
549
564
  else:
@@ -552,9 +567,19 @@ class App:
552
567
  render = self.create_render(
553
568
  render_id, session, client_address=client_addr
554
569
  )
570
+ if debug:
571
+ logger.info(
572
+ "[PulseDebug][prerender] session=%s render=%s reused=%s connected=%s",
573
+ session.sid,
574
+ render_id,
575
+ reused,
576
+ render.connected,
577
+ )
578
+ print(f"Prerendering for RenderSession {render_id}")
555
579
 
556
580
  # Schedule cleanup timeout (will cancel/reschedule on activity)
557
- self._schedule_render_cleanup(render_id)
581
+ if not render.connected:
582
+ self._schedule_render_cleanup(render_id)
558
583
 
559
584
  def _normalize_prerender_result(
560
585
  captured: ServerInitMessage | ServerNavigateToMessage,
@@ -659,10 +684,11 @@ class App:
659
684
  + "Use 'pulse run' CLI command or set the environment variable."
660
685
  )
661
686
 
662
- proxy_handler = ReactProxy(
687
+ self._proxy = ReactProxy(
663
688
  react_server_address=react_server_address,
664
689
  server_address=server_address,
665
690
  )
691
+ proxy_handler = self._proxy
666
692
 
667
693
  # In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
668
694
  # Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
@@ -693,6 +719,13 @@ class App:
693
719
  if cookie is None:
694
720
  raise ConnectionRefusedError("Socket connect missing cookie")
695
721
  session = await self.get_or_create_session(cookie)
722
+ debug = os.environ.get("PULSE_DEBUG_RENDER")
723
+ if debug:
724
+ logger.info(
725
+ "[PulseDebug][connect] session=%s render_id=%s",
726
+ session.sid,
727
+ rid,
728
+ )
696
729
 
697
730
  if not rid:
698
731
  # Still refuse connections without a renderId
@@ -703,6 +736,13 @@ class App:
703
736
  # Allow reconnects where the provided renderId no longer exists by creating a new RenderSession
704
737
  render = self.render_sessions.get(rid)
705
738
  if render is None:
739
+ # The client will try to attach to a non-existing RouteMount, which will cause a reload down the line
740
+ if debug:
741
+ logger.info(
742
+ "[PulseDebug][connect] render_missing session=%s render_id=%s creating=true",
743
+ session.sid,
744
+ rid,
745
+ )
706
746
  render = self.create_render(
707
747
  rid, session, client_address=get_client_address_socketio(environ)
708
748
  )
@@ -713,12 +753,21 @@ class App:
713
753
  f"Socket connect session mismatch render={render.id} "
714
754
  + f"owner={owner} session={session.sid}"
715
755
  )
756
+ if debug:
757
+ logger.info(
758
+ "[PulseDebug][connect] render_found session=%s render_id=%s owner=%s connected=%s",
759
+ session.sid,
760
+ render.id,
761
+ owner,
762
+ render.connected,
763
+ )
764
+ print(f"Connected to RenderSession {render.id}")
716
765
 
717
766
  def on_message(message: ServerMessage):
718
767
  payload = serialize(message)
719
768
  # `serialize` returns a tuple, which socket.io will mistake for multiple arguments
720
769
  payload = list(payload)
721
- create_task(self.sio.emit("message", list(payload), to=sid))
770
+ self._tasks.create_task(self.sio.emit("message", list(payload), to=sid))
722
771
 
723
772
  render.connect(on_message)
724
773
  # Map socket sid to renderId for message routing
@@ -790,8 +839,10 @@ class App:
790
839
  def _cancel_render_cleanup(self, rid: str):
791
840
  """Cancel any pending cleanup task for a render session."""
792
841
  cleanup_handle = self._render_cleanups.pop(rid, None)
793
- if cleanup_handle and not cleanup_handle.cancelled():
794
- cleanup_handle.cancel()
842
+ if cleanup_handle:
843
+ if not cleanup_handle.cancelled():
844
+ cleanup_handle.cancel()
845
+ self._timers.discard(cleanup_handle)
795
846
 
796
847
  def _schedule_render_cleanup(self, rid: str):
797
848
  """Schedule cleanup of a RenderSession after the configured timeout."""
@@ -817,12 +868,26 @@ class App:
817
868
  )
818
869
  self.close_render(rid)
819
870
 
820
- handle = later(self.session_timeout, _cleanup)
871
+ handle = self._timers.later(self.session_timeout, _cleanup)
821
872
  self._render_cleanups[rid] = handle
822
873
 
823
874
  async def _handle_pulse_message(
824
875
  self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
825
876
  ) -> None:
877
+ if os.environ.get("PULSE_DEBUG_RENDER") and msg["type"] in (
878
+ "attach",
879
+ "update",
880
+ "detach",
881
+ ):
882
+ logger.info(
883
+ "[PulseDebug][client-message] session=%s render=%s type=%s path=%s route_info=%s",
884
+ session.sid,
885
+ render.id,
886
+ msg["type"],
887
+ msg.get("path"),
888
+ msg.get("routeInfo"),
889
+ )
890
+
826
891
  async def _next() -> Ok[None]:
827
892
  if msg["type"] == "attach":
828
893
  render.attach(msg["path"], msg["routeInfo"])
@@ -1023,7 +1088,7 @@ class App:
1023
1088
  self._user_to_render[session.sid].remove(rid)
1024
1089
 
1025
1090
  if len(self._user_to_render[session.sid]) == 0:
1026
- later(60, self.close_session_if_inactive, sid)
1091
+ self._timers.later(60, self.close_session_if_inactive, sid)
1027
1092
 
1028
1093
  def close_session(self, sid: str):
1029
1094
  session = self.user_sessions.pop(sid, None)
@@ -1053,6 +1118,15 @@ class App:
1053
1118
  for sid in list(self.user_sessions.keys()):
1054
1119
  self.close_session(sid)
1055
1120
 
1121
+ # Cancel any remaining app-level tasks/timers
1122
+ self._tasks.cancel_all()
1123
+ self._timers.cancel_all()
1124
+ if self._proxy is not None:
1125
+ try:
1126
+ await self._proxy.close()
1127
+ except Exception:
1128
+ logger.exception("Error during ReactProxy.close()")
1129
+
1056
1130
  # Update status
1057
1131
  self.status = AppStatus.stopped
1058
1132
  # Call plugin on_shutdown hooks before closing
@@ -1082,5 +1156,8 @@ class App:
1082
1156
  return # no active render for this user session
1083
1157
 
1084
1158
  # We don't want to wait for this to resolve
1085
- create_task(render.call_api(f"{self.api_prefix}/set-cookies", method="GET"))
1159
+ render.create_task(
1160
+ render.call_api(f"{self.api_prefix}/set-cookies", method="GET"),
1161
+ name="cookies.refresh",
1162
+ )
1086
1163
  sess.scheduled_cookie_refresh = True
pulse/channel.py CHANGED
@@ -7,7 +7,6 @@ from dataclasses import dataclass
7
7
  from typing import TYPE_CHECKING, Any, cast
8
8
 
9
9
  from pulse.context import PulseContext
10
- from pulse.helpers import create_future_on_loop
11
10
  from pulse.messages import (
12
11
  ClientChannelRequestMessage,
13
12
  ClientChannelResponseMessage,
@@ -15,6 +14,7 @@ from pulse.messages import (
15
14
  ServerChannelRequestMessage,
16
15
  ServerChannelResponseMessage,
17
16
  )
17
+ from pulse.scheduling import create_future
18
18
 
19
19
  if TYPE_CHECKING:
20
20
  from pulse.render_session import RenderSession
@@ -203,7 +203,7 @@ class ChannelsManager:
203
203
  msg=msg,
204
204
  )
205
205
 
206
- asyncio.create_task(_invoke())
206
+ render.create_task(_invoke(), name=f"channel:{channel_id}:{event}")
207
207
 
208
208
  # ------------------------------------------------------------------
209
209
  def register_pending(
@@ -494,7 +494,7 @@ class Channel:
494
494
 
495
495
  self._ensure_open()
496
496
  request_id = uuid.uuid4().hex
497
- fut = create_future_on_loop()
497
+ fut = create_future()
498
498
  self._manager.register_pending(request_id, fut, self.id)
499
499
  msg = ServerChannelRequestMessage(
500
500
  type="channel_message",
@@ -53,15 +53,11 @@ export async function loader(args: LoaderFunctionArgs) {
53
53
  return data(prerenderData, { headers });
54
54
  }
55
55
 
56
- // Client loader: re-prerender on navigation while reusing renderId
56
+ // Client loader: re-prerender on navigation while reusing directives
57
57
  export async function clientLoader(args: ClientLoaderFunctionArgs) {
58
58
  const url = new URL(args.request.url);
59
59
  const matches = matchRoutes(rrPulseRouteTree, url.pathname) ?? [];
60
60
  const paths = matches.map(m => m.route.uniquePath);
61
- const renderId =
62
- typeof window !== "undefined" && typeof sessionStorage !== "undefined"
63
- ? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
64
- : undefined;
65
61
  const directives =
66
62
  typeof window !== "undefined" && typeof sessionStorage !== "undefined"
67
63
  ? (JSON.parse(sessionStorage.getItem("__PULSE_DIRECTIVES") ?? "{}"))
@@ -76,7 +72,7 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
76
72
  method: "POST",
77
73
  headers,
78
74
  credentials: "include",
79
- body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args), renderId }),
75
+ body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
80
76
  });
81
77
  if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
82
78
  const body = await res.json();
@@ -92,7 +88,6 @@ export async function clientLoader(args: ClientLoaderFunctionArgs) {
92
88
  export default function PulseLayout() {
93
89
  const data = useLoaderData<typeof loader>();
94
90
  if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
95
- sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
96
91
  sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
97
92
  }
98
93
  return (
@@ -101,6 +96,6 @@ export default function PulseLayout() {
101
96
  </PulseProvider>
102
97
  );
103
98
  }
104
- // Persist renderId and directives in sessionStorage for reuse in clientLoader is handled within the component
99
+ // Persist directives in sessionStorage for reuse in clientLoader is handled within the component
105
100
  """
106
101
  )
@@ -244,7 +244,7 @@ def Form(
244
244
  key: str,
245
245
  onSubmit: EventHandler1[FormData] | None = None,
246
246
  **props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
247
- ) -> Node:
247
+ ):
248
248
  """Server-registered HTML form component.
249
249
 
250
250
  Automatically wires up form submission to a Python handler. Uses
@@ -435,7 +435,7 @@ class ManualForm(Disposable):
435
435
  *children: Node,
436
436
  key: str | None = None,
437
437
  **props: Unpack[PulseFormProps],
438
- ) -> Node:
438
+ ):
439
439
  """Render as a form element with children.
440
440
 
441
441
  Args:
pulse/helpers.py CHANGED
@@ -1,4 +1,3 @@
1
- import asyncio
2
1
  import inspect
3
2
  import linecache
4
3
  import os
@@ -17,7 +16,6 @@ from typing import (
17
16
  )
18
17
  from urllib.parse import urlsplit
19
18
 
20
- from anyio import from_thread
21
19
  from fastapi import Request
22
20
 
23
21
  from pulse.env import env
@@ -89,7 +87,15 @@ P = ParamSpec("P")
89
87
  CSSProperties = dict[str, Any]
90
88
 
91
89
 
92
- MISSING = object()
90
+ class Missing:
91
+ __slots__: tuple[str, ...] = ()
92
+
93
+ @override
94
+ def __repr__(self) -> str:
95
+ return "MISSING"
96
+
97
+
98
+ MISSING = Missing()
93
99
 
94
100
 
95
101
  class File(TypedDict):
@@ -130,7 +136,6 @@ def data(**attrs: Any):
130
136
  return {f"data-{k}": v for k, v in attrs.items()}
131
137
 
132
138
 
133
- # --- Async scheduling helpers (work from loop or sync threads) ---
134
139
  class Disposable(ABC):
135
140
  __disposed__: bool = False
136
141
 
@@ -158,214 +163,6 @@ class Disposable(ABC):
158
163
  cls.dispose = wrapped_dispose
159
164
 
160
165
 
161
- def is_pytest() -> bool:
162
- """Detect if running inside pytest using environment variables."""
163
- return bool(os.environ.get("PYTEST_CURRENT_TEST")) or (
164
- "PYTEST_XDIST_TESTRUNUID" in os.environ
165
- )
166
-
167
-
168
- def schedule_on_loop(callback: Callable[[], None]) -> None:
169
- """Schedule a callback to run ASAP on the main event loop from any thread."""
170
- try:
171
- loop = asyncio.get_running_loop()
172
- loop.call_soon_threadsafe(callback)
173
- except RuntimeError:
174
-
175
- async def _runner():
176
- loop = asyncio.get_running_loop()
177
- loop.call_soon(callback)
178
-
179
- try:
180
- from_thread.run(_runner)
181
- except RuntimeError:
182
- if not is_pytest():
183
- raise
184
-
185
-
186
- def create_task(
187
- coroutine: Awaitable[T],
188
- *,
189
- name: str | None = None,
190
- on_done: Callable[[asyncio.Task[T]], None] | None = None,
191
- ) -> asyncio.Task[T]:
192
- """Create and schedule a coroutine task on the main loop from any thread.
193
-
194
- - factory should create a fresh coroutine each call
195
- - optional on_done is attached on the created task within the loop
196
- """
197
-
198
- try:
199
- asyncio.get_running_loop()
200
- # ensure_future accepts Awaitable and returns a Task when given a coroutine
201
- task = asyncio.ensure_future(coroutine)
202
- if name is not None:
203
- task.set_name(name)
204
- if on_done:
205
- task.add_done_callback(on_done)
206
- return task
207
- except RuntimeError:
208
-
209
- async def _runner():
210
- asyncio.get_running_loop()
211
- # ensure_future accepts Awaitable and returns a Task when given a coroutine
212
- task = asyncio.ensure_future(coroutine)
213
- if name is not None:
214
- task.set_name(name)
215
- if on_done:
216
- task.add_done_callback(on_done)
217
- return task
218
-
219
- try:
220
- return from_thread.run(_runner)
221
- except RuntimeError:
222
- if is_pytest():
223
- return None # pyright: ignore[reportReturnType]
224
- raise
225
-
226
-
227
- def create_future_on_loop() -> asyncio.Future[Any]:
228
- """Create an asyncio Future on the main event loop from any thread."""
229
- try:
230
- return asyncio.get_running_loop().create_future()
231
- except RuntimeError:
232
- from anyio import from_thread
233
-
234
- async def _create():
235
- loop = asyncio.get_running_loop()
236
- return loop.create_future()
237
-
238
- return from_thread.run(_create)
239
-
240
-
241
- def later(
242
- delay: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs
243
- ) -> asyncio.TimerHandle:
244
- """
245
- Schedule `fn(*args, **kwargs)` to run after `delay` seconds.
246
- Works with sync or async functions. Returns a TimerHandle; call .cancel() to cancel.
247
-
248
- The callback runs with no reactive scope to avoid accidentally capturing
249
- reactive dependencies from the calling context. Other context vars (like
250
- PulseContext) are preserved normally.
251
- """
252
-
253
- from pulse.reactive import Untrack
254
-
255
- try:
256
- loop = asyncio.get_running_loop()
257
- except RuntimeError:
258
- try:
259
- loop = asyncio.get_event_loop()
260
- except RuntimeError as exc:
261
- raise RuntimeError("later() requires an event loop") from exc
262
-
263
- def _run():
264
- try:
265
- with Untrack():
266
- res = fn(*args, **kwargs)
267
- if asyncio.iscoroutine(res):
268
- task = loop.create_task(res)
269
-
270
- def _log_task_exception(t: asyncio.Task[Any]):
271
- try:
272
- t.result()
273
- except asyncio.CancelledError:
274
- # Normal cancellation path
275
- pass
276
- except Exception as exc:
277
- loop.call_exception_handler(
278
- {
279
- "message": "Unhandled exception in later() task",
280
- "exception": exc,
281
- "context": {"callback": fn},
282
- }
283
- )
284
-
285
- task.add_done_callback(_log_task_exception)
286
- except Exception as exc:
287
- # Surface exceptions via the loop's exception handler and continue
288
- loop.call_exception_handler(
289
- {
290
- "message": "Unhandled exception in later() callback",
291
- "exception": exc,
292
- "context": {"callback": fn},
293
- }
294
- )
295
-
296
- return loop.call_later(delay, _run)
297
-
298
-
299
- class RepeatHandle:
300
- task: asyncio.Task[None] | None
301
- cancelled: bool
302
-
303
- def __init__(self) -> None:
304
- self.task = None
305
- self.cancelled = False
306
-
307
- def cancel(self):
308
- if self.cancelled:
309
- return
310
- self.cancelled = True
311
- if self.task is not None and not self.task.done():
312
- self.task.cancel()
313
-
314
-
315
- def repeat(interval: float, fn: Callable[P, Any], *args: P.args, **kwargs: P.kwargs):
316
- """
317
- Repeatedly run `fn(*args, **kwargs)` every `interval` seconds.
318
- Works with sync or async functions.
319
- For async functions, waits for completion before starting the next delay.
320
- Returns a handle with .cancel() to stop future runs.
321
-
322
- The callback runs with no reactive scope to avoid accidentally capturing
323
- reactive dependencies from the calling context. Other context vars (like
324
- PulseContext) are preserved normally.
325
-
326
- Optional kwargs:
327
- - immediate: bool = False # run once immediately before the first interval
328
- """
329
-
330
- from pulse.reactive import Untrack
331
-
332
- loop = asyncio.get_running_loop()
333
- handle = RepeatHandle()
334
-
335
- async def _runner():
336
- nonlocal handle
337
- try:
338
- while not handle.cancelled:
339
- # Start counting the next interval AFTER the previous execution completes
340
- await asyncio.sleep(interval)
341
- if handle.cancelled:
342
- break
343
- try:
344
- with Untrack():
345
- result = fn(*args, **kwargs)
346
- if asyncio.iscoroutine(result):
347
- await result
348
- except asyncio.CancelledError:
349
- # Propagate to outer handler to finish cleanly
350
- raise
351
- except Exception as exc:
352
- # Surface exceptions via the loop's exception handler and continue
353
- loop.call_exception_handler(
354
- {
355
- "message": "Unhandled exception in repeat() callback",
356
- "exception": exc,
357
- "context": {"callback": fn},
358
- }
359
- )
360
- except asyncio.CancelledError:
361
- # Swallow task cancellation to avoid noisy "exception was never retrieved"
362
- pass
363
-
364
- handle.task = loop.create_task(_runner())
365
-
366
- return handle
367
-
368
-
369
166
  def get_client_address(request: Request) -> str | None:
370
167
  """Best-effort client origin/address from an HTTP request.
371
168
 
pulse/proxy.py CHANGED
@@ -9,7 +9,6 @@ from typing import cast
9
9
  import httpx
10
10
  import websockets
11
11
  from fastapi.responses import StreamingResponse
12
- from starlette.background import BackgroundTask
13
12
  from starlette.requests import Request
14
13
  from starlette.responses import PlainTextResponse, Response
15
14
  from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -223,9 +222,17 @@ class ReactProxy:
223
222
  v = self.rewrite_url(v)
224
223
  response_headers[k] = v
225
224
 
225
+ async def _iter():
226
+ try:
227
+ async for chunk in r.aiter_raw():
228
+ if await request.is_disconnected():
229
+ break
230
+ yield chunk
231
+ finally:
232
+ await r.aclose()
233
+
226
234
  return StreamingResponse(
227
- r.aiter_raw(),
228
- background=BackgroundTask(r.aclose),
235
+ _iter(),
229
236
  status_code=r.status_code,
230
237
  headers=response_headers,
231
238
  )