Skip to content

Speed up logcat_processor file scanning and polling loops - #1057

Open
xpconanfan wants to merge 3 commits into
masterfrom
eff-logcat-processor-perf
Open

xpconanfan wants to merge 3 commits into
masterfrom
eff-logcat-processor-perf

Conversation

@xpconanfan

@xpconanfan xpconanfan commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator
  • _iter_lines: read in binary and track byte offsets by summing line lengths instead of two text-mode f.tell() calls per line (offsets stay compatible with tail()).
  • Normalise filter criteria once per query (_LineFilter compiles the pattern / builds level sets) instead of once per line; LogLine.matches() keeps its signature and delegates to it. Memoize _parse_timestamp so since= comparisons don't re-parse the bound per line.
  • listen() / wait_for() keep one file handle open (_LineReader) instead of re-opening the file every poll.
  • Fix a race in listen(): the start offset was snapshotted inside the listener thread, so a line appended right after listen() returned could be missed. Take it in __enter__ before the thread starts.

200k-line log, best of 3: _iter_lines 1.84s -> 0.71s, get_lines(pattern, since) 2.94s -> 1.24s.

Adds logcat_processor_test.py (offsets with multi-byte/CRLF/invalid UTF-8, tail vs scan consistency, filter equivalence, listen/wait_for).

Also fixes a latent bug in the polling paths: adb logcat output is block-buffered, so wait_for()/listen() could observe a half-written line and match neither fragment. Polling readers now defer a trailing line without a newline to the next poll; one-shot get_lines() is unchanged.

Behavior-preserving efficiency pass over
mobly/controllers/android_device_lib/logcat_processor.py:

* `_iter_lines` now reads the file in binary mode and derives byte offsets
  by summing raw line lengths instead of calling text-mode `f.tell()` twice
  per line (an opaque cookie computation that dominated scan time). Lines
  are decoded as UTF-8 with `errors='replace'` exactly as before, and the
  resulting offsets are the same byte offsets `tail()` already computes, so
  positions from forward scans and `tail()` remain interchangeable.
* Filter criteria are normalised once per query instead of once per line:
  a new internal `_LineFilter` compiles the pattern and builds the level
  sets a single time, and `_TimestampCutoff` pre-parses the `since`
  timestamp. `LogLine.matches()` keeps its public signature and now
  delegates to `_LineFilter`, so matching semantics are unchanged.
* `LogcatListenerContext._listen_loop` and `wait_for()` keep a single file
  handle open for the lifetime of the listen/wait (via a new internal
  `_LineReader`) instead of re-opening the file every 50ms/100ms. The
  reader lazily opens a file that does not exist yet, re-tries after I/O
  errors, and is always closed through a context manager so handles are
  not held past the wait. Truncation behaves as before (reads stall until
  the file grows past the saved offset). In-order `wait_for` reuses the
  same reader across patterns.
* `_parse_timestamp` uses precompiled split regexes.

No public API changes. The old in-order timeout message semantics
(reporting remaining budget) are kept as-is.

Micro-benchmark (Python 3.13, 200k-line / 12.8 MB logcat file, best of 3,
master's module loaded side-by-side via `git show origin/master:...`):

    _iter_lines (full scan)                  2.394s -> 0.709s  (3.4x)
    get_lines(level=[E,F], tag=[...])        2.467s -> 0.738s  (3.3x)
    get_lines(pattern, since=<timestamp>)    3.505s -> 1.150s  (3.0x)
    tail(num_lines=50000, level=E)           0.860s -> 0.800s

Adds tests/mobly/controllers/android_device_lib/logcat_processor_test.py
covering: filter/cutoff equivalence against the public API, byte offsets
with multi-byte characters, CRLF endings and invalid UTF-8, tail vs
forward-scan offset consistency, reader handle reuse / lazy open, and
listen/wait_for behaviour including files created after the wait starts.
All behavioural tests in the new file also pass against the master
implementation.
* `_iter_lines`: read in binary and track byte offsets by summing line lengths instead of two text-mode `f.tell()` calls per line (offsets stay compatible with `tail()`).
* Normalise filter criteria once per query (`_LineFilter` compiles the pattern / builds level sets) instead of once per line; `LogLine.matches()` keeps its signature and delegates to it. Memoize `_parse_timestamp` so `since=` comparisons don't re-parse the bound per line.
* `listen()` / `wait_for()` keep one file handle open (`_LineReader`) instead of re-opening the file every poll.
* Fix a race in `listen()`: the start offset was snapshotted inside the listener thread, so a line appended right after `listen()` returned could be missed. Take it in `__enter__` before the thread starts.

200k-line log, best of 3: `_iter_lines` 1.84s -> 0.71s, `get_lines(pattern, since)` 2.94s -> 1.24s.

Adds `logcat_processor_test.py` (offsets with multi-byte/CRLF/invalid UTF-8, tail vs scan consistency, filter equivalence, listen/wait_for).
@xpconanfan
xpconanfan force-pushed the eff-logcat-processor-perf branch from f25cd3b to 41cc778 Compare October 6, 2026 20:41
adb logcat output is block-buffered, so wait_for()/listen() can observe a
line whose tail has not been flushed yet. Consuming it split one log line
into two fragments that neither matched the pattern. Polling readers now
leave a trailing line without a newline for the next poll; one-shot
get_lines() is unchanged.
@xpconanfan
xpconanfan requested a review from xianyuanjia October 8, 2026 01:46
@xpconanfan xpconanfan self-assigned this Oct 8, 2026
@xpconanfan xpconanfan added this to the Mobly Release 1.14 milestone Oct 8, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant