Linux Perf Users
 help / color / mirror / Atom feed
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

  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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox