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/render_session.py CHANGED
@@ -1,13 +1,13 @@
1
1
  import asyncio
2
2
  import logging
3
+ import os
3
4
  import traceback
4
5
  import uuid
5
6
  from asyncio import iscoroutine
6
- from collections.abc import Callable
7
+ from collections.abc import Awaitable, Callable
7
8
  from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8
9
 
9
10
  from pulse.context import PulseContext
10
- from pulse.helpers import create_future_on_loop, create_task, later
11
11
  from pulse.hooks.runtime import NotFoundInterrupt, RedirectInterrupt
12
12
  from pulse.messages import (
13
13
  ServerApiCallMessage,
@@ -29,13 +29,19 @@ from pulse.routing import (
29
29
  RouteTree,
30
30
  ensure_absolute_path,
31
31
  )
32
+ from pulse.scheduling import (
33
+ TaskRegistry,
34
+ TimerHandleLike,
35
+ TimerRegistry,
36
+ create_future,
37
+ )
32
38
  from pulse.state import State
33
39
  from pulse.transpiler.id import next_id
34
40
  from pulse.transpiler.nodes import Expr
35
41
 
36
42
  if TYPE_CHECKING:
37
43
  from pulse.channel import ChannelsManager
38
- from pulse.form import FormRegistry
44
+ from pulse.forms import FormRegistry
39
45
 
40
46
  logger = logging.getLogger(__file__)
41
47
 
@@ -93,7 +99,7 @@ class RouteMount:
93
99
  state: MountState
94
100
  pending_action: PendingAction | None
95
101
  queue: list[ServerMessage] | None
96
- queue_timeout: asyncio.TimerHandle | None
102
+ queue_timeout: TimerHandleLike | None
97
103
  render_batch_id: int
98
104
  render_batch_renders: int
99
105
 
@@ -124,6 +130,7 @@ class RouteMount:
124
130
  def _cancel_pending_timeout(self) -> None:
125
131
  if self.queue_timeout is not None:
126
132
  self.queue_timeout.cancel()
133
+ self.render.discard_timer(self.queue_timeout)
127
134
  self.queue_timeout = None
128
135
  self.pending_action = None
129
136
 
@@ -131,6 +138,14 @@ class RouteMount:
131
138
  if self.state != "pending":
132
139
  return
133
140
  action = self.pending_action
141
+ if os.environ.get("PULSE_DEBUG_RENDER"):
142
+ logger.info(
143
+ "[PulseDebug][mount-timeout] render=%s path=%s action=%s state=%s",
144
+ self.render.id,
145
+ self.path,
146
+ action,
147
+ self.state,
148
+ )
134
149
  self.pending_action = None
135
150
  if action == "dispose":
136
151
  self.render.dispose_mount(self.path, self)
@@ -138,6 +153,15 @@ class RouteMount:
138
153
  self.to_idle()
139
154
 
140
155
  def start_pending(self, timeout: float, *, action: PendingAction = "idle") -> None:
156
+ if os.environ.get("PULSE_DEBUG_RENDER"):
157
+ logger.info(
158
+ "[PulseDebug][mount-pending] render=%s path=%s state=%s action=%s timeout=%s",
159
+ self.render.id,
160
+ self.path,
161
+ self.state,
162
+ action,
163
+ timeout,
164
+ )
141
165
  if self.state == "pending":
142
166
  prev_action = self.pending_action
143
167
  next_action: PendingAction = (
@@ -145,7 +169,9 @@ class RouteMount:
145
169
  )
146
170
  self._cancel_pending_timeout()
147
171
  self.pending_action = next_action
148
- self.queue_timeout = later(timeout, self._on_pending_timeout)
172
+ self.queue_timeout = self.render.schedule_later(
173
+ timeout, self._on_pending_timeout
174
+ )
149
175
  return
150
176
  self._cancel_pending_timeout()
151
177
  if self.state == "idle" and self.effect:
@@ -153,9 +179,19 @@ class RouteMount:
153
179
  self.state = "pending"
154
180
  self.queue = []
155
181
  self.pending_action = action
156
- self.queue_timeout = later(timeout, self._on_pending_timeout)
182
+ self.queue_timeout = self.render.schedule_later(
183
+ timeout, self._on_pending_timeout
184
+ )
157
185
 
158
186
  def activate(self, send_message: Callable[[ServerMessage], Any]) -> None:
187
+ if os.environ.get("PULSE_DEBUG_RENDER"):
188
+ logger.info(
189
+ "[PulseDebug][mount-activate] render=%s path=%s state=%s queued=%s",
190
+ self.render.id,
191
+ self.path,
192
+ self.state,
193
+ 0 if not self.queue else len(self.queue),
194
+ )
159
195
  if self.state != "pending":
160
196
  return
161
197
  self._cancel_pending_timeout()
@@ -182,6 +218,12 @@ class RouteMount:
182
218
  def to_idle(self) -> None:
183
219
  if self.state != "pending":
184
220
  return
221
+ if os.environ.get("PULSE_DEBUG_RENDER"):
222
+ logger.info(
223
+ "[PulseDebug][mount-idle] render=%s path=%s",
224
+ self.render.id,
225
+ self.path,
226
+ )
185
227
  self.state = "idle"
186
228
  self.queue = None
187
229
  self._cancel_pending_timeout()
@@ -248,6 +290,8 @@ class RenderSession:
248
290
  _pending_api: dict[str, asyncio.Future[dict[str, Any]]]
249
291
  _pending_js_results: dict[str, asyncio.Future[Any]]
250
292
  _global_states: dict[str, State]
293
+ _tasks: TaskRegistry
294
+ _timers: TimerRegistry
251
295
 
252
296
  def __init__(
253
297
  self,
@@ -262,7 +306,7 @@ class RenderSession:
262
306
  render_loop_limit: int = 50,
263
307
  ) -> None:
264
308
  from pulse.channel import ChannelsManager
265
- from pulse.form import FormRegistry
309
+ from pulse.forms import FormRegistry
266
310
 
267
311
  self.id = id
268
312
  self.routes = routes
@@ -277,6 +321,8 @@ class RenderSession:
277
321
  self.forms = FormRegistry(self)
278
322
  self._pending_api = {}
279
323
  self._pending_js_results = {}
324
+ self._tasks = TaskRegistry(name=f"render:{id}")
325
+ self._timers = TimerRegistry(tasks=self._tasks, name=f"render:{id}")
280
326
  self.prerender_queue_timeout = prerender_queue_timeout
281
327
  self.detach_queue_timeout = detach_queue_timeout
282
328
  self.disconnect_queue_timeout = disconnect_queue_timeout
@@ -303,11 +349,15 @@ class RenderSession:
303
349
 
304
350
  def connect(self, send_message: Callable[[ServerMessage], Any]):
305
351
  """WebSocket connected. Set sender, don't auto-flush (attach does that)."""
352
+ if os.environ.get("PULSE_DEBUG_RENDER"):
353
+ logger.info("[PulseDebug][render-connect] render=%s", self.id)
306
354
  self._send_message = send_message
307
355
  self.connected = True
308
356
 
309
357
  def disconnect(self):
310
358
  """WebSocket disconnected. Start queuing briefly before pausing."""
359
+ if os.environ.get("PULSE_DEBUG_RENDER"):
360
+ logger.info("[PulseDebug][render-disconnect] render=%s", self.id)
311
361
  self._send_message = None
312
362
  self.connected = False
313
363
 
@@ -381,6 +431,13 @@ class RenderSession:
381
431
  - Creates mounts in PENDING state and starts queue
382
432
  """
383
433
  normalized = [ensure_absolute_path(path) for path in paths]
434
+ if os.environ.get("PULSE_DEBUG_RENDER"):
435
+ logger.info(
436
+ "[PulseDebug][prerender] render=%s paths=%s route_info=%s",
437
+ self.id,
438
+ normalized,
439
+ route_info,
440
+ )
384
441
 
385
442
  results: dict[str, ServerInitMessage | ServerNavigateToMessage] = {}
386
443
 
@@ -388,6 +445,15 @@ class RenderSession:
388
445
  route = self.routes.find(path)
389
446
  info = route_info or route.default_route_info()
390
447
  mount = self.route_mounts.get(path)
448
+ if os.environ.get("PULSE_DEBUG_RENDER"):
449
+ route_label = repr(route)
450
+ logger.info(
451
+ "[PulseDebug][prerender] render=%s path=%s mount_state=%s route=%s",
452
+ self.id,
453
+ path,
454
+ mount.state if mount else None,
455
+ route_label,
456
+ )
391
457
 
392
458
  if mount is None:
393
459
  mount = RouteMount(self, path, route, info)
@@ -426,13 +492,29 @@ class RenderSession:
426
492
  mount = self.route_mounts.get(path)
427
493
 
428
494
  if mount is None or mount.state == "idle":
495
+ if os.environ.get("PULSE_DEBUG_RENDER"):
496
+ logger.info(
497
+ "[PulseDebug][attach] render=%s path=%s mount_state=%s mounts=%s route_info=%s",
498
+ self.id,
499
+ path,
500
+ mount.state if mount else None,
501
+ {key: value.state for key, value in self.route_mounts.items()},
502
+ route_info,
503
+ )
429
504
  # Initial render must come from prerender
505
+ print(f"[DEBUG] Missing or idle route '{path}', reloading")
430
506
  self.send({"type": "reload"})
431
507
  return
432
508
 
433
509
  # Update route info for active and pending mounts
434
510
  mount.update_route(route_info)
435
511
  if mount.state == "pending" and self._send_message:
512
+ if os.environ.get("PULSE_DEBUG_RENDER"):
513
+ logger.info(
514
+ "[PulseDebug][attach] render=%s path=%s activating=true",
515
+ self.id,
516
+ path,
517
+ )
436
518
  mount.activate(self._send_message)
437
519
 
438
520
  def update_route(self, path: str, route_info: RouteInfo):
@@ -448,6 +530,13 @@ class RenderSession:
448
530
  current = self.route_mounts.get(path)
449
531
  if current is not mount:
450
532
  return
533
+ if os.environ.get("PULSE_DEBUG_RENDER"):
534
+ logger.info(
535
+ "[PulseDebug][mount-dispose] render=%s path=%s state=%s",
536
+ self.id,
537
+ path,
538
+ mount.state,
539
+ )
451
540
  try:
452
541
  self.route_mounts.pop(path, None)
453
542
  mount.dispose()
@@ -546,9 +635,12 @@ class RenderSession:
546
635
 
547
636
  def close(self):
548
637
  self.forms.dispose()
638
+ self._tasks.cancel_all()
639
+ self._timers.cancel_all()
549
640
  for path in list(self.route_mounts.keys()):
550
641
  self.detach(path, timeout=0)
551
642
  self.route_mounts.clear()
643
+ self.query_store.dispose_all()
552
644
  for value in self._global_states.values():
553
645
  value.dispose()
554
646
  self._global_states.clear()
@@ -587,6 +679,28 @@ class RenderSession:
587
679
  with PulseContext.update(render=self):
588
680
  flush_effects()
589
681
 
682
+ def create_task(
683
+ self,
684
+ coroutine: Callable[[], Any] | Awaitable[Any],
685
+ *,
686
+ name: str | None = None,
687
+ on_done: Callable[[asyncio.Task[Any]], None] | None = None,
688
+ ) -> asyncio.Task[Any]:
689
+ """Create a tracked task tied to this render session."""
690
+ if callable(coroutine):
691
+ return self._tasks.create_task(coroutine(), name=name, on_done=on_done)
692
+ return self._tasks.create_task(coroutine, name=name, on_done=on_done)
693
+
694
+ def schedule_later(
695
+ self, delay: float, fn: Callable[..., Any], *args: Any, **kwargs: Any
696
+ ) -> TimerHandleLike:
697
+ """Schedule a tracked timer tied to this render session."""
698
+ return self._timers.later(delay, fn, *args, **kwargs)
699
+
700
+ def discard_timer(self, handle: TimerHandleLike | None) -> None:
701
+ """Remove a timer handle from the session registry."""
702
+ self._timers.discard(handle)
703
+
590
704
  def execute_callback(self, path: str, key: str, args: list[Any] | tuple[Any, ...]):
591
705
  mount = self.route_mounts[path]
592
706
  cb = mount.tree.callbacks[key]
@@ -598,9 +712,18 @@ class RenderSession:
598
712
  with PulseContext.update(render=self, route=mount.route):
599
713
  res = cb.fn(*args[: cb.n_args])
600
714
  if iscoroutine(res):
601
- create_task(
602
- res, on_done=lambda t: (e := t.exception()) and report(e, True)
603
- )
715
+
716
+ def _on_done(t: asyncio.Task[Any]) -> None:
717
+ if t.cancelled():
718
+ return
719
+ try:
720
+ exc = t.exception()
721
+ except asyncio.CancelledError:
722
+ return
723
+ if exc:
724
+ report(exc, True)
725
+
726
+ self.create_task(res, name=f"callback:{key}", on_done=_on_done)
604
727
  except Exception as e:
605
728
  report(e)
606
729
 
@@ -628,7 +751,7 @@ class RenderSession:
628
751
  api_path = url_or_path if url_or_path.startswith("/") else "/" + url_or_path
629
752
  url = f"{base}{api_path}"
630
753
  corr_id = uuid.uuid4().hex
631
- fut = create_future_on_loop()
754
+ fut = create_future()
632
755
  self._pending_api[corr_id] = fut
633
756
  headers = headers or {}
634
757
  headers["x-pulse-render-id"] = self.id
@@ -746,7 +869,7 @@ class RenderSession:
746
869
  if not future.done():
747
870
  future.set_exception(asyncio.TimeoutError())
748
871
 
749
- loop.call_later(timeout, _on_timeout)
872
+ self._timers.later(timeout, _on_timeout)
750
873
 
751
874
  return future
752
875