# profiler-cli walkthrough: 1Password infinite recursion in WebExtensions process Companion to `macos-extensions-hang-infinite-recursion.md`. Reproduces the same findings using `profiler-cli` with annotated output. Profile: https://profiler.firefox.com/public/jek6z1sbxtybk77ptwk79yna774r6ab6g2t8n6g --- ## Load the profile and get an overview ``` profiler-cli load https://profiler.firefox.com/public/jek6z1sbxtybk77ptwk79yna774r6ab6g2t8n6g profiler-cli profile info ``` ``` Name: Firefox 143 – macOS 15.5.0 Platform: macOS 15.5.0 This profile contains 14 threads across 9 processes. Top processes and threads by CPU usage: p-8: WebExtensions [pid 39769] [ts-A → end] - 4054.658ms ← nearly all CPU t-13: GeckoMain [tid 48838071] - 4054.658ms p-0: Parent Process [pid 39753] [ts<0z → end] - 1565.397ms t-0: GeckoMain [tid 48837837] - 1061.705ms t-1: Renderer [tid 48837905] - 460.349ms ... ``` The story is immediate: `p-8: WebExtensions` has 4054ms of CPU in a 5.46-second profile. Every other process is negligible. `t-13` is the only thread in that process and holds all of it. --- ## Check what the WebExtensions thread is doing ``` profiler-cli thread select t-13 profiler-cli thread samples ``` ``` Thread: WebExtensions Note: active samples only (idle excluded) — use --include-idle to include idle samples. Top Functions (by total time): (For a call tree starting from these functions, use: profiler-cli thread samples-top-down) f-0. (root) - total: 4440 (100.0%) f-13684. XRE_InitChildProcess - total: 4440 (100.0%) f-1347. js::RunScript - total: 4299 (96.8%) f-18033. resource://gre/modules/ExtensionChild.sys.mjs!recvRunListener - total: 4172 (94.0%) f-18034. resource://gre/modules/ExtensionChild.sys.mjs!fire - total: 4172 (94.0%) f-18035. Extension "1Password – Password Manager"!yRj - total: 4172 (94.0%) f-18053. Extension "1Password – Password Manager"!Kq/this.isEnabled - total: 4171 (93.9%) f-18054. Extension "1Password – Password Manager"!jj - total: 4171 (93.9%) f-18055. Extension "1Password – Password Manager"!getItem - total: 4171 (93.9%) f-18056. Extension "1Password – Password Manager"!getItem - total: 4171 (93.9%) ← duplicate! f-18057. Extension "1Password – Password Manager"!RjA - total: 4171 (93.9%) f-18058. Extension "1Password – Password Manager"!vj - total: 4171 (93.9%) f-18059. Extension "1Password – Password Manager"!YG - total: 4171 (93.9%) f-18126. Extension "1Password – Password Manager"!og - total: 4053 (91.3%) f-18135. Extension "1Password – Password Manager"!setItem - total: 4052 (91.3%) f-18136. Extension "1Password – Password Manager"!setItem - total: 4052 (91.3%) ← duplicate! f-11091. XUL!js::CaptureStack - total: 3265 (73.5%) f-3941. XUL!JS::CaptureCurrentStack - total: 3263 (73.5%) f-2832. js::SavedStacks::saveCurrentStack - total: 3261 (73.4%) ... f-18755. XUL!js::jit::CheckOverRecursedImpl - total: 1494 (33.6%) f-18757. XUL!js::ReportOverRecursed - total: 1481 (33.4%) Top Functions (by self time): (For a call tree showing what calls these functions, use: profiler-cli thread samples-bottom-up) f-18800. XUL!js::jit::CompactBufferReader::readByte - self: 178 (4.0%) f-11094. XUL!js::SavedStacks::getOrCreateSavedFrame - self: 122 (2.7%) f-18870. XUL!mozilla::detail::EntrySlot>::matchHash - self: 112 (2.5%) f-18144. Extension "1Password – Password Manager"!mLA - self: 101 (2.3%) ... ``` Two things stand out immediately: 1. `getItem` appears **twice** in the total-time list (f-18055 and f-18056), and so does `setItem` (f-18135 and f-18136). The same function name at multiple stack depths is a classic sign of recursion. 2. `js::SavedStacks::saveCurrentStack` at 73% and `js::ReportOverRecursed` at 33% are the JS engine's stack-capture and over-recursion-reporting machinery. The engine is hitting the recursion depth limit repeatedly and throwing errors, each of which captures a full JS stack trace. The self-time breakdown is also telling: the top self-time functions are all deep inside `saveCurrentStack` (hash table lookups, JIT frame iteration). No 1Password JS code appears in self time at all, confirming the extension itself is not doing any useful work. --- ## Confirm the infinite recursion with the top-down tree ``` profiler-cli thread samples-top-down --max-lines 40 ``` ``` Top-Down Call Tree: f-0. (root) [total: 100.0%, self: 0.0%] ├─ f-13684. XRE_InitChildProcess [total: 93.8%, self: 0.0%] │ f-8290. Task PWindowGlobal::Msg_RawMessage [total: 93.8%, self: 0.0%] │ f-8291. PWindowGlobal::Msg_RawMessage [total: 93.8%, self: 0.0%] │ f-7526. JSActor message handler [total: 93.8%, self: 0.0%] │ f-7529. JSActor receive message [total: 93.8%, self: 0.0%] │ f-1347. js::RunScript [total: 93.8%, self: 0.0%] │ f-1347. js::RunScript [total: 93.8%, self: 0.0%] │ f-18033. recvRunListener [total: 93.8%, self: 0.0%] │ f-18034. fire [total: 93.8%, self: 0.0%] │ f-18035. 1Password!yRj [total: 93.8%, self: 0.0%] │ f-18053. 1Password!Kq/this.isEnabled [total: 93.8%, self: 0.0%] │ f-18054. 1Password!jj [total: 93.8%, self: 0.0%] │ f-18055. 1Password!getItem [total: 93.8%, self: 0.0%] │ f-18056. 1Password!getItem [total: 93.8%, self: 0.0%] │ f-18057. 1Password!RjA [total: 93.8%, self: 0.0%] │ f-18058. 1Password!vj [total: 93.8%, self: 0.0%] │ f-18059. 1Password!YG [total: 93.8%, self: 0.0%] │ f-18054. 1Password!jj [total: 93.8%, self: 0.0%] ← same as above │ f-18055. 1Password!getItem [total: 93.8%, self: 0.0%] │ f-18056. 1Password!getItem [total: 93.8%, self: 0.0%] │ f-18057. 1Password!RjA [total: 93.8%, self: 0.0%] │ f-18058. 1Password!vj [total: 93.8%, self: 0.0%] │ f-18059. 1Password!YG [total: 93.8%, self: 0.0%] │ f-18054. 1Password!jj [total: 93.8%, self: 0.0%] ← and again │ ... ``` The recursion cycle (`jj → getItem → getItem → RjA → vj → YG → og → setItem → setItem`) repeats at 93.8% at every level. Note `og` and `setItem` appear at 91.3% rather than 93.9% because a small fraction of stacks are captured mid-cycle before reaching that branch. The profiler captured stacks at many different recursion depths, and the top-down tree displays each unique depth as a separate node, which is why the same functions appear over and over at the same percentage. --- ## Isolate the recursion with a filter To strip away the engine entry path and focus only on the `isEnabled` subtree, use `filter push --root-at`: ``` profiler-cli filter push --root-at f-18053 profiler-cli thread samples-top-down --max-lines 25 ``` ``` Filters: [1] root-at: f-18053 Top-Down Call Tree: f-18053. 1Password!Kq/this.isEnabled [total: 100.0%, self: 0.0%] └─ f-18054. 1Password!jj [total: 100.0%, self: 0.0%] f-18055. 1Password!getItem [total: 100.0%, self: 0.0%] f-18056. 1Password!getItem [total: 100.0%, self: 0.0%] f-18057. 1Password!RjA [total: 100.0%, self: 0.0%] f-18058. 1Password!vj [total: 100.0%, self: 0.0%] f-18059. 1Password!YG [total: 100.0%, self: 0.0%] f-18054. 1Password!jj [total: 100.0%, self: 0.0%] ← cycle repeats f-18055. 1Password!getItem [total: 100.0%, self: 0.0%] f-18056. 1Password!getItem [total: 100.0%, self: 0.0%] f-18057. 1Password!RjA [total: 100.0%, self: 0.0%] f-18058. 1Password!vj [total: 100.0%, self: 0.0%] f-18059. 1Password!YG [total: 100.0%, self: 0.0%] f-18054. 1Password!jj [total: 100.0%, self: 0.0%] ← and again ... ``` With the engine frames gone, 100% of all samples are inside the `isEnabled` call, and every single sample is the same recurring cycle. This is the clearest possible view of the recursion. ``` profiler-cli filter clear ``` --- ## GC pressure from the error flood ``` profiler-cli thread markers --category "GC / CC" --min-duration 50 ``` ``` Markers in thread t-13 (WebExtensions) — 8 markers By Name: GCMajor 8 markers (interval: min=55.30ms, avg=65.15ms, max=74.39ms) Examples: m-21 (74.39ms), m-22 (73.79ms), m-23 (71.79ms) ``` ``` profiler-cli thread markers --category "GC / CC" ``` ``` By Name: Parallel marking ran 1334 markers Parallel marking wait 1303 markers GCSlice 59 markers (avg=2.73ms) GCMinor 55 markers (avg=2.84ms) GCMajor 8 markers (avg=65.15ms) ``` Every recursive stack overflow creates a new Error object with a full JS stack trace attached. The JS heap fills rapidly with these objects. The GC response: 8 major GCs averaging 65ms each, 59 incremental slices, and 55 minor GCs. Combined that is roughly 850ms of garbage collection in 5.46 seconds, about 15% of total recording time. ``` profiler-cli thread markers --search "Jank" ``` ``` By Name: Jank 1 marker (4.17s) Examples: m-20 (4.17s at 1.29s - 5.46s) ``` The Jank marker confirms the hang started at 1.29s and lasted until the end of the profile, 4.17 seconds. --- ## What the engine is doing about it The 73% time in `js::SavedStacks::saveCurrentStack` explains why the CPU is so high despite the JS doing no useful work. Each time the recursion hits the depth limit, the engine: 1. Detects over-recursion via `js::jit::CheckOverRecursedImpl` 2. Calls `js::ReportOverRecursed` and throws an error 3. Captures the full JS stack trace for the Error object (`js::SavedStacks::saveCurrentStack`) The stack at this point is thousands of frames deep, so capturing it is expensive. Since the recursion immediately restarts after the error is caught, this happens continuously, producing 100% CPU with no forward progress. The bottom-up tree confirms this: the top self-time function is `CompactBufferReader::readByte` inside the JIT frame iterator, which is what `saveCurrentStack` calls as it walks the stack. None of the 1Password JS functions appear in self time at all. --- ``` profiler-cli stop ```