Skip to content

Trace this Mac live - #52

Merged
doronz88 merged 11 commits into
mainfrom
host-live-tracing
Oct 20, 2026
Merged

doronz88 merged 11 commits into
mainfrom
host-live-tracing

Conversation

@doronz88

@doronz88 doronz88 commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Adds live kdebug tracing of the Mac you run it on, decoded by pykdebugparser's handlers (which decode more than fs_usage/ktrace do):

sudo pykdebugparser live -sf 0x40c
2026-10-20 05:09:37.686500 ls(52516)                         fstatat64(4294967294, "private/tmp", 0x16f25d2a0, 0)
2026-10-20 05:09:37.686522 ls(52516)                         open_nocancel("private/tmp", O_RDONLY | O_NONBLOCK | O_CLOEXEC), fd: 4
Read 634970 events, the kernel dropped events 0 times

How

  • host_trace.HostTrace drives the kernel trace facility through the kern.kdebug sysctl (KERN_KDSETBUF/KDSETUP/KDSET_TYPEFILTER/KDPIDEX/KDENABLE/KDREADTR/KDTHRMAP/KDREMOVE), the same way sc_usage does. It needs root but no entitlement, and no stackshot: thread->process names come from KERN_KDTHRMAP. The MIB numbers and the kd_buf/kd_threadmap/kd_regtype/kbufinfo_t layouts were checked against XNU's bsd/sys/kdebug.h, kdebug_private.h and bsd/kern/kdebug.c.
  • The records are presented as a version 2 trace stream (header, threadmap, events), so PyKdebugParser parses them like any dump. Timestamps use the local mach_timebase_info, mach_absolute_time and timezone.
  • -cf/-sf go to the kernel typefilter, so the kernel drops the unwanted events (VFS_LOOKUP is added for BSD syscalls, so their paths are named). The tracer's own pid is excluded.
  • The facility is global, so it is always disabled and removed on exit: Ctrl-C, SIGTERM/SIGHUP, -c, and errors (including errors during setup). EBUSY (ktrace, fs_usage or Instruments is using it) and EPERM print a short message.

Commits

  1. dyld: an unknown string id (named before a live trace started) raised KeyError and ended the trace. Now it shows empty, as the image path already did.
  2. trace: forget the ongoing calls of a terminated thread. Those calls never end. This runs only on TRACE_DATA_THREAD_TERMINATE.
  3. traces_parser: live mode (TracesParser(..., live=True) / PyKdebugParser.live, off by default). A live host trace grew by hundreds of MB per minute: when the kernel drops events, a call whose end was dropped keeps every later event of its thread. The live mode forgets a call after MAX_ONGOING_EVENTS (4096) events. It also forgets calls ongoing since before the previous gap whenever the kernel reports dropped events (the kernel's TRACE_LOST_EVENTS marks the point after which the stream has no gaps).
  4. traces_parser: in live mode, forget dyld's strings and images when their process exits or execs, released strings soon after they are released (1024 kept: about 4% of lookups refer to a string after it was released), and terminated threads. Images are now kept per pid ({pid: {addr: path}}), so this cleanup is O(1).
  5. host_trace: the source, the live command, tests with a fake sysctl, and the README.
  6. trace_codes: pymobiledevice3's syscall groups (SYSCALL_GROUPS, group_mach_traps), event_eventids and syscall_names, so both tools can share them.
  7. pykdebugparser: when syscalls are selected by event id, drop a DBG_FSYSTEM kevent right after decoding if its thread has no ongoing call. Such a lookup can't belong to a selected call, and was only parsed to be dropped as a trace of its own. The output is byte-identical on recorded Mac and iPhone traces (--bsc, --fs, -s open, live and not); a rare syscall (-s connect) under a busy find parses 2.1M -> 3.1M events/s.
  8. host_trace: parse-live, with pymobiledevice3's parse-live options: --bsc, -s, -e, the groups (--fs, --net, --proc, --io, --mem, --signal, --cred, --sync, --mach), --dyld, --json, --color-mode, plus syscalls [--group] and events to list the names. The kernel filters by subclass at best, so the rest is dropped right after decoding. Output is written in batches and flushed when the trace waits for events. HostTrace gains max_lag, skip_behind, on_skip, on_wait and TraceLagError. On exit it prints whether it kept up: Read N events, the kernel dropped events N times, N events were skipped to catch up (pymobiledevice3 prints the same line).
  9. main: every command has a --help description.
  10. host_trace: falling more than --max-lag (2s) behind fails by default, saying how to skip ahead instead (--skip-behind), allow more lag or narrow the trace.
  11. pyproject: the version comes from the release's git tag via setuptools-scm (as in pymobiledevice3), so releasing is just creating the GitHub release vX.Y.Z, with no bump commit. The publish workflow checks out the full history so the tag is found. A clone tagged v1.7.0 builds pykdebugparser-1.7.0 (wheel and sdist).

No change for iOS (pymobiledevice3 parse-live)

The live-mode logic is in separate methods and handlers that are only installed when live=True. pymobiledevice3 doesn't set it, so it runs the same per-event code as before. Outside live mode, the only shared-path changes are the two handlers in commits 1-2 (they run only on rare events) and the per-pid dyld_images (only on dyld image events).

iPhone 11, iOS 27.2, USB, developer dvt core-profile-session parse-live --bsc. Each run was 60 s, with system apps launched in a loop. Sessions ran one at a time.

  • base = PyPI 1.6.4 in the checkout's env.
  • branch = this branch in the same env (PYTHONPATH).
  • branch (uv overlay) = uv run --with-editable.

Traces/s follows how many apps got launched in the window, so traces per launch is shown too.

run traces/s launches traces/launch skip warnings peak RSS
base 51,084 20 2,554 4 185 MB
base 62,211 24 2,592 0 184 MB
base 69,190 28 2,471 0 179 MB
base (uv overlay) 74,284 29 2,561 0 182 MB
branch 73,740 29 2,543 0 192 MB
branch 69,899 28 2,496 0 172 MB
branch 72,458 29 2,499 0 171 MB
branch 74,224 29 2,559 0 180 MB
branch 74,675 30 2,489 0 186 MB
branch 75,769 30 2,526 0 184 MB
branch (uv overlay) 70,445 29 2,429 3 164 MB
branch (uv overlay) 74,130 28 2,648 0 174 MB
idle device, base 4,694 / 4,681 0 - 0 114 MB
idle device, final branch 4,619 / 4,802 0 - 0 113-114 MB

How far the last trace was behind the wall clock at stop was the same in every run (a constant offset between this phone's clock and the Mac's in the harness). Skip warnings show up in both base and branch runs (4 and 3). An offline replay of a recorded 750 MB iPhone trace (--bsc filters, alternating runs) gives the same CPU time within noise: base 5.74-6.65 s, branch 5.64-6.36 s.

Host keep-up (M-series Mac, 30 s each, find / -xdev running in the background)

filter events read/s traces printed/s kernel drop gaps RSS (every 5 s)
none 253k 109k 226 49 50 50 51 51 MB
none, 120 s 215k 86k 815 50 -> 52 MB, flat
-sf 0x40c (BSD syscalls) 198k 79k 152 44 44 44 44 44 MB
-cf 0x1f (dyld, ls spawned 20x/s), 90 s 26k 10k 0 38 -> 45 MB, flat from 25 s

Before commits 3-4 the unfiltered trace grew 56 -> 397 MB in 120 s, and the dyld one 44 -> 123 MB in 30 s.
The find load makes ~200k syscalls/s, more than the decoder keeps up with, so the kernel drops events there and gaps are marked with TRACE_LOST_EVENTS. Narrower filters keep up (0 gaps). ktrace info shows the facility as found after every run ("Kernel buffer is not initialized", "Last configured by 'tailspind'").

parse-live keep-up (M-series Mac, 20 s each, output to /dev/null)

run events read/s kernel drop gaps skipped
normal use, --bsc 121k 0 0
normal use, --bsc --json 123k 0 0
normal use, --fs --net --proc 135k 0 0
normal use, -s open 125k 0 0
find load (1.2M events/s), -s connect 1.21M 0 0
find load, --net 1.29M 0 0
find load, -s open 0.90M 343 0

Under the find load, find alone opens ~112k directories/s, which is more traces than the parser prints; live -sf 0x40c drops the same way there. Offline, --bsc with rich colors parses ~386k events/s of a normal-use recording.

Tests: 235 pass on 3.9, 3.14 and 3.15, and each commit passes on its own. CI's flake8 is clean on the changed files.

A live trace starts after dyld named some of the strings its events refer to, and an unknown string id raised a KeyError that ended the trace. Show them empty, as the image path already does.
The calls a thread didn't end when it terminated never will, and each kept every later event of its thread.
… flat

Every event of a thread is kept for each of its ongoing calls, and a call that never ends (one whose end the kernel dropped, or one that never returns such as a run loop run) grows without bound on a long live trace. The live mode forgets a call once it kept MAX_ONGOING_EVENTS events, and when the kernel drops events, the calls ongoing since before the previous gap. It uses separate methods, so other traces run the same code as before.
… mode

dyld names a string for each image it maps, and a trace with dyld events kept every string and image of every process it ever saw. In live mode, they are forgotten when their process exits (or execs), and so are the strings a short while after they are released, and the threads when they terminate. The images are kept by pid, so a process forgets its own at once.
Add the `live` command, and HostTrace for Python: they drive the kernel trace facility through the kdebug sysctl, which only requires root (no entitlement), and present it as a version 2 trace, named by the kernel's threadmap. Class and subclass filters are applied by the kernel's typefilter, so the kernel drops the unwanted events, and tracing is always disabled and removed on exit (Ctrl-C, SIGTERM, errors).
Add the syscall groups of pymobiledevice3's parse-live (`SYSCALL_GROUPS`, with their Mach traps from `group_mach_traps`), `event_eventids` to select events by trace code name and `syscall_names` to list the names `syscall_eventids` accepts, so both tools share them.
When syscalls are selected by event id, DBG_FSYSTEM is only traced for the paths of the selected calls, yet every lookup of every syscall was fed to the traces parser and then dropped as a trace of its own. Drop a DBG_FSYSTEM kevent right after decoding when its thread has no ongoing call: it can't be part of one, and an end event of such a thread has no start to match anyway. The output is the same; selecting a rare syscall under a busy `find` parses 2.1M -> 3.1M events/s.
…device3's

`parse-live` traces this Mac with pymobiledevice3's parse-live options: `--bsc`, `-s`/`--syscall`, `-e`/`--event`, the syscall groups (`--fs`, `--net`, ...), `--dyld`, `--json` and `--color-mode`. The kernel filters by subclass at best, so it traces the subclasses of the selected syscalls and events, and the rest are dropped right after decoding. `syscalls` and `events` list the names they accept.

To keep up, the output is written in batches and flushed whenever the trace waits for events, and the reads whose newest event is more than `--max-lag` (2s) old are skipped (`HostTrace(max_lag=...)`), or fail with `TraceLagError` with `--no-skip-behind`. On exit it prints whether it kept up: the events read, the kernel's drops, and the skipped events.
Falling more than --max-lag behind now fails parse-live, telling how to skip ahead instead (--skip-behind), allow more lag or narrow the trace, so a trace never silently misses events.
…s-scm

Releasing no longer needs a version bump commit: creating the GitHub release (tag vX.Y.Z) builds that version. setuptools-scm writes pykdebugparser/_version.py at build time, as pymobiledevice3 does, and the publish workflow checks out the full history so the tag is found.
@doronz88
doronz88 merged commit 475e5c1 into main Oct 20, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant