From: sashiko-bot@kernel.org
To: "Ian Rogers" <irogers@google.com>
Cc: linux-perf-users@vger.kernel.org
Subject: Re: [PATCH v5 1/2] perf timechart: Add a --live mode to the TUI
Date: Fri, 23 Oct 2026 06:16:07 +0000 [thread overview]
Message-ID: <sashiko-outbox-182337@kernel.org> (raw)
In-Reply-To: <20261023054952.2503540-2-irogers@google.com>
Thank you for your contribution! Sashiko AI review found 6 potential issue(s) to consider:
- [Medium] perf timechart: broken live cursor following on zoom, reset, and goto_task()
- [Medium] perf timechart: mark_gap() overwrites pending gap_at on rapid pause/resume
- [Medium] perf timechart: unlocked nr_samples increment races with live_refresh()
- [Medium] perf timechart: missing finished_round leaves live events buffered
- [Medium] perf timechart: incomplete syscall filter in LiveRecorder.start()
- [Medium] perf timechart: live mode stop() blocks perf record and orphans workload
--
commit dc903a5155a83951be9359f3695ae64ac3fc69e5
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
runs perf record into a pipe and displays a rolling window of scheduler,
power, or I/O events interactively in the terminal UI. It also supports
pausing and resuming recording via perf record's control pipe and adaptive
refresh intervals.
> diff --git a/tools/perf/python/ttimechart.py b/tools/perf/python/ttimechart.py
> index 34ecf84e68c1d..acd1a8651244e 100755
> --- a/tools/perf/python/ttimechart.py
> +++ b/tools/perf/python/ttimechart.py
[ ... ]
> @@ -326,21 +425,83 @@ class TimechartData:
[ ... ]
> + def mark_gap(self, time: int) -> None:
> + """Note that recording, paused for a while, resumes at time, the lock must be held.
> +
> + Events weren't recorded while paused, so the states at the pause aren't
> + known to have continued. They are closed before the first event after
> + time is processed, the events are processed in time order.
> + """
> + self.gap_at = time
> +
> + def _close_gap(self) -> None:
> + """Close open states at the last event before the gap, see mark_gap()."""
> + end = self.last_time
> + for task in self.tasks.values():
> + task.change_state(end, STATE_UNKNOWN)
> + task.io_pending = None
> + for c in self.cpus.values():
> + if c.cur_tid > 0:
> + c.run.add(c.since, end, 1, c.cur_tid)
> + c.cur_tid = -1
> + if c.cstate_cur:
> + c.cstate.add(c.cstate_cur[0], end, 1, c.cstate_cur[1])
> + c.cstate_cur = None
> + if c.pstate_cur:
> + c.pstate.add(c.pstate_cur[0], end, 1, c.pstate_cur[1])
> + c.pstate_cur = None
> + self.resumed_at = self.gap_at
> + self.gap_at = 0
[ ... ]
> @@ -527,6 +701,8 @@ class TimechartData:
> def _process_event(self, sample: perf.sample_event) -> None:
> """Update the data from a sample, the lock must be held."""
> time = sample.sample_time
> + if self.gap_at and time >= self.gap_at:
> + self._close_gap()
> if not self.first_time or time < self.first_time:
> self.first_time = time
> self.last_time = max(self.last_time, time)
[Severity: Medium]
What happens in TimechartData.mark_gap() and TimechartData._process_event()
if the user pauses and resumes live recording again before an event from
the first resumed interval has been processed?
In TimechartApp.action_toggle_pause(), resuming from pause calls
self.data.mark_gap(monotonic_ns()), which sets self.gap_at = time. Open
task and CPU states from before the pause are only closed when
TimechartData._process_event() sees a sample with time >= self.gap_at and
calls self._close_gap().
Because perf record flushes periodically on ping and perf.session buffers
events in ordered_events until a subsequent round marker, a user can resume
at T2, pause at T3, and resume at T4 before any sample with time >= T2
reaches TimechartData._process_event(). When mark_gap(T4) overwrites
self.gap_at = T4, buffered samples from [T2, T3) have sample_time < T4 and
bypass _close_gap(), stretching pre-pause task and CPU states across the
first pause gap.
Should mark_gap() avoid overwriting a smaller non-zero self.gap_at, or
close the pending gap immediately before recording the new timestamp?
[ ... ]
> @@ -1486,6 +1726,204 @@ class TimechartApp(App):
[ ... ]
> + def live_refresh(self) -> None:
> + """Show newly recorded events and discard those no longer in the window."""
> + start = monotonic()
> + with self.data.lock:
> + samples = self.data.nr_samples
> + # When paused, once the buffered events are shown there's nothing new.
> + if not self.paused_at or samples != self.refreshed_samples:
> + self.refreshed_samples = samples
> + with self.data.lock:
> + self.data.checkpoint()
> + if self.recording and not self.paused_at:
> + self.live_prune()
> + self.update_views()
> + self.update_live_status()
> + # Measure the cost of the refresh including drawing the screen.
> + self.call_after_refresh(self.live_refreshed, start)
[Severity: Medium]
Can TimechartApp.live_refresh() race with TimechartData.process_event() and
miss rendering the final buffered event when paused?
In TimechartData.process_event(), the worker thread increments
self.nr_samples before acquiring self.lock to call _process_event(sample):
def process_event(self, sample: perf.sample_event) -> None:
"""Callback from perf.session for each sample."""
self.nr_samples += 1
...
with self.lock:
self._process_event(sample)
Meanwhile, TimechartApp.live_refresh() reads self.data.nr_samples under
self.data.lock, releases the lock, updates self.refreshed_samples = samples,
and then re-acquires self.data.lock to call self.data.checkpoint() and
self.update_views().
If live recording is paused (self.paused_at != 0) and live_refresh() reads
the incremented self.data.nr_samples before the worker thread acquires
self.lock and finishes _process_event(sample), live_refresh() records
self.refreshed_samples = samples and renders the views before the sample is
added to self.data. Subsequent live_refresh() ticks while paused then see
samples == self.refreshed_samples and skip refreshing.
Should self.nr_samples be incremented inside with self.lock: in
TimechartData.process_event(), and read inside the same lock acquisition in
TimechartApp.live_refresh()?
[ ... ]
> + def user_window(self, window: TimeWindow) -> None:
> + """Change the window for the user.
> +
> + In live mode, zooming or panning so that the whole window isn't shown
> + stops the view following the latest events.
> + """
> + if self.live:
> + whole = window.start == window.first and window.end == window.last
> + if window.cursor != self.window.cursor:
> + self.follow_cursor = False
> + elif whole and not self.following:
> + self.follow_cursor = True
> + if self.data.last_time:
> + window = replace(window, cursor=min(max(self.data.last_time, window.first),
> + window.last))
> + if self.following != whole:
> + self.following = whole
> + self.update_live_status()
> + self.window = window
[ ... ]
> @@ -1569,14 +2012,23 @@ class TimechartApp(App):
[ ... ]
> - if first or last:
> + if self.live:
> + live_first, live_last = self.live_bounds()
> + win = self.window.extend(live_first, live_last)
> + if self.following:
> + win = win.reset()
> + if self.follow_cursor and last:
> + win = replace(win, cursor=min(max(last, live_first), live_last))
> + self.window = win
> + elif first or last:
> self.window = self.window.extend(first, last)
[Severity: Medium]
Does cursor following in TimechartApp.user_window(),
TimechartApp.update_views(), and TimechartApp.goto_task() stay in sync when
zooming, resetting zoom, or jumping to a waker in live mode?
There appear to be three related cases where self.follow_cursor and
self.following diverge:
1. When zooming in or out via TimelineView.action_zoom_in() or
action_zoom_out(), TimeWindow.zoom() keeps window.cursor ==
self.window.cursor whenever the cursor is within [self.start, self.end].
In TimechartApp.user_window(), whole becomes False and self.following is
set to False, but self.follow_cursor = False is only run if window.cursor
!= self.window.cursor. Because self.follow_cursor remains True, every
subsequent live_refresh() -> update_views() overwrites win.cursor with
last, dragging the cursor out of the zoomed view.
2. If the user moves the cursor without zooming or panning, user_window()
sets self.follow_cursor = False while whole and self.following remain True.
Pressing 0 (TimelineView.action_zoom_reset()) then calls
user_window(self.window.reset()), where elif whole and not self.following:
is skipped because self.following is already True, leaving
self.follow_cursor stuck at False.
3. Pressing w (TimechartApp.action_goto_waker() -> goto_task()) assigns
self.window = self.window.with_cursor(time) directly instead of calling
self.user_window(), leaving self.follow_cursor (and self.following) True so
the next refresh snaps the cursor and view back to the latest event.
[ ... ]
> @@ -1727,52 +2180,374 @@ class TimechartApp(App):
[ ... ]
> + def start(self) -> None:
> + """Start perf record, the data is then read from data_fd."""
> + output = open(self.output, "wb") if self.output else None
> + # perf record's ends of the control pipes, closed once it is started.
> + child_fds: List[int] = []
> + try:
> + ctl_r, self.ctl_fd = os.pipe()
> + child_fds.append(ctl_r)
> + self.ack_fd, ack_w = os.pipe()
> + child_fds.append(ack_w)
> + # Quiet so that only errors are written to stderr, see error_text().
> + cmd = [self.perf_exe, "record", "-q", "-o", "-", "-R", "-c", "1", "-k", "mono",
> + "--synth=task", "--control", f"fd:{ctl_r},{ack_w}"]
> + filt = f"common_pid != {os.getpid()}"
> + for name in self.events():
> + cmd += ["-e", name]
> + if name.startswith("syscalls:"):
> + cmd += ["--filter", filt]
[Severity: Medium]
Does the tracepoint filter common_pid != os.getpid() in
LiveRecorder.start() exclude I/O syscalls made by the child perf record
process and ttimechart.py's worker threads when running with --live -I?
In kernel tracepoints, common_pid is current->pid (the thread ID, not the
process TGID). Unlike builtin-timechart.c:timechart__io_record(), which
runs cmd_record() in-process on a single thread, LiveRecorder.start() spawns
perf record as a child process and runs control_thread, tee_thread,
record_events, and Textual workers on separate Python threads whose TIDs
differ from os.getpid().
While TimechartApp.__init__() adds os.getpid() and proc.pid to
self.data.ignore_pids to hide them from live TUI rows, all of their
read/write/poll/select syscalls are still recorded by the kernel. Those
events increment self.nr_samples in TimechartData.process_event() before
io_enter() and io_exit() check ignore_pids, and tee_thread writes them into
any file saved with -o, polluting the saved perf.data when later viewed
with perf timechart or perf timechart --tui.
[ ... ]
> + def control_loop(self) -> None:
> + """Send queued commands, otherwise pings that flush perf record's buffers."""
> + try:
> + while not self.stopping.is_set():
> + try:
> + cmd = self.commands.get(timeout=self.flush_interval)
> + except queue.Empty:
> + if self.paused:
> + continue
> + cmd = "ping"
> + if not cmd or self.stopping.is_set() or not self.send(cmd):
> + break
> + finally:
> + self.close_control_fds()
> +
> + def set_paused(self, paused: bool) -> None:
> + """Pause or resume recording."""
> + self.paused = paused
> + self.commands.put("disable" if paused else "enable")
[Severity: Medium]
When pausing recording or running in --power-only mode, can the latest
round of events remain buffered in perf.session's ordered_events queue
without being delivered to TimechartData.process_event()?
In tools/perf/util/ordered-events.c:__ordered_events__flush(), handling
OE_FLUSH__ROUND (on PERF_RECORD_FINISHED_ROUND) calls do_flush() up to the
previous oe->next_flush before advancing oe->next_flush = oe->max_timestamp:
err = do_flush(oe, show_progress);
if (!err) {
if (how == OE_FLUSH__ROUND)
oe->next_flush = oe->max_timestamp;
oe->last_flush_type = how;
}
TimechartApp.tune_refresh() sets self.live.flush_interval =
self.refresh_interval / 2 so that two pings occur per refresh. However, in
tools/perf/builtin-record.c:record__mmap_read_evlist(), perf record only
writes finished_round_event when new bytes were actually read from the
ring buffers during that pass:
if (!record__threads_enabled(rec) && bytes_written != rec->bytes_written)
rc = record__write(rec, NULL, &finished_round_event, sizeof(finished_round_event));
When LiveRecorder.set_paused(True) queues "disable" and stops sending
"ping" in LiveRecorder.control_loop(), perf record disables the evlist,
drains the final pre-pause events in one pass, and writes only one
PERF_RECORD_FINISHED_ROUND (which flushes the prior round and sets
oe->next_flush). The next pass reads 0 bytes from the disabled ring buffers
and emits no second PERF_RECORD_FINISHED_ROUND, leaving the final round of
pre-pause events buffered in ordered_events while paused. The same happens
in --power-only mode when a subsequent ping pass sees no new power events.
[ ... ]
> + def stop(self) -> None:
> + """Stop recording, perf record then writes any remaining data and exits.
> +
> + Called from both the UI thread and the thread processing the events,
> + so they don't both signal, reap and close perf record's pipes at once.
> + """
> + self.stopping.set()
> + self.commands.put("")
> + with self.stop_lock:
> + self._stop()
> +
> + def _stop(self) -> None:
> + """Stop perf record and the threads, stop_lock must be held."""
> + proc = self.proc
> + if proc is not None and proc.poll() is None:
> + try:
> + proc.send_signal(signal.SIGINT)
> + proc.wait(timeout=1)
> + except subprocess.TimeoutExpired:
> + # Probably blocked writing data that is no longer processed.
> + proc.kill()
> + proc.wait()
> + if self.tee_thread and self.tee_thread is not threading.current_thread():
> + self.tee_thread.join(timeout=1)
> + if self.control_thread and self.control_thread is not threading.current_thread():
> + self.control_thread.join(timeout=1)
> + self.close_control_fds()
> + self.close_data_fds()
> +
> + def close_data_fds(self) -> None:
> + """Close the read ends of the data pipes, stop_lock must be held.
> +
> + perf.data() duplicates the file descriptor it reads from, so they
> + aren't needed once processing has started. Closing them means writes
> + to them fail, rather than block, once they are no longer read.
> + """
> + self.data_fd = -1
> + tee_r, self.tee_r = self.tee_r, -1
> + if tee_r >= 0:
> + try:
> + os.close(tee_r)
> + except OSError:
> + pass
> + # Unless the tee thread is still reading from it.
> + if self.proc and self.proc.stdout and not (self.tee_thread and
> + self.tee_thread.is_alive()):
> + self.proc.stdout.close()
[Severity: Medium]
When quitting live mode, can TimechartApp.cancel_loading() and
LiveRecorder._stop() cause perf record to block on pipe writes, get killed
with SIGKILL, and leave the workload child process running as an orphan?
When quitting, TimechartApp.cancel_loading() sets loading.cancelled = True
before calling self.live.stop():
loading = self.loading
if loading is not None:
loading.cancelled = True
if self.live:
self.live.stop()
On the worker thread, TimechartData.process_event() raises LoadCancelled at
the next PROGRESS_INTERVAL (1000 samples), causing read_events() to exit
and close perf.data's duplicated pipe fd while LiveRecorder._stop() sends
SIGINT to perf record and calls proc.wait(timeout=1). However,
self.close_data_fds() is not called until the end of _stop(), so
self.proc.stdout remains open while nobody is reading from it.
In tools/perf/builtin-record.c:__cmd_record(), perf record flushes all
ring buffers to stdout before sending SIGTERM to its workload child:
record__mmap_read_all(rec, true);
goto out_free_threads;
...
if (forks) {
int exit_status;
if (!child_finished)
kill(evlist__workload_pid(rec->evlist), SIGTERM);
Once the pipe fills, perf record blocks in write(), hits
subprocess.TimeoutExpired after 1 second, and proc.kill() sends SIGKILL
only to proc.pid (even though perf record was started with
start_new_session=True), leaving any workload started after -- running
in the background. (Even if close_data_fds() were called before
proc.wait(), an early EPIPE/SIGPIPE during record__mmap_read_all() would
still terminate perf record before it reaches the kill(..., SIGTERM) call.)
--
Sashiko AI review · https://sashiko.dev/#/patchset/20261023054952.2503540-1-irogers@google.com?part=1
next prev parent reply other threads:[~2026-10-23 6:16 UTC|newest]
Thread overview: 52+ 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 [this message]
2026-10-23 5:49 ` [PATCH v5 2/2] perf test: Test perf timechart --live Ian Rogers
2026-10-23 6:01 ` 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-182337@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.