Skip to content

feat(callstack): restore call stack instrument (FF150 JSM->ESM port) - #1178

Open
vringar wants to merge 7 commits into
masterfrom
feat/restore-callstack-instrument
Open

feat(callstack): restore call stack instrument (FF150 JSM->ESM port)#1178
vringar wants to merge 7 commits into
masterfrom
feat/restore-callstack-instrument

Conversation

@vringar

@vringar vringar commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores the callstack_instrument (disabled since #557), which captures the
JavaScript call stack responsible for each HTTP request and writes it to the
callstacks table. It had been disabled because the privileged stackDump
WebExtension Experiment no longer loaded on modern Firefox. This is primarily a
JSM→ESM port plus the actor‑registration and module‑loading fixes those changes
require — the underlying capture mechanism (the same one that powers the DevTools
"Initiator" column) is intact. It also wires the async‑initiator path so
fetch/XHR/WebSocket/worker stacks are now captured (closes #1177).

What broke (Firefox 150) — four distinct issues

  1. Legacy JSM modulesEXPORTED_SYMBOLS + ChromeUtils.import(...jsm) were
    removed in Firefox 136 (Bug 1881888). Ported OpenWPMStackDump{Child,Parent}.jsm
    .sys.mjs (ESM).
  2. XPCOMUtils.defineLazyServiceGetter for resProto threw on FF150 — replaced
    with Services.io.getProtocolHandler.
  3. registerWindowActor now requires esModuleURI (not moduleURI).
  4. Content‑process actor reachability — requires Services.ppmm.sharedData.flush()
    and a content‑readable module path (see sandbox note below).

How it captures

Synchronous, script‑initiated requests. A JSWindowActor child observes
http-on-opening-request in the content process, walks Components.stack, and
serializes frames as name@file:line:col;asyncCause; the parent maps
channelId → ChannelWrapper.id (== the webRequest requestId) and re‑broadcasts
via the openwpm-stacktrace observer; callstack-instrument.ts writes to the
callstacks table.

Asynchronous initiators (fetch/XHR/WebSocket/worker) — closes #1177. These
open their channel off the JS stack, so Components.stack in the content process
is empty. Firefox instead captures the initiator stack at request time, serializes
it (SerializedStackHolder, converted to a plain SavedFrame object and
JSON‑stringified), and delivers it in the parent process via the
network-monitor-alternate-stack observer notification (subject = the request
channel, data = the JSON stack). The parent actor:

  • enables capture by setting watchedByDevTools on the top BrowsingContext
    in actorCreated — the same gecko flag the DevTools network monitor relies on;
    the C++ fetch/XHR/WebSocket paths all gate origin‑stack capture on it, and on
    FF150 these stacks are otherwise never captured without an attached client. The
    only other documented side effect is HTML‑content reporting, benign for
    measurement;
  • observes network-monitor-alternate-stack, JSON.parses the stack, walks
    parent/asyncParent links (preserving asyncCause), and reformats each frame
    into the same name@file:line:col;asyncCause string the sync path produces,
    resolving the requestId straight from the channel subject via
    ChannelWrapper.get(channel).id.

Both observers are registered once as guarded module‑global singletons (extending
the existing ensureObserverRegistered() over OBSERVED_TOPICS), so they are not
appended per page over a long crawl.

Module loading & the content sandbox

The privileged child ES module must be readable by the content process, which the
content sandbox blocks for geckodriver's temp‑installed xpi. Rather than relaxing
the sandbox, this installs the extension non‑temporarily into the profile's
extensions/ directory
— a path the Linux sandbox broker grants read‑only
unconditionally — so the content sandbox stays fully enabled at its default
level
: no security.sandbox.content.level change and no path whitelist. The
profile‑sideload prefs (extensions.startupScanScopes, extensions.autoDisableScopes)
and the xpi copy are applied unconditionally to every crawl, not gated on
callstack_instrument: the extension is now installed this way for all crawls
(its experiment APIs load on a non‑privileged profile sideload), so the install
path is uniform regardless of which instruments are enabled.

Detectability

callstack_instrument requires js_instrument (the legacy, already‑detectable
instrument). The resource://openwpm substitution is registered without
ALLOW_CONTENT_ACCESS, so page script cannot fetch() it (verified: returns
NetworkError) — it does not add a new content‑observable artifact.

Tests

  • test/test_callstack_instrument.py::test_http_stacktrace now asserts three
    non‑vacuous captured initiator stacks against real Firefox 150: the sync
    <script>‑initiated request, plus an async fetch‑initiated and an async
    XHR‑initiated request (the two alternate‑stack paths). Each asserts a specific
    captured frame chain and fails if that path's capture breaks.
  • test/test_pages/http_stacktrace.html exercises all three mechanisms; assets are
    local for deterministic, offline runs.

Docs

The detailed callstack mechanism (privileged JSWindowActor, profile extensions/
install, capture scope, the alternate‑stack path) now lives in the
callstack_instrument section of docs/Configuration.md; AGENTS.md keeps only a
short pointer.

Closes #557. Closes #1177.

Copilot AI review requested due to automatic review settings June 11, 2026 01:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores the callstack_instrument on modern Firefox (FF150) by porting the legacy stackDump WebExtension Experiment machinery from JSM to system ESM, updating window-actor registration/loading semantics, and adjusting Firefox deployment so the content sandbox can read the privileged child actor module. It also re-enables and stabilizes the callstack test by localizing assets and narrowing assertions to the supported (script-initiated) scope.

Changes:

  • Port stackDump actors to .sys.mjs (ESM) and update actor registration (esModuleURI, sharedData.flush(), modern resource:// handler acquisition).
  • Install the extension non-temporarily into the Firefox profile extensions/ dir when callstack_instrument is enabled (to satisfy the content sandbox), and skip geckodriver’s temporary install in that case.
  • Reactivate and de-flake test_http_stacktrace by using local test assets and asserting a deterministic script-initiator stack; update docs/agent notes accordingly.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/test_pages/shared/inject_pixel.js New local script asset used to make the initiating script request deterministic/offline.
test/test_pages/http_stacktrace.html Removes remote gist dependency and simplifies page to only exercise script-initiated stack capture.
test/test_callstack_instrument.py Re-enables the callstack test and updates expectations for the script-initiated stack only.
openwpm/deploy_browsers/deploy_firefox.py Adds profile-sideload install path + prefs when callstack capture is enabled; avoids double-install.
openwpm/config.py Removes the unconditional “broken” guard so callstack_instrument can run again (still requires js_instrument).
Extension/eslint.config.mjs Adds globals config for privileged .sys.mjs system ES modules.
Extension/bundled/privileged/stackDump/OpenWPMStackDumpParent.sys.mjs Parent actor ported to ESM; adds one-time observer registration.
Extension/bundled/privileged/stackDump/OpenWPMStackDumpChild.sys.mjs Child actor ported to ESM; improves teardown/navigation robustness and event-based instantiation.
Extension/bundled/privileged/stackDump/api.js Updates resProto acquisition, registers window actor via esModuleURI, flushes sharedData, adds clearer failure reporting.
docs/Configuration.md Updates callstack_instrument docs for restored behavior, limitations, and deployment implications.
AGENTS.md Updates “Known Issues” to reflect restored instrument + limitations and deployment behavior.
Comments suppressed due to low confidence (2)

openwpm/config.py:257

  • The ConfigError message is now misleading: callstack_instrument is restored, so saying it "currently doesn't work" (and pointing to #557) is inaccurate. The error should simply explain that callstack_instrument requires js_instrument to be enabled.
        if browser_params.callstack_instrument and not browser_params.js_instrument:
            raise ConfigError(
                "The callstacks instrument currently doesn't work without "
                "the JS instrument enabled. see: "
                "https://github.com/openwpm/OpenWPM/issues/557"
            )

Extension/bundled/privileged/stackDump/OpenWPMStackDumpParent.sys.mjs:94

  • receiveMessage waits indefinitely for gChannelMap to contain channelId. If the matching *-on-opening-request is missed (or the child sends a channelId that never gets recorded), this async method will spin forever and can accumulate stuck tasks. Add a bounded timeout and bail (optionally logging) to prevent an infinite wait.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread AGENTS.md Outdated
@codecov

codecov Bot commented Jun 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.48%. Comparing base (25a7827) to head (f573005).

Files with missing lines Patch % Lines
openwpm/config.py 93.02% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1178      +/-   ##
==========================================
+ Coverage   62.36%   62.48%   +0.11%     
==========================================
  Files          40       40              
  Lines        3930     3974      +44     
==========================================
+ Hits         2451     2483      +32     
- Misses       1479     1491      +12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@vringar
vringar force-pushed the feat/restore-callstack-instrument branch 3 times, most recently from 0257f32 to 7044c59 Compare June 11, 2026 08:51
@vringar
vringar requested a review from Copilot June 11, 2026 10:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

openwpm/config.py:257

  • validate_browser_params still raises a ConfigError that says the callstack instrument "currently doesn't work" without js_instrument. The instrument is being restored in this PR, so this message is misleading; it should state the actual requirement (callstack_instrument requires js_instrument) without implying the feature is broken.
            raise ConfigError(
                "The callstacks instrument currently doesn't work without "
                "the JS instrument enabled. see: "
                "https://github.com/openwpm/OpenWPM/issues/557"
            )

Comment thread docs/Configuration.md
Comment thread Extension/bundled/privileged/stackDump/OpenWPMStackDumpParent.sys.mjs Outdated
Comment thread openwpm/deploy_browsers/deploy_firefox.py

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment thread openwpm/deploy_browsers/deploy_firefox.py
@vringar
vringar force-pushed the feat/restore-callstack-instrument branch 8 times, most recently from 77a972d to 21dcb67 Compare June 18, 2026 00:07
vringar added 5 commits July 20, 2026 22:37
OpenWPM requires its sideloaded web extension to collect any data. A user
pref in browser_params.prefs could override the extension-load prefs at
browser launch, silently disabling the extension and all instrumentation.

Validate the extension-load-critical prefs at config-validation time (before
any browser launches) and raise a hard ConfigError naming the offending pref:
  - extensions.startupScanScopes must include SCOPE_PROFILE (bit 1)
  - extensions.autoDisableScopes must NOT include SCOPE_PROFILE (bit 1)
  - xpinstall.signatures.required must be falsy
Pref values are coerced robustly from int/string forms.
@vringar
vringar force-pushed the feat/restore-callstack-instrument branch from 857689a to d2d54d5 Compare July 20, 2026 22:54
vringar added 2 commits July 21, 2026 11:01
…e race

The sync-path stack capture dropped every row on Firefox 152: the parent
actor's receiveMessage waits asynchronously for the channel-tracking
observer to record the request's channel, but on a fast (localhost)
connection http-on-examine-response fired and deleted the entry within a
single 10ms poll gap -- before the async wait loop ever observed it. The
consumer then spun to its deadline and dropped the stack, leaving the
callstacks table empty. FF152 tightened the open-request -> examine-response
timing enough that this race, latent on FF150, now always loses.

Resolve the WebRequest requestId eagerly at http-on-opening-request (while
the channel is alive) and retain it in a bounded FIFO map keyed by
channelId, instead of storing the channel object and deleting it at
examine-response. receiveMessage then reads the requestId by channelId,
waiting briefly only for it to appear. This removes the record-then-delete
race entirely; the map is capped to keep memory bounded over long crawls.
The async (network-monitor-alternate-stack) path is unchanged -- it already
resolves the requestId directly from the notification's channel subject.
…age script

The parent actor set watchedByDevTools in actorCreated, but a JSWindowActorParent
is instantiated lazily -- only when the child sends its first *sync*
script-initiated stack. Firefox gates the network-monitor-alternate-stack
initiator stack (fetch/XHR/WebSocket) on that flag at request time, so any async
request that precedes the first sync child message was captured with no initiator
stack. In the limit -- a page whose first subresource is an async fetch -- the
first page of every browser under-captured non-deterministically.

Set the flag in the parent-process observer at document-on-opening-request
instead: the top-level document channel opens in the parent process before the
content process creates the window or runs any page script, so watchedByDevTools
is replicated to content before the page's first async request fires. Race-free.
The actorCreated call is kept as an idempotent defense-in-depth fallback.
@vringar
vringar force-pushed the feat/restore-callstack-instrument branch from d2d54d5 to f573005 Compare July 21, 2026 11:05
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.

callstack_instrument: capture fetch/XHR/websocket initiators via network-monitor-alternate-stack Narrow down issues with callstack tests

2 participants