From: sashiko-bot@kernel.org
To: "Ian Rogers" <irogers@google.com>
Cc: linux-perf-users@vger.kernel.org
Subject: Re: [PATCH v7 1/2] perf timechart: Add a --live mode to the TUI
Date: Fri, 13 Nov 2026 06:29:16 +0000 [thread overview]
Message-ID: <sashiko-outbox-212273@kernel.org> (raw)
In-Reply-To: <20261113055926.1159033-2-irogers@google.com>
Thank you for your contribution! Sashiko AI review found 9 potential
issues to consider:
- [Medium] perf timechart: live_prune() at last_time deletes all open
states
- [Medium] perf timechart: checkpoint() and prune() drop blocked and
waiting states
- [Medium] perf timechart: stale pre-exec comm in task() during live I/O
mode
- [Medium] perf timechart: io_bytes and switches drift in add() and
prune()
- [Low] perf timechart: Task.totals drift after checkpoint() and
change_state()
- [Low] perf timechart: update_views() and dump() use sched tasks with
-I
- [Low] perf timechart: fix switch count underflow in Task.prune() after
gaps
- [Low] perf timechart: race in LiveRecorder.pump() clearing
flush_round_target
- [Low] perf timechart: race on data_fd in LiveRecorder.backlog() during
stop()
--
commit bf97da434173bd983d47c0d1625c5c56ef3fbfc7
Author: Alice Rogers <alice.mei.rogers@gmail.com>
perf timechart: Add a --live mode to the TUI
This commit adds a --live mode to perf timechart and ttimechart.py that
streams events from perf record into the TUI and prunes history outside a
sliding time window.
> diff --git a/tools/perf/python/ttimechart.py b/tools/perf/python/ttimechart.py
> index d7ba0276a9fb4..253116d96c717 100755
> --- a/tools/perf/python/ttimechart.py
> +++ b/tools/perf/python/ttimechart.py
[ ... ]
> @@ -265,6 +319,42 @@ class Task:
> return True
> return any(f == str(self.tid) or f in self.comms for f in filters)
>
> + def prune(self, time: int) -> None:
> + """Discard history ending at or before time, removing it from the totals."""
> + segs = self.segs
> + num = segs.num_ending_by(time)
> + # A still running task's last segment, extended by checkpoint(), ends
> + # at since. It hasn't been switched out yet.
> + open_edge = len(segs) - 1 if self.state == STATE_RUNNING and segs and \
> + segs.ends[-1] == self.since else -1
> + for i in range(num):
> + state = segs.keys[i]
> + self.totals[state] -= segs.ends[i] - segs.starts[i]
> + if state == STATE_RUNNING and i != open_edge:
> + # Running segments end when the task is switched out.
> + self.switches = max(self.switches - 1, 0)
[Severity: Low]
Does decrementing self.switches for every pruned STATE_RUNNING segment cause
the switch count to drift when running segments are closed without a switch?
TimechartData._close_gap(), TimechartData.finish() and the lost-switch path
in TimechartData.sched_switch() transition a running task to STATE_UNKNOWN
and add a STATE_RUNNING segment without incrementing task.switches. When
those segments are later pruned, i != open_edge is true and Task.prune()
decrements self.switches anyway. Conversely, if a switch-in was lost so
prev.state is not STATE_RUNNING in sched_switch(), prev.switches is
incremented without a matching STATE_RUNNING segment to decrement it on
prune.
> + if num < len(segs.starts) and segs.starts[num] < time:
> + self.totals[segs.keys[num]] -= time - segs.starts[num]
> + segs.prune(time)
> + io = self.io
> + for i in range(io.num_ending_by(time)):
> + ret = io.data[i][1]
> + if ret > 0 and io.keys[i] in (IOTYPE_READ, IOTYPE_WRITE, IOTYPE_TX, IOTYPE_RX):
> + self.io_bytes -= ret
> + io.prune(time)
[Severity: Medium]
Can SegmentList.add() merging or clipping segments cause Task.io_bytes and
syscall counts to drift across Task.prune()?
SegmentList.add() merges a new segment into the previous one when
start == self.ends[-1], key == self.keys[-1] and data == self.data[-1], and
drops segments that clip to end <= start. In TimechartData.io_exit(),
task.io.add() uses iotype as key and (fd, ret) as data, while task.io_bytes
is incremented by ret on every call. If two consecutive syscalls on the
same fd have the same type and return value and meet at self.ends[-1] (or
share the same timestamp so the second is clipped out), only one segment is
kept in task.io while task.io_bytes is incremented twice. len(task.io) then
undercounts syscalls and Task.prune() only subtracts ret once, leaking ret
bytes in task.io_bytes.
> + if self.io_pending and self.io_pending[0] < time:
> + self.io_pending = (time, self.io_pending[1], self.io_pending[2])
> + del self.wakeups[:bisect.bisect_right(self.wakeups, (time, sys.maxsize))]
> + if self.since <= time:
> + self.since = time
> + if not segs and self.state != STATE_RUNNING:
> + self.state = STATE_UNKNOWN
[Severity: Medium]
Does pruning a task whose state is STATE_BLOCKED or STATE_WAITING drop its
current state and history?
TimechartData.checkpoint() only extends open segments for tasks in
STATE_RUNNING, leaving STATE_BLOCKED and STATE_WAITING tasks unextended.
While a task remains blocked or waiting, that state is not shown in the live
timeline until a later event transitions the task, and once the prune window
reaches self.since, segs becomes empty and Task.prune() resets self.state to
STATE_UNKNOWN, allowing TimechartData.prune() to delete the task altogether.
[ ... ]
> @@ -342,13 +500,13 @@ class TimechartData:
> def task(self, tid: int, comm: Optional[str] = None) -> Task:
> """Find or create a task."""
> task = self.tasks.get(tid)
> + if not comm and self.session and (task is None or task.comm == f"[{tid}]"):
> + try:
> + thread = self.session.find_thread(tid, tid)
> + comm = thread.comm() if thread else None
> + except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
> + comm = None
> if task is None:
> - if not comm and self.session:
> - try:
> - thread = self.session.find_thread(tid, tid)
> - comm = thread.comm() if thread else None
> - except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
> - comm = None
> task = Task(tid, comm or f"[{tid}]")
> self.tasks[tid] = task
> else:
[Severity: Medium]
In live I/O-only mode, does this leave a task's pre-exec comm stuck after
execve()?
Syscall tracepoints call self.task(tid) with comm=None. When a child
process (such as a workload started by perf record with comm "perf-exec")
performs an I/O syscall before execve(), self.session.find_thread() sets
task.comm to the pre-exec name. After execve() emits PERF_RECORD_COMM,
task.comm is neither None nor "[{tid}]", so self.task() never queries
self.session.find_thread() again, and TimechartData.update_comms() is never
called in live mode because self.progress is None.
[ ... ]
> @@ -562,6 +752,32 @@ class TimechartData:
> except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
> pass
>
> + def checkpoint(self) -> None:
> + """Extend open running, idle and frequency states to last_time.
> +
> + Live mode calls this before displaying and pruning so that states that
> + haven't changed recently are shown up to the latest event, rather than
> + only once they end. The lock must be held.
> + """
> + end = self.last_time
> + if not end:
> + return
> + for task in self.tasks.values():
> + if task.state == STATE_RUNNING and end > task.since:
> + task.segs.add(task.since, end, STATE_RUNNING, task.cpu)
> + task.totals[STATE_RUNNING] += end - task.since
> + task.since = end
[Severity: Low]
Can extending all running tasks to self.last_time cause Task.totals to drift
when a subsequent flush delivers an earlier event on another CPU?
checkpoint() advances task.segs.ends[-1] and task.since to self.last_time
for every running task. If the next round flushes an event for one of those
tasks with sample_time < self.last_time, Task.change_state() sets
self.since = time (which is now earlier than self.segs.ends[-1]). On the
following state change, change_state() adds the unclipped time - self.since
to self.totals, whereas self.segs.add() clips the segment start to
self.segs.ends[-1]; when Task.prune() later removes that segment, it
subtracts the clipped length from self.totals, leaking the difference.
[ ... ]
> @@ -604,7 +820,8 @@ class TimechartData:
> if len(t.io) and t.passes_filter(filters)),
> key=lambda t: -len(t.io))
>
> - def dump(self, power_only: bool, tasks_only: bool, filters: Sequence[str]) -> None:
> + def dump(self, power_only: bool, tasks_only: bool, filters: Sequence[str],
> + io_only: bool = False) -> None:
> """Print a plain text summary, for use without a terminal UI."""
> duration = self.last_time - self.first_time
> span = max(duration, 1)
> @@ -613,7 +830,7 @@ class TimechartData:
> print(f"Duration: {fmt_duration(duration)}, CPUs: {len(self.cpus)}, "
> f"tasks: {len(tasks) or len(io_tasks)}, sched events: {self.sched_events}, "
> f"power events: {self.power_events}, I/O events: {self.io_events}")
[Severity: Low]
When io_only is true on a perf.data file that also contains scheduler
events, shouldn't dump() and TimechartApp.update_views() ignore
sched_tasks()?
In both dump() and TimechartApp.update_views(), tasks is still populated
from data.sched_tasks(filters) when io_only is set. If a recorded file
contains scheduler events as well as I/O events, -I reports the scheduler
task count and fills the Summary tab with scheduler tasks instead of
io_tasks, and pressing Enter on a task without I/O fails to jump to the
hidden Tasks tab.
[ ... ]
> @@ -1486,6 +1743,218 @@ class TimechartApp(App):
[ ... ]
> + def recording_finished(self) -> None:
> + """Called on the UI thread when perf record exits, such as when the workload ends."""
> + # Keep showing the window at the end of recording, or when paused,
> + # which is after the history already discarded.
> + self.finished_at = self.paused_at or monotonic_ns()
> + self.data.finish()
> + self.recording = False
> + self.paused_at = 0
> + self.loaded = True
> + with self.data.lock:
> + # Discard history from events flushed when perf record exited.
> + self.live_prune()
> + self.refresh_bindings()
> + self.update_views()
> + self.update_live_status()
> +
> + def live_bounds(self) -> Tuple[int, int]:
> + """The time range kept and shown in live mode, the last window of the recording.
> +
> + Until a window of time has been recorded the range is the first window,
> + so the timeline fills from the left, after that it moves with time.
> + """
> + assert self.live
> + if self.paused_at:
> + now = self.paused_at
> + elif self.recording:
> + now = monotonic_ns()
> + else:
> + now = self.finished_at
> + last = max(now, self.live.origin + self.live_window)
> + return max(self.live.origin, last - self.live_window), last
> +
> + def live_prune(self) -> None:
> + """Discard history before the live window, the data's lock must be held.
> +
> + Open states are only extended up to the last event, see checkpoint(),
> + so history after it isn't discarded. Otherwise, when events haven't
> + been seen for a window, a still open state would lose its history.
> + """
> + self.data.prune(min(self.live_bounds()[0], self.data.last_time))
> + self.prune_rows()
[Severity: Medium]
What happens in live_prune() when no new events arrive for --window seconds
so self.live_bounds()[0] reaches self.data.last_time?
TimechartData.checkpoint() and TimechartData.finish() only extend open
segments up to self.last_time, and SegmentList.num_ending_by(time) uses
bisect.bisect_right(self.ends, time), which selects all segments with
end <= time. Once self.live_bounds()[0] >= self.data.last_time, clamping
the prune time to self.data.last_time passes self.data.last_time to
self.data.prune(), which deletes every segment in the trace (including open
states just extended to self.data.last_time by checkpoint()). In
recording_finished(), self.data.finish() also sets every task's state to
STATE_UNKNOWN before self.live_prune() runs, so all tasks become empty and
are deleted as well.
[ ... ]
> @@ -1726,52 +2210,642 @@ class TimechartApp(App):
[ ... ]
> + def pump(self, src: int, dst: int, output: Any) -> None:
[ ... ]
> + while True:
> + try:
> + buf = os.read(src, 1 << 16)
> + except BlockingIOError:
> + break
> + except OSError:
> + eof = True
> + break
> + if not buf:
> + eof = True
> + break
> + if output:
> + output.write(buf)
> + with self.round_lock:
> + tracker.feed(buf)
> + if forwarding and not self.stopping.is_set():
> + # Keep draining src if dst is no longer read.
> + forwarding = self.write_pump(dst, buf)
> + if eof or not forwarding or self.stopping.is_set():
> + continue
> + flush = False
> + with self.round_lock:
> + target = self.flush_round_target
> + self.flush_round_target = None
> + if (target is not None and tracker.at_boundary() and
> + tracker.unflushed_round and
> + (target < 0 or tracker.rounds == target)):
> + tracker.unflushed_round = False
> + flush = True
[Severity: Low]
Can pump() clear self.flush_round_target before reading the data that
control_loop() wanted to flush?
pump thread control thread
----------- --------------
os.read(src) -> BlockingIOError
send("ping")
perf record writes round + FINISHED_ROUND
perf record writes ack
with self.round_lock:
self.flush_round_target = -1
wake_pump()
with self.round_lock:
target = self.flush_round_target
self.flush_round_target = None <-- cleared before new data in src is read
On the next loop iteration pump() drains the new round from src (setting
tracker.unflushed_round = True), but self.flush_round_target has already
been reset to None, leaving the final round unflushed in ordered_events
while paused. Snapshotting self.flush_round_target before draining src (and
only clearing it if it wasn't updated in the meantime) would avoid losing
the flush request.
[ ... ]
> + def backlog(self) -> float:
> + """The fraction of the data pipe filled with data waiting to be processed."""
> + try:
> + pending = array.array("i", [0])
> + fcntl.ioctl(self.data_fd, termios.FIONREAD, pending)
> + size = fcntl.fcntl(self.data_fd, fcntl.F_GETPIPE_SZ)
> + except OSError:
> + return 0.0
> + return pending[0] / max(size, 1)
[Severity: Low]
Can backlog() race with close_data_fds() when recording stops and raise an
uncaught ValueError on the UI thread?
worker thread UI thread
------------- ---------
record_events()
live.stop()
close_data_fds()
self.data_fd = -1
os.close(tee_r)
live_refreshed()
tune_refresh()
live.backlog()
fcntl.ioctl(-1, ...) <-- raises ValueError
call_from_thread(recording_finished)
self.recording = False
In Python, passing -1 to fcntl.ioctl() or fcntl.fcntl() raises ValueError
rather than OSError, which is not caught here. In addition, if
close_data_fds() closes tee_r right after backlog() reads self.data_fd, a
concurrent open() (such as restore_own_comm() opening /proc/self/comm) can
reuse that file descriptor number before ioctl() runs.
--
Sashiko AI review · https://sashiko.dev/#/patchset/20261113055926.1159033-1-irogers@google.com?part=1
next prev parent reply other threads:[~2026-11-13 6:29 UTC|newest]
Thread overview: 62+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-02 18:26 [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Ian Rogers
2026-10-02 18:26 ` [PATCH v1 01/13] perf session: Don't flush remaining events once processing is done Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 02/13] perf python: Quietly stop processing events when a callback raises Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 03/13] perf python: Lazily copy events and samples from process_events Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 04/13] perf python: Lazily resolve sample callchains Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 05/13] perf python: Release the GIL while processing session events Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 06/13] perf list: Add a --tui option to launch ilist Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 07/13] perf test: Add a test for the ilist script Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 08/13] perf treport: Show the profile while it loads Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-03 9:31 ` Arnaldo Carvalho de Melo
2026-10-02 18:26 ` [PATCH v1 09/13] perf test: Add a test for the treport script Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-03 9:35 ` Arnaldo Carvalho de Melo
2026-10-02 18:26 ` [PATCH v1 10/13] perf timechart: Add an interactive --tui mode Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 11/13] perf test: Add a test for perf timechart --tui Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 12/13] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-02 18:26 ` [PATCH v1 13/13] perf test: Test perf timechart --live Ian Rogers
2026-10-03 1:33 ` sashiko-bot
2026-10-03 9:10 ` [PATCH v1 00/13] perf timechart/list/treport: Interactive Textual TUIs and perf python session improvements Arnaldo Carvalho de Melo
2026-10-03 9:12 ` Arnaldo Carvalho de Melo
2026-10-03 9:15 ` Arnaldo Carvalho de Melo
2026-10-14 4:22 ` [PATCH v2 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-14 4:22 ` [PATCH v2 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-14 4:35 ` sashiko-bot
2026-10-14 4:22 ` [PATCH v2 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-14 4:28 ` sashiko-bot
2026-10-23 4:18 ` [PATCH v3 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23 4:18 ` [PATCH v3 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23 4:37 ` sashiko-bot
2026-10-23 4:18 ` [PATCH v3 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23 4:23 ` sashiko-bot
2026-10-23 5:02 ` [PATCH v4 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23 5:02 ` [PATCH v4 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23 5:25 ` sashiko-bot
2026-10-23 5:02 ` [PATCH v4 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23 5:07 ` sashiko-bot
2026-10-23 5:49 ` [PATCH v5 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-10-23 5:49 ` [PATCH v5 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-10-23 6:16 ` sashiko-bot
2026-10-23 5:49 ` [PATCH v5 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23 6:01 ` sashiko-bot
2026-11-12 23:37 ` [PATCH v6 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-11-12 23:37 ` [PATCH v6 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-11-13 0:00 ` sashiko-bot
2026-11-12 23:37 ` [PATCH v6 2/2] perf test: Test perf timechart --live Ian Rogers
2026-11-12 23:43 ` sashiko-bot
2026-11-13 5:59 ` [PATCH v7 0/2] perf timechart: Add --live mode to the TUI and test Ian Rogers
2026-11-13 5:59 ` [PATCH v7 1/2] perf timechart: Add a --live mode to the TUI Ian Rogers
2026-11-13 6:29 ` sashiko-bot [this message]
2026-11-13 5:59 ` [PATCH v7 2/2] perf test: Test perf timechart --live Ian Rogers
2026-11-13 6:06 ` sashiko-bot
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=sashiko-outbox-212273@kernel.org \
--to=sashiko-bot@kernel.org \
--cc=irogers@google.com \
--cc=linux-perf-users@vger.kernel.org \
--cc=sashiko-reviews@lists.linux.dev \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.