austin-python 2.2.0__py3-none-any.whl → 2.4.0__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.
austin/events.py CHANGED
@@ -43,6 +43,63 @@ class AustinMetadata(AustinEvent):
43
43
  value: str
44
44
 
45
45
 
46
+ @dataclass(frozen=True)
47
+ class AustinTask:
48
+ """A node in an asyncio task tree (3.14+ only).
49
+
50
+ ``frames`` is this task's last-known position: its coroutine chain if it
51
+ was suspended, or the frames it was running if it was running.
52
+ ``awaiting`` are the tasks this task is itself awaiting, i.e. the tasks
53
+ for which this task is the direct waiter.
54
+
55
+ This is the structure of the tree only. The time tasks spend at their
56
+ positions is carried by AustinTaskReport (see AustinSample.task_reports)
57
+ and, while they are running, by the samples they are running at (see
58
+ AustinSample.running_task).
59
+ """
60
+
61
+ task_id: int
62
+ name: t.Optional[str]
63
+ frames: t.Tuple[AustinFrame, ...] = ()
64
+ awaiting: t.Tuple["AustinTask", ...] = ()
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AustinTaskReport:
69
+ """A report of a task's position (3.14+ only).
70
+
71
+ Austin only reports a task's position when it has changed. ``frames`` is
72
+ the task's new position, and ``elapsed`` is how long the task was
73
+ *suspended* at its ``previous`` one, which this report replaces -- the
74
+ earliest point at which that duration could be known at all. Austin
75
+ doesn't report it: readers work it out as the time of the task's thread's
76
+ samples while the task held that position without running. It is await
77
+ time only: the time a task spends running is the time of the samples it
78
+ is running at (see AustinSample.running_task). Always 0 in CPU mode, where
79
+ a suspended task uses no CPU.
80
+
81
+ ``previous`` is None on a task's first report, which has no previous
82
+ position (and so ``elapsed`` is 0). A report with no ``frames`` closes the
83
+ task: it is gone, and ``elapsed`` is the time at its last position. At the
84
+ end of a stream, each task still alive gets a report with ``frames`` equal
85
+ to ``previous``, carrying the time it has spent at its position since.
86
+
87
+ Each report is attached to exactly one sample, so summing ``elapsed``
88
+ over every sample's reports counts each duration once.
89
+ """
90
+
91
+ task_id: int
92
+ name: t.Optional[str]
93
+ frames: t.Tuple[AustinFrame, ...]
94
+ previous: t.Optional[t.Tuple[AustinFrame, ...]]
95
+ elapsed: MicroSeconds
96
+
97
+ @property
98
+ def closing(self) -> bool:
99
+ """Whether this report closes the task."""
100
+ return not self.frames
101
+
102
+
46
103
  @dataclass(frozen=True)
47
104
  class AustinSample(AustinEvent):
48
105
  """Austin sample."""
@@ -63,6 +120,13 @@ class AustinSample(AustinEvent):
63
120
  frames: t.Optional[t.Tuple[AustinFrame, ...]] = None
64
121
  gc: t.Optional[bool] = None
65
122
  idle: t.Optional[bool] = None
123
+ # The live tasks owned by this sample's thread, as of this sample.
124
+ tasks: t.Tuple[AustinTask, ...] = ()
125
+ # The task reports that came with this sample, each one exactly once.
126
+ task_reports: t.Tuple[AustinTaskReport, ...] = ()
127
+ # The task this sample's thread was running, if any. The sample's metrics
128
+ # then belong to that task too, at its latest position (see AustinTask).
129
+ running_task: t.Optional[int] = None
66
130
 
67
131
  def key(
68
132
  self,
austin/format/mojo.py CHANGED
@@ -12,10 +12,17 @@ from austin.events import AustinFrame
12
12
  from austin.events import AustinMetadata
13
13
  from austin.events import AustinMetrics
14
14
  from austin.events import AustinSample
15
+ from austin.events import AustinTask
16
+ from austin.events import AustinTaskReport
15
17
  from austin.events import InterpreterId
16
18
  from austin.events import ProcessId
17
19
  from austin.events import ThreadName
18
20
 
21
+ # Guards against a corrupted/cyclic waiter graph in the source stream -- a
22
+ # real await chain never comes anywhere close to this. Mirrors austin's own
23
+ # WHERE_MAX_TREE_DEPTH (src/events.c).
24
+ MAX_TASK_TREE_DEPTH = 64
25
+
19
26
 
20
27
  def to_varint(n: int) -> bytes:
21
28
  """Convert an integer to a variable-length integer."""
@@ -67,6 +74,9 @@ class MojoEvents:
67
74
  STRING = 11
68
75
  STRING_REF = 12
69
76
  STACK_REPEAT = 13
77
+ TASK_STACK = 14
78
+ TASK_WAITER = 15
79
+ TASK_RUNNING = 16
70
80
 
71
81
 
72
82
  class MojoEventHandler:
@@ -197,6 +207,52 @@ class MojoStackRepeat(MojoEvent):
197
207
  EVENT_ID = MojoEvents.STACK_REPEAT
198
208
 
199
209
 
210
+ @dataclass(frozen=True, eq=True)
211
+ class MojoTaskWaiter(MojoEvent):
212
+ """MOJO task waiter event.
213
+
214
+ One edge of a task's waiter DAG: ``task_id`` is awaited by
215
+ ``waiter_id``.
216
+ """
217
+
218
+ EVENT_ID = MojoEvents.TASK_WAITER
219
+
220
+ task_id: int
221
+ waiter_id: int
222
+
223
+
224
+ @dataclass(frozen=True, eq=True)
225
+ class MojoTaskRunning(MojoEvent):
226
+ """MOJO task running event.
227
+
228
+ The task the enclosing sample's thread was running at that sample. The
229
+ sample's metrics then belong to that task too, at its latest position.
230
+ """
231
+
232
+ EVENT_ID = MojoEvents.TASK_RUNNING
233
+
234
+ task_id: int
235
+
236
+
237
+ @dataclass(frozen=True, eq=True)
238
+ class MojoTaskStack(MojoEvent):
239
+ """MOJO task stack event.
240
+
241
+ Introduces a task's new position, keyed by an opaque task id and its
242
+ (possibly cached) name key (0 if unresolved, and on a closing flush). The
243
+ next ``frame_count`` frame references are the task's frames; none means
244
+ the task is gone. The report carries no time: the time a task spends at a
245
+ position is the time of its thread's samples while it holds it -- see
246
+ AustinTaskReport.
247
+ """
248
+
249
+ EVENT_ID = MojoEvents.TASK_STACK
250
+
251
+ task_id: int
252
+ name_key: int
253
+ frame_count: int
254
+
255
+
200
256
  @dataclass(frozen=True, eq=True)
201
257
  class MojoFrame(MojoEvent):
202
258
  """MOJO frame."""
@@ -264,6 +320,33 @@ class _RunningSample:
264
320
  metrics: t.Dict[MojoMetricType, MojoMetric] = field(default_factory=dict)
265
321
  gc: t.Optional[bool] = None
266
322
  idle: t.Optional[bool] = None
323
+ running_task: t.Optional[int] = None
324
+ task_reports: t.List[AustinTaskReport] = field(default_factory=list)
325
+
326
+
327
+ @dataclass
328
+ class _RunningTaskStack:
329
+ task_id: int
330
+ name_key: int
331
+ frame_count: int
332
+ frames: t.List[MojoFrame] = field(default_factory=list)
333
+
334
+
335
+ @dataclass
336
+ class _TaskInfo:
337
+ """Best-known state for one task_id.
338
+
339
+ Built up from MOJO_TASK_STACK events as they arrive. ``owner`` is the
340
+ (pid, thread) of whichever sample's wire bracket this task's most recent
341
+ MOJO_TASK_STACK fell within -- see BaseMojoStreamReader's task-stack
342
+ handling for why that bracket is the only ownership signal that exists.
343
+ """
344
+
345
+ name: t.Optional[str]
346
+ owner: t.Tuple[ProcessId, ThreadName]
347
+ frames: t.Tuple[AustinFrame, ...] = ()
348
+ # Time spent at ``frames`` so far, i.e. since the task's last report.
349
+ pending: int = 0
267
350
 
268
351
 
269
352
  def int_reader() -> t.Generator[t.Optional[int], bytes, int]:
@@ -340,6 +423,26 @@ class BaseMojoStreamReader(AustinEventIterator):
340
423
  # last fully-finalised sample for that thread.
341
424
  self._prev_frames: t.Dict[t.Tuple[int, str], t.List[MojoFrame]] = {}
342
425
 
426
+ # Suspended-task coroutine-chain state (3.14+ asyncio). A task stack's
427
+ # frames/terminating time metric are ordinary MOJO_FRAME/MOJO_FRAME_REF/
428
+ # MOJO_METRIC_TIME events on the wire, indistinguishable by event ID
429
+ # alone from those of the enclosing regular stack sample -- so while
430
+ # this is not None, get_frame_ref/get_time_metric route to it instead
431
+ # of the running sample.
432
+ self._running_task_stack: t.Optional[_RunningTaskStack] = None
433
+
434
+ # Best-known state per task_id, keyed by remote TaskObj address.
435
+ self._task_info: t.Dict[int, _TaskInfo] = {}
436
+
437
+ # Best-known waiter DAG edges: (task_id, waiter_id) pairs, meaning
438
+ # task_id is awaited by waiter_id. The wire re-emits a task's
439
+ # *complete* current waiter set whenever it changes (never a diff --
440
+ # see py_asyncio.c's fingerprint comment), so a fresh batch for a
441
+ # task_id replaces its previous edges; batch boundaries are inferred
442
+ # from consecutive same-task_id events (see get_task_waiter).
443
+ self._task_edges: t.Set[t.Tuple[int, int]] = set()
444
+ self._last_waiter_task_id: t.Optional[int] = None
445
+
343
446
  # Austin events
344
447
  self.metadata: t.Dict[str, str] = {}
345
448
  self.samples: t.List[AustinSample] = []
@@ -384,10 +487,61 @@ class BaseMojoStreamReader(AustinEventIterator):
384
487
  def gc(self) -> t.Optional[str]:
385
488
  return self.metadata.get("gc")
386
489
 
387
- def _finalize_sample(self) -> AustinSample:
388
- """Finalize the current sample."""
490
+ def _accrue_task_time(self, sample: _RunningSample) -> None:
491
+ """Add the time of a sample to the tasks its thread holds.
492
+
493
+ A task's time at a position is the time of its thread's samples while
494
+ it holds it: the running task's go to it as running (see
495
+ AustinSample.running_task), every other task's as time at its current
496
+ position, which its next report carries. In CPU mode a task that isn't
497
+ running isn't using the CPU, so it gets nothing.
498
+ """
499
+ if self.mode == "cpu":
500
+ return
501
+
502
+ time_metric = sample.metrics.get(MojoMetricType.TIME)
503
+ if time_metric is None or not time_metric.value:
504
+ return
505
+
506
+ owner = (sample.pid, sample.thread)
507
+ for task_id, info in self._task_info.items():
508
+ if info.owner == owner and task_id != sample.running_task:
509
+ info.pending += time_metric.value
510
+
511
+ def _end_task_reports(self) -> t.List[AustinTaskReport]:
512
+ """Report the time the tasks still alive have spent at their position.
513
+
514
+ Nothing else would: a task's time at a position is only reported when
515
+ the task moves on, or goes. These reports leave the position unchanged
516
+ (``frames`` and ``previous`` are the same).
517
+ """
518
+ reports = [
519
+ AustinTaskReport(
520
+ task_id=task_id,
521
+ name=info.name,
522
+ frames=info.frames,
523
+ previous=info.frames,
524
+ elapsed=info.pending,
525
+ )
526
+ for task_id, info in self._task_info.items()
527
+ if info.pending
528
+ ]
529
+ for info in self._task_info.values():
530
+ info.pending = 0
531
+ return reports
532
+
533
+ def _finalize_sample(self, last: bool = False) -> AustinSample:
534
+ """Finalize the current sample.
535
+
536
+ The last sample of the stream also carries the end-of-stream task
537
+ reports (see _end_task_reports).
538
+ """
389
539
  assert self._running_sample is not None, self._running_sample
390
540
 
541
+ self._accrue_task_time(self._running_sample)
542
+ if last:
543
+ self._running_sample.task_reports.extend(self._end_task_reports())
544
+
391
545
  self.samples.append(
392
546
  sample := AustinSample(
393
547
  pid=self._running_sample.pid,
@@ -416,6 +570,11 @@ class BaseMojoStreamReader(AustinEventIterator):
416
570
  ),
417
571
  gc=self._running_sample.gc,
418
572
  idle=self._running_sample.idle,
573
+ running_task=self._running_sample.running_task,
574
+ task_reports=tuple(self._running_sample.task_reports),
575
+ tasks=self.get_tasks(
576
+ self._running_sample.pid, self._running_sample.thread
577
+ ),
419
578
  )
420
579
  )
421
580
 
@@ -459,6 +618,10 @@ class BaseMojoStreamReader(AustinEventIterator):
459
618
  if self._running_sample is not None:
460
619
  self._finalize_sample()
461
620
 
621
+ # A task's waiter set is a run of consecutive MOJO_TASK_WAITER events,
622
+ # which never spans a sample boundary.
623
+ self._last_waiter_task_id = None
624
+
462
625
  self._running_sample = _RunningSample(
463
626
  pid=pid,
464
627
  iid=iid,
@@ -499,11 +662,184 @@ class BaseMojoStreamReader(AustinEventIterator):
499
662
  """Parse a frame reference."""
500
663
  frame = self._frame_map[self.ref(ref)]
501
664
 
502
- assert self._running_sample is not None, self._running_sample
503
- self._running_sample.frames.append(frame)
665
+ if self._running_task_stack is not None:
666
+ self._running_task_stack.frames.append(frame)
667
+ if (
668
+ len(self._running_task_stack.frames)
669
+ >= self._running_task_stack.frame_count
670
+ ):
671
+ self._finalize_task_stack()
672
+ else:
673
+ assert self._running_sample is not None, self._running_sample
674
+ self._running_sample.frames.append(frame)
504
675
 
505
676
  return MojoFrameReference(frame)
506
677
 
678
+ def get_task_waiter(self, task_id: int, waiter_id: int) -> MojoTaskWaiter:
679
+ """Parse a task waiter edge."""
680
+ if self._last_waiter_task_id != task_id:
681
+ # Starting a fresh batch for this task_id: the wire re-emits the
682
+ # complete current waiter set whenever it changes, so drop
683
+ # whatever we knew about this task_id's waiters before.
684
+ self._task_edges = {e for e in self._task_edges if e[0] != task_id}
685
+ self._last_waiter_task_id = task_id
686
+
687
+ # A waiter of 0 is no task: the task's waiter set is now empty.
688
+ if waiter_id:
689
+ self._task_edges.add((task_id, waiter_id))
690
+
691
+ return MojoTaskWaiter(task_id, waiter_id)
692
+
693
+ def get_task_running(self, task_id: int) -> MojoTaskRunning:
694
+ """Parse the task the current sample's thread was running."""
695
+ assert self._running_sample is not None, self._running_sample
696
+ self._running_sample.running_task = task_id
697
+
698
+ return MojoTaskRunning(task_id)
699
+
700
+ def get_task_stack(
701
+ self, task_id: int, name_key: int, frame_count: int
702
+ ) -> MojoTaskStack:
703
+ """Parse the header of a task report.
704
+
705
+ Its frames arrive as the next ``frame_count`` frame references -- see
706
+ get_frame_ref. A report with none closes the task right away.
707
+ """
708
+ self._running_task_stack = _RunningTaskStack(task_id, name_key, frame_count)
709
+ self._last_waiter_task_id = None
710
+
711
+ if not frame_count:
712
+ self._finalize_task_stack()
713
+
714
+ return MojoTaskStack(task_id, name_key, frame_count)
715
+
716
+ def _finalize_task_stack(self) -> None:
717
+ """Finalize the running task stack.
718
+
719
+ Triggered by its last frame reference (see get_frame_ref), or right
720
+ away by a header with no frames (see get_task_stack). The report's
721
+ frames are the task's new position, and its elapsed time is the time
722
+ the task spent at its previous one -- see AustinTaskReport and
723
+ _accrue_task_time. The report is attached to the sample
724
+ currently being assembled, and the task's owner is recorded as that
725
+ sample's thread: this event only ever arrives bracketed inside that
726
+ sample's own MOJO_STACK...next-MOJO_STACK window, so there's nothing
727
+ else it could belong to.
728
+
729
+ An empty ``ts.frames`` is a closing flush -- the task is gone
730
+ (completed, or otherwise), and this only carries its final time. It
731
+ has no name of its own, nor a meaningful owner (by the time eviction
732
+ runs, every thread for this cycle has already had its turn, so
733
+ "whichever sample is currently being assembled" is arbitrary here).
734
+ The task is forgotten, along with every waiter edge it is part of.
735
+ """
736
+ ts = self._running_task_stack
737
+ assert ts is not None, ts
738
+ assert self._running_sample is not None, self._running_sample
739
+
740
+ self._running_task_stack = None
741
+
742
+ existing = self._task_info.get(ts.task_id)
743
+ previous = existing.frames if existing is not None else None
744
+ elapsed = existing.pending if existing is not None else 0
745
+
746
+ if ts.name_key:
747
+ name: t.Optional[str] = self._lookup_string(ts.name_key).value
748
+ else:
749
+ name = existing.name if existing is not None else None
750
+
751
+ frames = tuple(
752
+ AustinFrame(
753
+ filename=mf.filename.value,
754
+ function=mf.scope.value,
755
+ line=mf.line,
756
+ line_end=mf.line_end,
757
+ column=mf.column,
758
+ column_end=mf.column_end,
759
+ )
760
+ for mf in ts.frames
761
+ )
762
+
763
+ self._running_sample.task_reports.append(
764
+ AustinTaskReport(
765
+ task_id=ts.task_id,
766
+ name=name,
767
+ frames=frames,
768
+ previous=previous,
769
+ elapsed=elapsed,
770
+ )
771
+ )
772
+
773
+ if not frames:
774
+ self._task_info.pop(ts.task_id, None)
775
+ self._task_edges = {e for e in self._task_edges if ts.task_id not in e}
776
+ return
777
+
778
+ self._task_info[ts.task_id] = _TaskInfo(
779
+ name=name,
780
+ owner=(self._running_sample.pid, self._running_sample.thread),
781
+ frames=frames,
782
+ )
783
+
784
+ def get_tasks(self, pid: ProcessId, thread: ThreadName) -> t.Tuple[AustinTask, ...]:
785
+ """Return the current best-known root tasks owned by (pid, thread).
786
+
787
+ Every root task it owns (nobody awaits it), and everything it
788
+ transitively awaits, as one AustinTask per root -- a thread can own
789
+ more than one at once (e.g. several siblings under one gather()).
790
+
791
+ Mirrors austin's own where_event_handler__render_tree: only live
792
+ tasks that have been reported at least once appear. A waiter we know
793
+ nothing about (never reported, or already gone) doesn't count as a
794
+ parent, so the tasks it awaited still show up, as roots. Like that
795
+ C-side counterpart, this is a no-op (returns empty) when (pid,
796
+ thread) owns no tasks, so callers can call it unconditionally.
797
+ """
798
+
799
+ def has_parent(task_id: int) -> bool:
800
+ return any(
801
+ t_id == task_id and w_id in self._task_info
802
+ for t_id, w_id in self._task_edges
803
+ )
804
+
805
+ def build(task_id: int, depth: int) -> AustinTask:
806
+ info = self._task_info[task_id]
807
+ awaiting = (
808
+ tuple(
809
+ build(awaited_id, depth + 1)
810
+ for awaited_id, waiter_id in self._task_edges
811
+ if waiter_id == task_id and awaited_id in self._task_info
812
+ )
813
+ if depth < MAX_TASK_TREE_DEPTH
814
+ else ()
815
+ )
816
+ return AustinTask(
817
+ task_id=task_id,
818
+ name=info.name,
819
+ frames=info.frames,
820
+ awaiting=awaiting,
821
+ )
822
+
823
+ owner = (pid, thread)
824
+ roots = [
825
+ task_id
826
+ for task_id, info in self._task_info.items()
827
+ if info.owner == owner and not has_parent(task_id)
828
+ ]
829
+
830
+ if not roots:
831
+ # Best-effort fallback, matching austin's own render_tree: if
832
+ # every one of this owner's tasks appears to have a parent (a
833
+ # cyclic/bogus edge), there's no true root -- show each as its
834
+ # own top-level tree rather than silently showing nothing.
835
+ roots = [
836
+ task_id
837
+ for task_id, info in self._task_info.items()
838
+ if info.owner == owner
839
+ ]
840
+
841
+ return tuple(build(task_id, 0) for task_id in roots)
842
+
507
843
  def get_kernel_frame(self, name: str) -> MojoKernelFrame:
508
844
  """Parse kernel frame."""
509
845
  return MojoKernelFrame(name)
@@ -630,7 +966,7 @@ class MojoStreamReader(BaseMojoStreamReader):
630
966
  @handles(MojoEvents.METRIC_TIME)
631
967
  def parse_time_metric(self) -> MojoMetric:
632
968
  """Parse time metric."""
633
- return self._parse_metric(MojoMetricType.TIME)
969
+ return self.get_time_metric(self.read_int())
634
970
 
635
971
  @handles(MojoEvents.METRIC_MEMORY)
636
972
  def parse_memory_metric(self) -> MojoMetric:
@@ -667,6 +1003,21 @@ class MojoStreamReader(BaseMojoStreamReader):
667
1003
  """Parse a stack repeat event."""
668
1004
  return self.get_stack_repeat()
669
1005
 
1006
+ @handles(MojoEvents.TASK_WAITER)
1007
+ def parse_task_waiter(self) -> MojoTaskWaiter:
1008
+ """Parse a task waiter edge."""
1009
+ return self.get_task_waiter(self.read_int(), self.read_int())
1010
+
1011
+ @handles(MojoEvents.TASK_RUNNING)
1012
+ def parse_task_running(self) -> MojoTaskRunning:
1013
+ """Parse the running task of a sample."""
1014
+ return self.get_task_running(self.read_int())
1015
+
1016
+ @handles(MojoEvents.TASK_STACK)
1017
+ def parse_task_stack(self) -> MojoTaskStack:
1018
+ """Parse the header of a task report."""
1019
+ return self.get_task_stack(self.read_int(), self.read_int(), self.read_int())
1020
+
670
1021
  def parse_event(self) -> t.Optional[MojoEvent]:
671
1022
  """Parse a single event."""
672
1023
  try:
@@ -724,10 +1075,13 @@ class MojoStreamReader(BaseMojoStreamReader):
724
1075
  if self.samples:
725
1076
  yield self.samples[-1]
726
1077
  if self._running_sample is not None:
727
- yield self._finalize_sample()
1078
+ yield self._finalize_sample(last=True)
728
1079
 
729
1080
  def hexdump(
730
- self, start: int, end: int, highlight: t.Set[int] = set() # noqa: B006
1081
+ self,
1082
+ start: int,
1083
+ end: int,
1084
+ highlight: t.Set[int] = set(), # noqa: B006
731
1085
  ) -> None:
732
1086
  """Print a hexdump of the MOJO file."""
733
1087
  self.mojo.seek(start)
@@ -818,7 +1172,7 @@ class AsyncMojoStreamReader(BaseMojoStreamReader):
818
1172
  @handles(MojoEvents.METRIC_TIME)
819
1173
  async def parse_time_metric(self) -> MojoMetric:
820
1174
  """Parse time metric."""
821
- return await self._parse_metric(MojoMetricType.TIME)
1175
+ return self.get_time_metric(await self.read_int())
822
1176
 
823
1177
  @handles(MojoEvents.METRIC_MEMORY)
824
1178
  async def parse_memory_metric(self) -> MojoMetric:
@@ -857,6 +1211,23 @@ class AsyncMojoStreamReader(BaseMojoStreamReader):
857
1211
  """Parse a stack repeat event."""
858
1212
  return self.get_stack_repeat()
859
1213
 
1214
+ @handles(MojoEvents.TASK_WAITER)
1215
+ async def parse_task_waiter(self) -> MojoTaskWaiter:
1216
+ """Parse a task waiter edge."""
1217
+ return self.get_task_waiter(await self.read_int(), await self.read_int())
1218
+
1219
+ @handles(MojoEvents.TASK_RUNNING)
1220
+ async def parse_task_running(self) -> MojoTaskRunning:
1221
+ """Parse the running task of a sample."""
1222
+ return self.get_task_running(await self.read_int())
1223
+
1224
+ @handles(MojoEvents.TASK_STACK)
1225
+ async def parse_task_stack(self) -> MojoTaskStack:
1226
+ """Parse the header of a task report."""
1227
+ return self.get_task_stack(
1228
+ await self.read_int(), await self.read_int(), await self.read_int()
1229
+ )
1230
+
860
1231
  async def parse_event(self) -> t.Optional[MojoEvent]:
861
1232
  """Parse a single event."""
862
1233
  try:
@@ -914,13 +1285,13 @@ class AsyncMojoStreamReader(BaseMojoStreamReader):
914
1285
  if self.samples:
915
1286
  yield self.samples[-1]
916
1287
  if self._running_sample is not None:
917
- yield self._finalize_sample()
1288
+ yield self._finalize_sample(last=True)
918
1289
 
919
1290
 
920
1291
  class BaseMojoStreamWriter(abc.ABC):
921
1292
  """Base class for MOJO stream writers."""
922
1293
 
923
- HEADER = b"MOJ\x03"
1294
+ HEADER = b"MOJ\x04"
924
1295
 
925
1296
  def __init__(self, mojo: t.Any) -> None:
926
1297
  self.mojo = mojo
@@ -936,6 +1307,11 @@ class BaseMojoStreamWriter(abc.ABC):
936
1307
  self._gc = False
937
1308
 
938
1309
  self._new_entries: t.List[MojoEvent] = []
1310
+ self._task_ids: t.Dict[int, int] = {}
1311
+ # The waiter set last written for each task, and the tasks written so
1312
+ # far (see write_tasks).
1313
+ self._written_waiters: t.Dict[int, t.FrozenSet[int]] = {}
1314
+ self._written_tasks: t.Set[int] = set()
939
1315
 
940
1316
  def set_metadata(self, metadata: AustinMetadata) -> None:
941
1317
  self._meta[metadata.name] = metadata.value
@@ -952,6 +1328,15 @@ class BaseMojoStreamWriter(abc.ABC):
952
1328
  self._new_entries.append(mojo_string)
953
1329
  return mojo_string
954
1330
 
1331
+ def resolve_task_id(self, task_id: int) -> int:
1332
+ try:
1333
+ return self._task_ids[task_id]
1334
+ except KeyError:
1335
+ # 0 is reserved: it stands for no task (e.g. an unknown waiter).
1336
+ compact_id = len(self._task_ids) + 1
1337
+ self._task_ids[task_id] = compact_id
1338
+ return compact_id
1339
+
955
1340
  def resolve_frame(self, frame: AustinFrame) -> MojoFrame:
956
1341
  try:
957
1342
  return self._frames[frame]
@@ -968,6 +1353,114 @@ class BaseMojoStreamWriter(abc.ABC):
968
1353
  self._new_entries.append(mojo_frame)
969
1354
  return mojo_frame
970
1355
 
1356
+ def _collect_tasks(
1357
+ self,
1358
+ tasks: t.Sequence[AustinTask],
1359
+ seen: t.Dict[int, AustinTask],
1360
+ edges: t.Dict[int, t.Set[int]],
1361
+ ) -> None:
1362
+ """Flatten a root-task list into its distinct tasks and waiter edges.
1363
+
1364
+ A task can legitimately appear more than once (once per waiter, if
1365
+ it has more than one -- see AustinTask's own docstring on
1366
+ ``awaiting``), so this collects each task_id's own stack exactly
1367
+ once (``seen``, first occurrence wins) and every one of its waiters
1368
+ together (``edges``), rather than re-walking and re-emitting it once
1369
+ per occurrence.
1370
+ """
1371
+ for task in tasks:
1372
+ seen.setdefault(task.task_id, task)
1373
+ for awaited in task.awaiting:
1374
+ edges.setdefault(awaited.task_id, set()).add(task.task_id)
1375
+ self._collect_tasks(task.awaiting, seen, edges)
1376
+
1377
+ def write_tasks(self, sample: AustinSample) -> int:
1378
+ """Write a sample's task reports, then the waiter edges that changed.
1379
+
1380
+ Each report is written once, as it was read, without its elapsed time,
1381
+ which readers work out from the samples. Reports that leave a task
1382
+ where it was, like end-of-stream reports, aren't written: readers
1383
+ produce those themselves. Waiter edges are written as the complete
1384
+ waiter set of every task whose set differs from the last one written
1385
+ for it, contiguously (required for MojoStreamReader.get_task_waiter's
1386
+ own batch-replace logic to reconstruct a multi-waiter task correctly),
1387
+ and an emptied set as a single edge to the reserved 0. See
1388
+ _collect_tasks for why each task is considered exactly once.
1389
+
1390
+ A task in the sample's tree that the stream hasn't introduced yet
1391
+ (e.g. a sample built by hand, with no reports) is introduced with a
1392
+ report of its own, carrying no time: readers only learn of tasks from
1393
+ reports.
1394
+ """
1395
+ size = 0
1396
+
1397
+ seen: t.Dict[int, AustinTask] = {}
1398
+ edges: t.Dict[int, t.Set[int]] = {}
1399
+ self._collect_tasks(sample.tasks, seen, edges)
1400
+
1401
+ reported = {r.task_id for r in sample.task_reports}
1402
+ introductions = tuple(
1403
+ AustinTaskReport(
1404
+ task_id=task.task_id,
1405
+ name=task.name,
1406
+ frames=task.frames,
1407
+ previous=None,
1408
+ elapsed=0,
1409
+ )
1410
+ for task in seen.values()
1411
+ if task.task_id not in self._written_tasks
1412
+ and task.task_id not in reported
1413
+ and task.frames
1414
+ )
1415
+
1416
+ for report in sample.task_reports + introductions:
1417
+ if report.frames and report.frames == report.previous:
1418
+ continue
1419
+
1420
+ name_key = (
1421
+ self.resolve_string(report.name).key
1422
+ if report.name and report.frames
1423
+ else 0
1424
+ )
1425
+ task_frames = [self.resolve_frame(f) for f in report.frames]
1426
+
1427
+ while self._new_entries:
1428
+ size += self.mojo.write(self._new_entries.pop(0).to_bytes())
1429
+
1430
+ size += self.mojo.write(
1431
+ MojoTaskStack(
1432
+ task_id=self.resolve_task_id(report.task_id),
1433
+ name_key=name_key,
1434
+ frame_count=len(task_frames),
1435
+ ).to_bytes()
1436
+ )
1437
+
1438
+ for frame in task_frames:
1439
+ size += self.mojo.write(MojoFrameReference(frame).to_bytes())
1440
+
1441
+ if report.closing:
1442
+ self._written_waiters.pop(report.task_id, None)
1443
+ self._written_tasks.discard(report.task_id)
1444
+ else:
1445
+ self._written_tasks.add(report.task_id)
1446
+
1447
+ for task_id in seen:
1448
+ waiter_ids = frozenset(edges.get(task_id, ()))
1449
+ if waiter_ids == self._written_waiters.get(task_id, frozenset()):
1450
+ continue
1451
+ self._written_waiters[task_id] = waiter_ids
1452
+
1453
+ compact_task_id = self.resolve_task_id(task_id)
1454
+ for waiter_id in waiter_ids or (0,):
1455
+ size += self.mojo.write(
1456
+ MojoTaskWaiter(
1457
+ task_id=compact_task_id,
1458
+ waiter_id=self.resolve_task_id(waiter_id) if waiter_id else 0,
1459
+ ).to_bytes()
1460
+ )
1461
+
1462
+ return size
1463
+
971
1464
  @abc.abstractmethod
972
1465
  def write(self, event: AustinEvent) -> int: ... # noqa: E704
973
1466
 
@@ -990,16 +1483,28 @@ class MojoStreamWriter(BaseMojoStreamWriter):
990
1483
  )
991
1484
 
992
1485
  elif isinstance(event, AustinSample):
993
- frames = (
994
- [self.resolve_frame(f) for f in event.frames] if event.frames else []
995
- )
996
-
997
1486
  size += self.mojo.write(
998
1487
  MojoStack(
999
1488
  pid=event.pid, iid=event.iid or 0, tid=event.thread
1000
1489
  ).to_bytes()
1001
1490
  )
1002
1491
 
1492
+ # Task-scan payload (task reports, then waiter edges) is written
1493
+ # before this sample's own frames -- it
1494
+ # rides between the enclosing MOJO_STACK header and the sample's
1495
+ # own frame dump on the real wire too (see austin's own
1496
+ # py_proc.c, _py_proc__sample_threads).
1497
+ size += self.write_tasks(event)
1498
+
1499
+ if event.running_task is not None:
1500
+ size += self.mojo.write(
1501
+ MojoTaskRunning(self.resolve_task_id(event.running_task)).to_bytes()
1502
+ )
1503
+
1504
+ frames = (
1505
+ [self.resolve_frame(f) for f in event.frames] if event.frames else []
1506
+ )
1507
+
1003
1508
  while self._new_entries:
1004
1509
  size += self.mojo.write(self._new_entries.pop(0).to_bytes())
1005
1510
 
austin/stats.py CHANGED
@@ -67,7 +67,8 @@ class HierarchicalStats:
67
67
  leaf is given by those instances with an empty ``children`` attribute.
68
68
 
69
69
  The ``label`` attribute is used for indexing reasons and therefore should
70
- be of a hashable type.
70
+ be of a hashable type. The ``samples`` attribute counts the samples that
71
+ contributed to the ``total`` of the node.
71
72
 
72
73
  This class overrides the default ``add`` operator so that one can perform
73
74
  operations like ``stats1 + stats2``. Note, however, that instances of this
@@ -79,6 +80,7 @@ class HierarchicalStats:
79
80
  own: int
80
81
  total: int
81
82
  children: Dict[Any, "HierarchicalStats"] = field(default_factory=dict)
83
+ samples: int = 0
82
84
 
83
85
  def __lshift__(self, other: "HierarchicalStats") -> "HierarchicalStats":
84
86
  """Merge the RHS into the LHS."""
@@ -87,6 +89,7 @@ class HierarchicalStats:
87
89
 
88
90
  self.own += other.own
89
91
  self.total += other.total
92
+ self.samples += other.samples
90
93
 
91
94
  for frame, child in other.children.items():
92
95
  try:
@@ -270,8 +273,11 @@ class AustinStats:
270
273
  by using :func:`Sample.parse` on a sample string passed by Austin to
271
274
  the sample callback.
272
275
  """
273
- if self.stats_type in {AustinStatsType.WALL, AustinStatsType.CPU}:
276
+ if self.stats_type is AustinStatsType.WALL:
274
277
  metric = sample.metrics.time
278
+ elif self.stats_type is AustinStatsType.CPU:
279
+ # In full mode, idle samples contribute wall time but no CPU time
280
+ metric = None if sample.idle else sample.metrics.time
275
281
  elif (
276
282
  self.stats_type is AustinStatsType.MEMORY_ALLOC
277
283
  and sample.metrics.memory is not None
@@ -292,13 +298,15 @@ class AustinStats:
292
298
 
293
299
  pid = sample.pid
294
300
  thread_info = ThreadInfo(sample.thread, sample.iid or 0)
295
- thread_stats = ThreadStats(thread_info, own=0, total=metric)
301
+ thread_stats = ThreadStats(thread_info, own=0, total=metric, samples=1)
296
302
 
297
303
  # Convert the list of frames into a nested FrameStats instance
298
304
  stats: HierarchicalStats = thread_stats
299
305
  container = thread_stats.children
300
306
  for height, frame in enumerate(sample.frames or []):
301
- stats = FrameStats(label=frame, height=height, own=0, total=metric)
307
+ stats = FrameStats(
308
+ label=frame, height=height, own=0, total=metric, samples=1
309
+ )
302
310
  container[frame] = stats
303
311
  container = stats.children
304
312
  stats.own = stats.total
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: austin-python
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Python wrapper for Austin, the CPython frame stack sampler.
5
5
  Project-URL: homepage, https://github.com/P403n1x87/austin-python
6
6
  Project-URL: documentation, https://austin-python.readthedocs.io
@@ -4,14 +4,14 @@ austin/base.py,sha256=Xq734VKzE2ggShs6UThsGznBGazg-gCw_nZEJDJKMlM,7553
4
4
  austin/cli.py,sha256=--PKm2I4NBBSSSBV5l-YC0mcmLjrvb1FHzxgY4dkby4,7826
5
5
  austin/config.py,sha256=PEwdG2Cy8ay1SrFr2N6YfQSZwseYBA1BA5dMfocKR3A,2091
6
6
  austin/errors.py,sha256=lCDSTANW_95Az_eDXbn__JRBllB9yw7tGnFo1-tw09Q,1046
7
- austin/events.py,sha256=u04uArj15b-CkQF7_WYnd1qZ41SFquAnxgpZN9KBskQ,2159
7
+ austin/events.py,sha256=afe6SYCXD_47qn6fVajKAgq4J-ivnEBtSOGqRAWFoA4,4910
8
8
  austin/simple.py,sha256=iTmJGAjSBeUv82SgdMTwdNlAQwuK33RyU8FkBCE8rRg,5579
9
- austin/stats.py,sha256=xY4BZtiE1Z4Bfucah1da6jwkEREvd1jLfmW4WuCgbP0,11084
9
+ austin/stats.py,sha256=YAE641Uwjt0Ty-CbhsHPeCy2B8OgShY_oo-BW-sJ_7Q,11465
10
10
  austin/threads.py,sha256=AvcoOkr3FeFAGBcqgXukNIt9xOZUipv7pXMqPIL_Qts,3649
11
11
  austin/format/__init__.py,sha256=ZtBJCLpTiZaLFwCN1UbMXLQvYjOSpwmtyGwqgK8yruQ,405
12
12
  austin/format/collapsed_stack.py,sha256=vD0CFkUgnRqHaA7dYsh4QYRZubE5IxyhVFnuPa37BSs,7245
13
13
  austin/format/compress.py,sha256=by6i79o0pEmGf2Py8edC6RWHVdr5RGsG2FWT6xQjUvk,3797
14
- austin/format/mojo.py,sha256=iqfPmJQ3tqoP-fclKDd0OiOSLiIQaWRjddA8L-7Huyw,31414
14
+ austin/format/mojo.py,sha256=YWFoturPDxihzF0ACTF_DFaLni5FyE8BJZa9GKzCa2U,51325
15
15
  austin/format/mojo_compress.py,sha256=qhzXsenUThxwAz1lX1Z8xtGgBpPEIbD3cqZz2Iar3ws,3711
16
16
  austin/format/speedscope.py,sha256=hIuB1A8wbGFFLQUpZqdM7Tlf4dqFwyy01zFHDybs2Iw,8324
17
17
  austin/format/pprof/__init__.py,sha256=E2JJDaw_k_m18OZeITKtepUUIo3n09UHdkpvabSSABc,6804
@@ -21,10 +21,10 @@ austin/format/pprof/profile_pb2.py,sha256=i_uM8lDExR95yz7qnEVikhpkpNvetKaYzoiH7A
21
21
  austin/tools/diff.py,sha256=5yrHlniofIeMSqzOUD55l2h6Ps0GqEpfisFRj65gpdQ,5008
22
22
  austin/tools/mojodbg.py,sha256=l8Lsa4rTQLXfkau8RyGziyz8ZyPEgacFVXafbHT9FN4,2816
23
23
  austin/tools/resolve.py,sha256=bTXkll68nOtXqa0XHW79mp_seAJW8_bhd6ej6wa2_Oc,7816
24
- austin_python-2.2.0.dist-info/METADATA,sha256=EAzdTQnAVmxg6NqXea2G5DzYcqIFwPksjByks3Qvn2s,7537
25
- austin_python-2.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
26
- austin_python-2.2.0.dist-info/entry_points.txt,sha256=xmPVC_6UkSOYu7-YMYm3BwIcMFYEhRw0qlF05Cq5gf0,378
27
- austin_python-2.2.0.dist-info/licenses/AUTHORS,sha256=pARRmpJQYH8iWy_0SonXsxOiV02AFitowle4AtURxUI,45
28
- austin_python-2.2.0.dist-info/licenses/COPYING,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
29
- austin_python-2.2.0.dist-info/licenses/LICENSE.md,sha256=edVDhmEiPf2W5lWzqLkrj6EYLSrCeYg9MJy28YsiMFU,34937
30
- austin_python-2.2.0.dist-info/RECORD,,
24
+ austin_python-2.4.0.dist-info/METADATA,sha256=ELAptpJDAMuBwv7PBHYsyG4GAYKvT3vD4k436ECt9wo,7537
25
+ austin_python-2.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
26
+ austin_python-2.4.0.dist-info/entry_points.txt,sha256=xmPVC_6UkSOYu7-YMYm3BwIcMFYEhRw0qlF05Cq5gf0,378
27
+ austin_python-2.4.0.dist-info/licenses/AUTHORS,sha256=pARRmpJQYH8iWy_0SonXsxOiV02AFitowle4AtURxUI,45
28
+ austin_python-2.4.0.dist-info/licenses/COPYING,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
29
+ austin_python-2.4.0.dist-info/licenses/LICENSE.md,sha256=edVDhmEiPf2W5lWzqLkrj6EYLSrCeYg9MJy28YsiMFU,34937
30
+ austin_python-2.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any