austin-python 2.3.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
@@ -47,31 +47,59 @@ class AustinMetadata(AustinEvent):
47
47
  class AustinTask:
48
48
  """A node in an asyncio task tree (3.14+ only).
49
49
 
50
- ``frames`` is this task's own last-known suspended coroutine-chain
51
- snapshot -- empty if it was never captured suspended (e.g. it was only
52
- ever seen actively running rather than awaiting something). ``awaiting``
53
- are the tasks this task is itself awaiting, i.e. the tasks for which this
54
- task is the direct waiter.
55
-
56
- ``elapsed`` does NOT describe ``frames``. Austin only re-samples a task's
57
- chain (and reports a new node) when its identity has actually changed
58
- since the last scan, so the two are always one step apart: ``frames`` is
59
- the task's brand new position (just captured because it changed), while
60
- ``elapsed`` is how long the task dwelled at its *previous*, now-replaced
61
- position -- the earliest point at which that duration could be known at
62
- all. None on a task's first-ever sighting, when there is no previous
63
- position to report a duration for. Always a wall/CPU time value, or None
64
- if Austin was run in pure memory mode (no per-task memory delta exists to
65
- report -- see austin's own py_proc.c, _py_proc__maybe_discover_asyncio).
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).
66
59
  """
67
60
 
68
61
  task_id: int
69
62
  name: t.Optional[str]
70
63
  frames: t.Tuple[AustinFrame, ...] = ()
71
- elapsed: t.Optional[MicroSeconds] = None
72
64
  awaiting: t.Tuple["AustinTask", ...] = ()
73
65
 
74
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
+
75
103
  @dataclass(frozen=True)
76
104
  class AustinSample(AustinEvent):
77
105
  """Austin sample."""
@@ -92,7 +120,13 @@ class AustinSample(AustinEvent):
92
120
  frames: t.Optional[t.Tuple[AustinFrame, ...]] = None
93
121
  gc: t.Optional[bool] = None
94
122
  idle: t.Optional[bool] = None
123
+ # The live tasks owned by this sample's thread, as of this sample.
95
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
96
130
 
97
131
  def key(
98
132
  self,
austin/format/mojo.py CHANGED
@@ -13,6 +13,7 @@ from austin.events import AustinMetadata
13
13
  from austin.events import AustinMetrics
14
14
  from austin.events import AustinSample
15
15
  from austin.events import AustinTask
16
+ from austin.events import AustinTaskReport
16
17
  from austin.events import InterpreterId
17
18
  from austin.events import ProcessId
18
19
  from austin.events import ThreadName
@@ -75,6 +76,7 @@ class MojoEvents:
75
76
  STACK_REPEAT = 13
76
77
  TASK_STACK = 14
77
78
  TASK_WAITER = 15
79
+ TASK_RUNNING = 16
78
80
 
79
81
 
80
82
  class MojoEventHandler:
@@ -219,28 +221,36 @@ class MojoTaskWaiter(MojoEvent):
219
221
  waiter_id: int
220
222
 
221
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
+
222
237
  @dataclass(frozen=True, eq=True)
223
238
  class MojoTaskStack(MojoEvent):
224
239
  """MOJO task stack event.
225
240
 
226
- Introduces the coroutine stack of a suspended task, keyed by the remote
227
- address of its TaskObj and its (possibly cached) name key (0 if
228
- unresolved). The frames that make up the stack, and the trailing time
229
- metric that terminates it, follow as ordinary MOJO_FRAME/MOJO_FRAME_REF/
230
- MOJO_METRIC_TIME events rather than being part of this event itself --
231
- see BaseMojoStreamReader's task-stack handling.
232
-
233
- The trailing metric is always a time value, and describes how long the
234
- task dwelled at its PREVIOUS position, not the frames that just followed
235
- this event -- see AustinTask's own docstring for why the two are always
236
- one step apart. It never appears at all when austin was run in pure
237
- memory mode.
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.
238
247
  """
239
248
 
240
249
  EVENT_ID = MojoEvents.TASK_STACK
241
250
 
242
251
  task_id: int
243
252
  name_key: int
253
+ frame_count: int
244
254
 
245
255
 
246
256
  @dataclass(frozen=True, eq=True)
@@ -310,12 +320,15 @@ class _RunningSample:
310
320
  metrics: t.Dict[MojoMetricType, MojoMetric] = field(default_factory=dict)
311
321
  gc: t.Optional[bool] = None
312
322
  idle: t.Optional[bool] = None
323
+ running_task: t.Optional[int] = None
324
+ task_reports: t.List[AustinTaskReport] = field(default_factory=list)
313
325
 
314
326
 
315
327
  @dataclass
316
328
  class _RunningTaskStack:
317
329
  task_id: int
318
330
  name_key: int
331
+ frame_count: int
319
332
  frames: t.List[MojoFrame] = field(default_factory=list)
320
333
 
321
334
 
@@ -332,7 +345,8 @@ class _TaskInfo:
332
345
  name: t.Optional[str]
333
346
  owner: t.Tuple[ProcessId, ThreadName]
334
347
  frames: t.Tuple[AustinFrame, ...] = ()
335
- elapsed: t.Optional[int] = None
348
+ # Time spent at ``frames`` so far, i.e. since the task's last report.
349
+ pending: int = 0
336
350
 
337
351
 
338
352
  def int_reader() -> t.Generator[t.Optional[int], bytes, int]:
@@ -473,10 +487,61 @@ class BaseMojoStreamReader(AustinEventIterator):
473
487
  def gc(self) -> t.Optional[str]:
474
488
  return self.metadata.get("gc")
475
489
 
476
- def _finalize_sample(self) -> AustinSample:
477
- """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
+ """
478
539
  assert self._running_sample is not None, self._running_sample
479
540
 
541
+ self._accrue_task_time(self._running_sample)
542
+ if last:
543
+ self._running_sample.task_reports.extend(self._end_task_reports())
544
+
480
545
  self.samples.append(
481
546
  sample := AustinSample(
482
547
  pid=self._running_sample.pid,
@@ -505,6 +570,8 @@ class BaseMojoStreamReader(AustinEventIterator):
505
570
  ),
506
571
  gc=self._running_sample.gc,
507
572
  idle=self._running_sample.idle,
573
+ running_task=self._running_sample.running_task,
574
+ task_reports=tuple(self._running_sample.task_reports),
508
575
  tasks=self.get_tasks(
509
576
  self._running_sample.pid, self._running_sample.thread
510
577
  ),
@@ -551,6 +618,10 @@ class BaseMojoStreamReader(AustinEventIterator):
551
618
  if self._running_sample is not None:
552
619
  self._finalize_sample()
553
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
+
554
625
  self._running_sample = _RunningSample(
555
626
  pid=pid,
556
627
  iid=iid,
@@ -593,6 +664,11 @@ class BaseMojoStreamReader(AustinEventIterator):
593
664
 
594
665
  if self._running_task_stack is not None:
595
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()
596
672
  else:
597
673
  assert self._running_sample is not None, self._running_sample
598
674
  self._running_sample.frames.append(frame)
@@ -608,73 +684,103 @@ class BaseMojoStreamReader(AustinEventIterator):
608
684
  self._task_edges = {e for e in self._task_edges if e[0] != task_id}
609
685
  self._last_waiter_task_id = task_id
610
686
 
611
- self._task_edges.add((task_id, waiter_id))
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))
612
690
 
613
691
  return MojoTaskWaiter(task_id, waiter_id)
614
692
 
615
- def get_task_stack(self, task_id: int, name_key: int) -> MojoTaskStack:
616
- """Parse the header of a suspended task's coroutine-chain snapshot.
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)
617
699
 
618
- The frames and terminating time metric that complete it arrive as
619
- subsequent, ordinary events -- see get_frame_ref/get_time_metric.
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.
620
707
  """
621
- self._running_task_stack = _RunningTaskStack(task_id, name_key)
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()
622
713
 
623
- return MojoTaskStack(task_id, name_key)
714
+ return MojoTaskStack(task_id, name_key, frame_count)
624
715
 
625
- def _finalize_task_stack(self, elapsed: int) -> None:
716
+ def _finalize_task_stack(self) -> None:
626
717
  """Finalize the running task stack.
627
718
 
628
- Triggered by its terminating MOJO_METRIC_TIME (see get_time_metric).
629
- ``ts.frames`` (the task's brand new position) and ``elapsed`` (how
630
- long it dwelled at its previous one) describe two different moments
631
- even though they land in the same _TaskInfo/AustinTask -- see
632
- AustinTask's own docstring. The task's owner is recorded as whichever
633
- sample is currently being assembled: this event only ever arrives
634
- bracketed inside that sample's own MOJO_STACK...next-MOJO_STACK
635
- window, so there's nothing else it could belong to.
636
-
637
- An empty ``ts.frames`` is a closing flush -- the task has just been
638
- evicted (completed, or otherwise gone), and this only carries its
639
- final dwell time, with no new content and no meaningful owner (by
640
- the time eviction runs, every thread for this cycle has already had
641
- its turn, so "whichever sample is currently being assembled" is
642
- arbitrary here, not this task's actual owner). Only update elapsed
643
- on whatever's already known; leave frames/owner untouched rather
644
- than wiping the task's last real position right as it disappears.
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.
645
735
  """
646
736
  ts = self._running_task_stack
647
737
  assert ts is not None, ts
648
738
  assert self._running_sample is not None, self._running_sample
649
739
 
650
- if not ts.frames:
651
- existing = self._task_info.get(ts.task_id)
652
- if existing is not None:
653
- existing.elapsed = elapsed
654
- self._running_task_stack = None
655
- return
740
+ self._running_task_stack = None
656
741
 
657
- name = self._lookup_string(ts.name_key).value if ts.name_key else None
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
658
777
 
659
778
  self._task_info[ts.task_id] = _TaskInfo(
660
779
  name=name,
661
780
  owner=(self._running_sample.pid, self._running_sample.thread),
662
- frames=tuple(
663
- AustinFrame(
664
- filename=mf.filename.value,
665
- function=mf.scope.value,
666
- line=mf.line,
667
- line_end=mf.line_end,
668
- column=mf.column,
669
- column_end=mf.column_end,
670
- )
671
- for mf in ts.frames
672
- ),
673
- elapsed=elapsed,
781
+ frames=frames,
674
782
  )
675
783
 
676
- self._running_task_stack = None
677
-
678
784
  def get_tasks(self, pid: ProcessId, thread: ThreadName) -> t.Tuple[AustinTask, ...]:
679
785
  """Return the current best-known root tasks owned by (pid, thread).
680
786
 
@@ -682,17 +788,19 @@ class BaseMojoStreamReader(AustinEventIterator):
682
788
  transitively awaits, as one AustinTask per root -- a thread can own
683
789
  more than one at once (e.g. several siblings under one gather()).
684
790
 
685
- Mirrors austin's own where_event_handler__render_tree: a task only
686
- appears if it was itself captured suspended at least once (a waiter
687
- edge naming a task we never saw a MOJO_TASK_STACK for -- e.g. it was
688
- only ever seen actively running -- is omitted, exactly like austin's
689
- own where_event_handler__find_task returning NULL for it). Like that
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
690
795
  C-side counterpart, this is a no-op (returns empty) when (pid,
691
796
  thread) owns no tasks, so callers can call it unconditionally.
692
797
  """
693
798
 
694
799
  def has_parent(task_id: int) -> bool:
695
- return any(t_id == task_id for t_id, _ in self._task_edges)
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
+ )
696
804
 
697
805
  def build(task_id: int, depth: int) -> AustinTask:
698
806
  info = self._task_info[task_id]
@@ -709,7 +817,6 @@ class BaseMojoStreamReader(AustinEventIterator):
709
817
  task_id=task_id,
710
818
  name=info.name,
711
819
  frames=info.frames,
712
- elapsed=info.elapsed,
713
820
  awaiting=awaiting,
714
821
  )
715
822
 
@@ -746,17 +853,7 @@ class BaseMojoStreamReader(AustinEventIterator):
746
853
  return metric
747
854
 
748
855
  def get_time_metric(self, value: int) -> MojoMetric:
749
- """Parse time metric.
750
-
751
- A task stack's frame sequence has no length prefix on the wire -- its
752
- end is only knowable by the terminating MOJO_METRIC_TIME that always
753
- follows it (see mojo_event_handler__handle_task_stack_end), which is
754
- why this, rather than get_task_stack, is where finalization happens.
755
- """
756
- if self._running_task_stack is not None:
757
- self._finalize_task_stack(value)
758
- return MojoMetric(MojoMetricType.TIME, value)
759
-
856
+ """Parse time metric."""
760
857
  return self._get_metric(MojoMetricType.TIME, value)
761
858
 
762
859
  def get_memory_metric(self, value: int) -> MojoMetric:
@@ -911,10 +1008,15 @@ class MojoStreamReader(BaseMojoStreamReader):
911
1008
  """Parse a task waiter edge."""
912
1009
  return self.get_task_waiter(self.read_int(), self.read_int())
913
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
+
914
1016
  @handles(MojoEvents.TASK_STACK)
915
1017
  def parse_task_stack(self) -> MojoTaskStack:
916
- """Parse the header of a suspended task's coroutine-chain snapshot."""
917
- return self.get_task_stack(self.read_int(), self.read_int())
1018
+ """Parse the header of a task report."""
1019
+ return self.get_task_stack(self.read_int(), self.read_int(), self.read_int())
918
1020
 
919
1021
  def parse_event(self) -> t.Optional[MojoEvent]:
920
1022
  """Parse a single event."""
@@ -973,7 +1075,7 @@ class MojoStreamReader(BaseMojoStreamReader):
973
1075
  if self.samples:
974
1076
  yield self.samples[-1]
975
1077
  if self._running_sample is not None:
976
- yield self._finalize_sample()
1078
+ yield self._finalize_sample(last=True)
977
1079
 
978
1080
  def hexdump(
979
1081
  self,
@@ -1114,10 +1216,17 @@ class AsyncMojoStreamReader(BaseMojoStreamReader):
1114
1216
  """Parse a task waiter edge."""
1115
1217
  return self.get_task_waiter(await self.read_int(), await self.read_int())
1116
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
+
1117
1224
  @handles(MojoEvents.TASK_STACK)
1118
1225
  async def parse_task_stack(self) -> MojoTaskStack:
1119
- """Parse the header of a suspended task's coroutine-chain snapshot."""
1120
- return self.get_task_stack(await self.read_int(), await self.read_int())
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
+ )
1121
1230
 
1122
1231
  async def parse_event(self) -> t.Optional[MojoEvent]:
1123
1232
  """Parse a single event."""
@@ -1176,7 +1285,7 @@ class AsyncMojoStreamReader(BaseMojoStreamReader):
1176
1285
  if self.samples:
1177
1286
  yield self.samples[-1]
1178
1287
  if self._running_sample is not None:
1179
- yield self._finalize_sample()
1288
+ yield self._finalize_sample(last=True)
1180
1289
 
1181
1290
 
1182
1291
  class BaseMojoStreamWriter(abc.ABC):
@@ -1199,6 +1308,10 @@ class BaseMojoStreamWriter(abc.ABC):
1199
1308
 
1200
1309
  self._new_entries: t.List[MojoEvent] = []
1201
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()
1202
1315
 
1203
1316
  def set_metadata(self, metadata: AustinMetadata) -> None:
1204
1317
  self._meta[metadata.name] = metadata.value
@@ -1219,7 +1332,8 @@ class BaseMojoStreamWriter(abc.ABC):
1219
1332
  try:
1220
1333
  return self._task_ids[task_id]
1221
1334
  except KeyError:
1222
- compact_id = len(self._task_ids)
1335
+ # 0 is reserved: it stands for no task (e.g. an unknown waiter).
1336
+ compact_id = len(self._task_ids) + 1
1223
1337
  self._task_ids[task_id] = compact_id
1224
1338
  return compact_id
1225
1339
 
@@ -1260,51 +1374,88 @@ class BaseMojoStreamWriter(abc.ABC):
1260
1374
  edges.setdefault(awaited.task_id, set()).add(task.task_id)
1261
1375
  self._collect_tasks(task.awaiting, seen, edges)
1262
1376
 
1263
- def write_tasks(self, tasks: t.Sequence[AustinTask]) -> int:
1264
- """Write every task's own coroutine-chain snapshot, then every waiter edge.
1265
-
1266
- See _collect_tasks for why each is written exactly once, and edges
1267
- for one task_id are always written contiguously (required for
1268
- MojoStreamReader.get_task_waiter's own batch-replace logic to
1269
- reconstruct a multi-waiter task correctly).
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.
1270
1394
  """
1271
1395
  size = 0
1272
1396
 
1273
1397
  seen: t.Dict[int, AustinTask] = {}
1274
1398
  edges: t.Dict[int, t.Set[int]] = {}
1275
- self._collect_tasks(tasks, seen, edges)
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
+ )
1276
1415
 
1277
- for task in seen.values():
1278
- name_key = self.resolve_string(task.name).key if task.name else 0
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]
1279
1426
 
1280
1427
  while self._new_entries:
1281
1428
  size += self.mojo.write(self._new_entries.pop(0).to_bytes())
1282
1429
 
1283
1430
  size += self.mojo.write(
1284
1431
  MojoTaskStack(
1285
- task_id=self.resolve_task_id(task.task_id), name_key=name_key
1432
+ task_id=self.resolve_task_id(report.task_id),
1433
+ name_key=name_key,
1434
+ frame_count=len(task_frames),
1286
1435
  ).to_bytes()
1287
1436
  )
1288
1437
 
1289
- task_frames = [self.resolve_frame(f) for f in task.frames]
1290
-
1291
- while self._new_entries:
1292
- size += self.mojo.write(self._new_entries.pop(0).to_bytes())
1293
-
1294
1438
  for frame in task_frames:
1295
1439
  size += self.mojo.write(MojoFrameReference(frame).to_bytes())
1296
1440
 
1297
- size += self.mojo.write(
1298
- MojoMetric(MojoMetricType.TIME, task.elapsed or 0).to_bytes()
1299
- )
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
1300
1452
 
1301
- for task_id, waiter_ids in edges.items():
1302
1453
  compact_task_id = self.resolve_task_id(task_id)
1303
- for waiter_id in waiter_ids:
1454
+ for waiter_id in waiter_ids or (0,):
1304
1455
  size += self.mojo.write(
1305
1456
  MojoTaskWaiter(
1306
1457
  task_id=compact_task_id,
1307
- waiter_id=self.resolve_task_id(waiter_id),
1458
+ waiter_id=self.resolve_task_id(waiter_id) if waiter_id else 0,
1308
1459
  ).to_bytes()
1309
1460
  )
1310
1461
 
@@ -1338,13 +1489,17 @@ class MojoStreamWriter(BaseMojoStreamWriter):
1338
1489
  ).to_bytes()
1339
1490
  )
1340
1491
 
1341
- # Task-scan payload (waiter edges + suspended coroutine-chain
1342
- # snapshots) is written before this sample's own frames -- it
1492
+ # Task-scan payload (task reports, then waiter edges) is written
1493
+ # before this sample's own frames -- it
1343
1494
  # rides between the enclosing MOJO_STACK header and the sample's
1344
1495
  # own frame dump on the real wire too (see austin's own
1345
1496
  # py_proc.c, _py_proc__sample_threads).
1346
- if event.tasks:
1347
- size += self.write_tasks(event.tasks)
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
+ )
1348
1503
 
1349
1504
  frames = (
1350
1505
  [self.resolve_frame(f) for f in event.frames] if event.frames else []
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
1
  Metadata-Version: 2.5
2
2
  Name: austin-python
3
- Version: 2.3.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=6d-rNYG_yXx6VOqyyMWalcBHvstriCZNWWPttDhO_zM,3579
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=mEmMdRreh60p_-rKTCecqVSd7bCxkHssa-pK2_vHKrM,45734
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.3.0.dist-info/METADATA,sha256=K7EAem3JpnpvVlVY0XQ3WyV-sSfkY5UcAP-hPKLZYF8,7537
25
- austin_python-2.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
26
- austin_python-2.3.0.dist-info/entry_points.txt,sha256=xmPVC_6UkSOYu7-YMYm3BwIcMFYEhRw0qlF05Cq5gf0,378
27
- austin_python-2.3.0.dist-info/licenses/AUTHORS,sha256=pARRmpJQYH8iWy_0SonXsxOiV02AFitowle4AtURxUI,45
28
- austin_python-2.3.0.dist-info/licenses/COPYING,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
29
- austin_python-2.3.0.dist-info/licenses/LICENSE.md,sha256=edVDhmEiPf2W5lWzqLkrj6EYLSrCeYg9MJy28YsiMFU,34937
30
- austin_python-2.3.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.32.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any