diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index e07d501aa6..b59d5fe0e3 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,10 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 65 + +The stack table's `frame` column (stored at `profile.shared.stackTable.frame`) can now optionally be stored as an `Int32Array`, for profiles loaded from [JsonSlabs](https://github.com/mstange/json-slabs/) files (.jslb, .jslb.gz). Regular JS / JSON arrays are still accepted. + ### Version 64 A new `SourceLocationTable` has been added to `profile.shared.sourceLocationTable`. It holds the original (pre-compilation) source positions produced by source map symbolication, paired with the generated `line`/`column` already on `FrameTable`. diff --git a/locales/de/app.ftl b/locales/de/app.ftl index 85f964073f..178ac76272 100644 --- a/locales/de/app.ftl +++ b/locales/de/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Die Profile konnten nicht abgerufen werden. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = Aufgrund einer spezifischen Einschränkung in Safari kann { -profiler-brand-name } Profile vom lokalen Computer nicht in diesem Browser importieren. Bitte öffnen Sie diese Seite stattdessen in { -firefox-brand-name } oder Chrome. .title = Safari kann lokale Profile nicht importieren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dieses Profil verwendet ein Format, das von dieser Version von { -profiler-brand-name } nicht unterstützt wird. + Versuchen Sie, die Seite zu aktualisieren, um zu überprüfen, ob ein Update für { -profiler-brand-name } verfügbar ist. AppViewRouter--route-not-found--home = .specialMessage = Die URL, die Sie erreichen wollten, wurde nicht erkannt. diff --git a/locales/el/app.ftl b/locales/el/app.ftl index 5f879431ee..d3df514dce 100644 --- a/locales/el/app.ftl +++ b/locales/el/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = εισαγάγει προφίλ από τη συσκευή σε αυτό το πρόγραμμα περιήγησης. Ανοίξτε αυτήν τη σελίδα στο { -firefox-brand-name } ή το Chrome. .title = Το Safari δεν μπορεί να εισαγάγει τοπικά προφίλ +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Αυτό το προφίλ χρησιμοποιεί μια μορφή που δεν υποστηρίζεται από αυτήν την έκδοση του { -profiler-brand-name }. + Δοκιμάστε να ανανεώσετε τη σελίδα για να ελέγξετε αν διατίθεται ενημέρωση για το { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Δεν αναγνωρίστηκε το URL που προσπαθήσατε να μεταβείτε. @@ -894,6 +899,10 @@ TrackMemoryGraph--relative-memory-at-this-time2 = { $value } # $value (String) - the memory range across the graph (e.g. "5MB") TrackMemoryGraph--memory-range-in-graph2 = { $value } .label = εύρος μνήμης στο γράφημα +# Variables: +# $value (String) - count of allocations and deallocations since the previous sample +TrackMemoryGraph--allocations-and-deallocations-since-the-previous-sample2 = { $value } + .label = εκχωρήσεις και αποδεσμεύσεις από το προηγούμενο δείγμα ## TrackProcessCPUGraph ## This is used to show the CPU usage of a process over time in the timeline. @@ -948,6 +957,11 @@ TrackPower--tooltip-average-power-watt = { $value } W # $value (String) - the power value at this location TrackPower--tooltip-average-power-milliwatt = { $value } mW .label = Μέση ισχύς στην τρέχουσα επιλογή +# This is used in the tooltip when the power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-average-power-microwatt = { $value } μW + .label = Μέση ισχύς στην τρέχουσα επιλογή # This is used in the tooltip when the energy used in the current range uses the # kilowatt-hour unit. # Variables: diff --git a/locales/en-GB/app.ftl b/locales/en-GB/app.ftl index 7ea46c3eba..e40039d84c 100644 --- a/locales/en-GB/app.ftl +++ b/locales/en-GB/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = to import profiles from the local machine in this browser. Please open this page in { -firefox-brand-name } or Chrome instead. .title = Safari cannot import local profiles +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + This profile uses a format that is not supported by this version of { -profiler-brand-name }. + Try refreshing the page to check if there is an update available for { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = The URL you tried to reach was not recognised. diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 5625f479b5..86409569e7 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -54,6 +54,12 @@ AppViewRouter--error-from-localhost-url-safari = this page in { -firefox-brand-name } or Chrome instead. .title = Safari cannot import local profiles +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + This profile uses a format that is not supported by this version of { -profiler-brand-name }. + Try refreshing the page to check if there is an update available for { -profiler-brand-name }. + AppViewRouter--route-not-found--home = .specialMessage = The URL you tried to reach was not recognized. diff --git a/locales/es-CL/app.ftl b/locales/es-CL/app.ftl index f14e73b5ff..7591ba2890 100644 --- a/locales/es-CL/app.ftl +++ b/locales/es-CL/app.ftl @@ -46,6 +46,12 @@ AppViewRouter--error-compare = No se pudieron recuperar los perfiles. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = Debido a una limitación específica en Safari, { -profiler-brand-name } no puede importar perfiles de la máquina local en este navegador. Por favor, abre esta página en { -firefox-brand-name } o Chrome en su lugar. .title = Safari no puede importar perfiles locales +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Este perfil utiliza un formato no compatible con esta versión de { -profiler-brand-name }. + + Intenta actualizar la página para comprobar si hay alguna actualización disponible para { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = La URL a la que intentaste acceder no fue reconocida. diff --git a/locales/fr/app.ftl b/locales/fr/app.ftl index e96cdee9a3..4ac9c5e908 100644 --- a/locales/fr/app.ftl +++ b/locales/fr/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Impossible de récupérer les profils. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = En raison d’une limitation spécifique à Safari, { -profiler-brand-name } ne peut pas importer de profils depuis la machine locale dans ce navigateur. Veuillez ouvrir cette page dans { -firefox-brand-name } ou Chrome à la place. .title = Safari ne peut pas importer de profils locaux +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Ce profil utilise un format qui n’est pas pris en charge par cette version de { -profiler-brand-name }. + Essayez d’actualiser la page pour vérifier si une mise à jour est disponible pour { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = L’URL que vous avez tenté d’atteindre n’a pas été trouvée diff --git a/locales/fur/app.ftl b/locales/fur/app.ftl index 2f3c985016..3389b02751 100644 --- a/locales/fur/app.ftl +++ b/locales/fur/app.ftl @@ -811,6 +811,32 @@ TrackNameButton--hide-track = TrackNameButton--hide-process = .title = Plate procès +## TrackMemoryGraph +## This is used to show the memory graph of that process in the timeline part of +## the UI. To learn more about it, visit: +## https://profiler.firefox.com/docs/#/./memory-allocations?id=memory-track + +# Variables: +# $value (String) - the relative memory at this time (e.g. "5MB") +TrackMemoryGraph--relative-memory-at-this-time2 = { $value } + .label = memorie relative a chest pont +# Variables: +# $value (String) - the memory range across the graph (e.g. "5MB") +TrackMemoryGraph--memory-range-in-graph2 = { $value } + .label = interval di memorie tal grafic +# Variables: +# $value (String) - count of allocations and deallocations since the previous sample +TrackMemoryGraph--allocations-and-deallocations-since-the-previous-sample2 = { $value } + .label = assegnazion e disassegnazion dal campion di prime + +## TrackProcessCPUGraph +## This is used to show the CPU usage of a process over time in the timeline. + +# Variables: +# $value (String) - the CPU usage at this sample (e.g. "50%") +TrackProcessCPUGraph--cpu = { $value } + .label = CPU + ## TrackPower ## This is used to show the power used by the CPU and other chips in a computer, ## graphed over time. @@ -856,6 +882,11 @@ TrackPower--tooltip-average-power-watt = { $value } W # $value (String) - the power value at this location TrackPower--tooltip-average-power-milliwatt = { $value } mW .label = Consum medi te selezion atuâl +# This is used in the tooltip when the power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-average-power-microwatt = { $value } μW + .label = Consum medi te selezion corinte # This is used in the tooltip when the energy used in the current range uses the # kilowatt-hour unit. # Variables: diff --git a/locales/fy-NL/app.ftl b/locales/fy-NL/app.ftl index addadbbaa5..2fbae9169a 100644 --- a/locales/fy-NL/app.ftl +++ b/locales/fy-NL/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = profilen fan de lokale kompjûter yn dizze browser ymportearje. Iepenje yn stee dêrfan dizze side yn { -firefox-brand-name } of Chrome. .title = Safari kan geen lokale profielen importeren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dit profyl brûkt in yndieling dy’t net troch dizze ferzje fan { -profiler-brand-name } stipe wurdt. + Probearje de side te ferfarskjen om te kontrolearjen oft der in fernijing beskikber is foar { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = De URL dy’t jo probearre te berikken, waard net werkend. diff --git a/locales/ia/app.ftl b/locales/ia/app.ftl index 119fd5d9b8..fefc4679d5 100644 --- a/locales/ia/app.ftl +++ b/locales/ia/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = importar profilos ab le local apparato in iste browser. Per favor aperi in vice iste pagina in { -firefox-brand-name } o Chrome. .title = Safari non pote importar profilos local +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Iste profilo usa un formato que non es supportate per iste version de { -profiler-brand-name }. + Essaya refrescar le pagina pro verificar si il ha un actualisation disponibile pro { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Le URL que tu tentava attinger non ha essite recognoscite. diff --git a/locales/it/app.ftl b/locales/it/app.ftl index 09ba75b1cd..29b5ef0bc8 100644 --- a/locales/it/app.ftl +++ b/locales/it/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = Impossibile recuperare i profili. # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = A causa di una limitazione specifica di Safari, { -profiler-brand-name } non può importare profili dal dispositivo locale in questo browser. Aprire questa pagina in { -firefox-brand-name } o Chrome. .title = Impossibile importare profili locali in Safari +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Questo profilo utilizza un formato non compatibile con questa versione di { -profiler-brand-name }. + Ricarica la pagina per verificare se è disponibile un aggiornamento per { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = L’URL che hai cercato di raggiungere non è stato riconosciuto. diff --git a/locales/nl/app.ftl b/locales/nl/app.ftl index ca9147c060..57a9fd737e 100644 --- a/locales/nl/app.ftl +++ b/locales/nl/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = profielen van de lokale computer in deze browser importeren. Open in plaats daarvan deze pagina in { -firefox-brand-name } of Chrome. .title = Safari kan geen lokale profielen importeren +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Dit profiel gebruikt een indeling die niet door deze versie van { -profiler-brand-name } wordt ondersteund. + Probeer de pagina te vernieuwen om te controleren of er een update beschikbaar is voor { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = De URL die u probeerde te bereiken, werd niet herkend. diff --git a/locales/ru/app.ftl b/locales/ru/app.ftl index 70f9a7633c..c30a2ab90b 100644 --- a/locales/ru/app.ftl +++ b/locales/ru/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = импортировать профили с локальной машины в этот браузер. Пожалуйста, откройте эту страницу в { -firefox-brand-name } или Chrome. .title = Safari не может импортировать локальные профили +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Этот профиль использует формат, который не поддерживается этой версией { -profiler-brand-name }. + Попробуйте обновить страницу, чтобы проверить, доступно ли обновление для { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = URL-адрес, который вы пытались открыть, не был распознан. diff --git a/locales/sv-SE/app.ftl b/locales/sv-SE/app.ftl index 79e9a8fd44..1799a97736 100644 --- a/locales/sv-SE/app.ftl +++ b/locales/sv-SE/app.ftl @@ -49,6 +49,11 @@ AppViewRouter--error-from-localhost-url-safari = { -profiler-brand-name } importera profiler från den lokala datorn i den här webbläsaren. Öppna den här sidan i { -firefox-brand-name } eller Chrome istället. .title = Safari kan inte importera lokala profiler +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + Den här profilen använder ett format som inte stöds av den här versionen av { -profiler-brand-name }. + Testa att uppdatera sidan för att kontrollera om det finns en tillgänglig uppdatering för { -profiler-brand-name }. AppViewRouter--route-not-found--home = .specialMessage = Webbadressen du försökte nå kändes inte igen. diff --git a/locales/zh-TW/app.ftl b/locales/zh-TW/app.ftl index bc16bcecaf..6f3fb97161 100644 --- a/locales/zh-TW/app.ftl +++ b/locales/zh-TW/app.ftl @@ -46,6 +46,11 @@ AppViewRouter--error-compare = 無法取得效能檢測檔。 # https://profiler.firefox.com/from-url/http%3A%2F%2F127.0.0.1%3A3000%2Fprofile.json/ AppViewRouter--error-from-localhost-url-safari = 由於 Safari 的特殊限制,{ -profiler-brand-name } 無法從這套瀏覽器自本機匯入效能檢測檔。請改用 { -firefox-brand-name } 或 Chrome 開啟此頁面。 .title = 無法使用 Safari 匯入本機效能檢測檔 +# This error message is displayed when the profile is in a newer format version +# than this build of the Profiler is able to read. +AppViewRouter--error-profile-version = + 此版本的 { -profiler-brand-name } 不支援此效能檢測檔使用的格式。 + 請嘗試重新整理頁面,看看是否有 { -profiler-brand-name } 的更新可以使用。 AppViewRouter--route-not-found--home = .specialMessage = 無法處理您嘗試開啟的網址。 diff --git a/package.json b/package.json index b0f2b85b7d..96e20725f8 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "@codemirror/lang-rust": "^6.0.2", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.43.0", + "@codemirror/view": "^6.43.1", "@firefox-devtools/react-contextmenu": "^5.2.4", "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", @@ -100,7 +100,7 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.6.3", + "protobufjs": "^8.6.5", "query-string": "^9.4.0", "react": "~19.1.8", "react-dom": "~19.1.8", @@ -132,7 +132,7 @@ "@types/jest": "^30.0.0", "@types/node": "^24.13.2", "@types/query-string": "^6.3.0", - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/redux-logger": "^3.0.6", "@types/tgwf__co2": "^0.14.2", @@ -143,7 +143,7 @@ "babel-plugin-module-resolver": "^5.0.3", "browserslist": "^4.28.2", "browserslist-to-esbuild": "^2.1.1", - "caniuse-lite": "^1.0.30001793", + "caniuse-lite": "^1.0.30001799", "cross-env": "^10.1.0", "cross-spawn": "^7.0.6", "devtools-license-check": "^0.9.0", @@ -169,12 +169,12 @@ "lockfile-lint": "^5.0.0", "npm-run-all2": "^8.0.4", "open": "^11.0.0", - "oxfmt": "^0.51.0", + "oxfmt": "^0.56.0", "patch-package": "^8.0.1", "postcss": "^8.5.15", "postinstall-postinstall": "^2.1.0", "rimraf": "^6.1.3", - "stylelint": "^17.12.0", + "stylelint": "^17.13.0", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-standard": "^40.0.0", "typescript": "^6.0.3", diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index bc1cca6bfb..65cef7e2eb 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -117,6 +117,7 @@ HANDLE SYSTEM t-0, t-1 Thread handles (from "profile info") m-1234 Marker handles (from "thread markers") f-12 Function handles (from "thread samples", "thread functions") + c-0, c-1 Counter handles (from "counter list" or "profile info") ts-6 Timestamp handles (named points in time, usable with "zoom push") Handle lifetime and stability: @@ -127,6 +128,8 @@ HANDLE SYSTEM m-N thread markers No -- rebuilt each time the daemon starts f-N thread samples, Yes -- direct index into the profile's function thread functions table; same profile always yields the same f-N + c-N counter list Yes -- direct index into the profile's counter + array; same profile always yields the same c-N ts-N thread markers No -- position-based, session-scoped ────────────────────────────────────────────────────────────────────────── @@ -215,6 +218,31 @@ FILTERS profiler-cli filter push --during-marker --search Paint +COUNTERS + + Counters are time series the profiler records alongside samples: memory usage, + network bandwidth, process CPU, power, and similar. Each counter has a handle + (c-0, c-1, ...) and carries its own display metadata (label, unit, graph type). + + profiler-cli counter list List all counters with one-line summaries + profiler-cli counter info c-0 Detailed info and stats for one counter + + Counters also appear in "profile info", listed under their owning process + next to that process's threads (much like the timeline track list). + + The stats shown come from the counter's own tooltip schema, so they match the + timeline tooltips. Each counter reports its whole-range aggregates, e.g. the + memory range for Memory, data transferred for Bandwidth, or energy used (with a + CO2e estimate) for Power. + + All counter stats respect the current zoom: with no zoom they cover the whole + profile; after "zoom push" they cover the committed range. Combine with zoom to + see, for example, how much memory a specific time window allocated: + + profiler-cli zoom push 2.7,3.1 + profiler-cli counter info c-0 + + JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq diff --git a/profiler-cli/package.json b/profiler-cli/package.json index 5d528a63d7..e5dd4fbae9 100644 --- a/profiler-cli/package.json +++ b/profiler-cli/package.json @@ -1,6 +1,6 @@ { "name": "@firefox-devtools/profiler-cli", - "version": "0.2.1", + "version": "0.3.0", "description": "Command-line interface for querying Firefox Profiler profiles with persistent daemon sessions", "scripts": { "prepublishOnly": "node ../scripts/verify-profiler-cli-build.mjs" diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 2a13b1389f..602560a352 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -19,12 +19,39 @@ profiler-cli profile info --json processes: [{ pid, name, cpuMs, threads: [{ threadHandle, threadIndex, name, tid, cpuMs }], - remainingThreads?: { count, combinedCpuMs, maxCpuMs } + remainingThreads?: { count, combinedCpuMs, maxCpuMs }, + counters?: [CounterSummary] }], remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, context: SessionContext } +CounterSummary: + { + counterHandle, counterIndex, name, label, category, + unit, graphType, + color, pid, mainThreadIndex, mainThreadHandle, mainThreadName, + rangeSampleCount, + stats: [{ source, label, value, formattedValue, carbon? }] + } + +profiler-cli counter list --json + { + type: "counter-list", + counters: [CounterSummary], + context: SessionContext + } + +profiler-cli counter info --json + { + type: "counter-info", + ...CounterSummary, + description, + sampleCount, + rangeStart, rangeEnd, + context: SessionContext + } + profiler-cli thread samples --json { type: "thread-samples", diff --git a/profiler-cli/src/commands/counter.ts b/profiler-cli/src/commands/counter.ts new file mode 100644 index 0000000000..15a8e5d9a5 --- /dev/null +++ b/profiler-cli/src/commands/counter.ts @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * `profiler-cli counter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerCounterCommand( + program: Command, + sessionDir: string +): void { + const counter = program + .command('counter') + .description('Counter-level commands'); + + addGlobalOptions( + counter + .command('list') + .description('List all counters with one-line summaries') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'list' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + counter + .command('info [handle]') + .description('Show detailed information about a counter (e.g. c-0)') + .option('--counter ', 'Counter handle') + ).action(async (handleArg: string | undefined, opts) => { + const counterHandle = handleArg ?? opts.counter; + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'info', counter: counterHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/constants.ts b/profiler-cli/src/constants.ts index 3050cbaaac..d8be3144d0 100644 --- a/profiler-cli/src/constants.ts +++ b/profiler-cli/src/constants.ts @@ -8,6 +8,7 @@ // These globals are defined via esbuild's define option. declare const __BUILD_HASH__: string; +declare const __PACKAGE_NAME__: string; declare const __VERSION__: string; /** @@ -16,6 +17,11 @@ declare const __VERSION__: string; */ export const BUILD_HASH = __BUILD_HASH__; +/** + * Package name from profiler-cli/package.json, injected at build time. + */ +export const PACKAGE_NAME = __PACKAGE_NAME__; + /** * Package version from profiler-cli/package.json, injected at build time. */ diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 992b827f62..bc2375de9b 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -11,6 +11,7 @@ import * as net from 'net'; import * as fs from 'fs'; import { ProfileQuerier } from '../../src/profile-query'; import type { LoadPhase } from '../../src/profile-query/loader'; +import { ProfileVersionError } from 'firefox-profiler/profile-logic/errors'; import type { ClientCommand, ClientMessage, @@ -28,7 +29,27 @@ import { ensureSessionDir, } from './session'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; -import { BUILD_HASH } from './constants'; +import { BUILD_HASH, PACKAGE_NAME } from './constants'; + +/** + * Build a user-facing message for a profile load failure. When the profile is + * too new for this build, append instructions on how to update the CLI. + */ +function formatProfileLoadError(error: unknown): string { + if ( + error instanceof ProfileVersionError || + (error instanceof Error && error.name === 'ProfileVersionError') + ) { + const versionError = error as ProfileVersionError; + return ( + `This profile is version ${versionError.profileVersion}, but this profiler-cli only ` + + `supports up to version ${versionError.supportedVersion} of the ${versionError.formatName} profile format.\n` + + `Update to the latest version with:\n` + + ` npm install -g ${PACKAGE_NAME}@latest` + ); + } + return error instanceof Error ? error.message : String(error); +} export class Daemon { private querier: ProfileQuerier | null = null; @@ -41,7 +62,7 @@ export class Daemon { private profilePath: string; private symbolServerUrl?: string; private loadPhase: LoadPhase = 'fetching'; - private profileLoadError: Error | null = null; + private profileLoadError: string | null = null; constructor( sessionDir: string, @@ -149,8 +170,7 @@ export class Daemon { console.log('Profile loaded successfully'); } catch (error) { console.error(`Failed to load profile: ${error}`); - this.profileLoadError = - error instanceof Error ? error : new Error(String(error)); + this.profileLoadError = formatProfileLoadError(error); } } @@ -210,7 +230,7 @@ export class Daemon { if (this.profileLoadError) { return { type: 'error', - error: `Profile load failed: ${this.profileLoadError.message}`, + error: `Profile load failed: ${this.profileLoadError}`, }; } switch (this.loadPhase) { @@ -245,7 +265,7 @@ export class Daemon { if (this.profileLoadError) { return { type: 'error', - error: `Profile load failed: ${this.profileLoadError.message}`, + error: `Profile load failed: ${this.profileLoadError}`, }; } if (this.loadPhase !== 'ready' || !this.querier) { @@ -360,6 +380,18 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'counter': + switch (command.subcommand) { + case 'list': + return this.querier!.counterList(); + case 'info': + if (!command.counter) { + throw new Error('counter handle required for counter info'); + } + return this.querier!.counterInfo(command.counter); + default: + throw assertExhaustiveCheck(command); + } case 'sample': switch (command.subcommand) { case 'info': diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index f62da72cd5..6dee2303bc 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -35,6 +35,9 @@ import type { SampleFilterSpec, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from './protocol'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; @@ -428,6 +431,10 @@ Name: ${result.name}\n`; if (process.remainingThreads) { output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; } + + for (const counter of process.counters ?? []) { + output += ` ${counter.counterHandle}: ${counter.label}${formatCounterStats(counter)}\n`; + } } if (result.remainingProcesses) { @@ -451,6 +458,85 @@ Name: ${result.name}\n`; return output; } +function formatCounterStatInline( + stat: CounterSummary['stats'][number] +): string { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + return `${stat.label}: ${value}`; +} + +/** The ` - stat; stat [N samples]` trailer shared by counter list and profile info. */ +function formatCounterStats(counter: CounterSummary): string { + const stats = + counter.stats.length > 0 + ? ` - ${counter.stats.map(formatCounterStatInline).join('; ')}` + : ''; + return `${stats} [${counter.rangeSampleCount} samples]`; +} + +function formatCounterSummaryLine(counter: CounterSummary): string { + return ` ${counter.counterHandle}: ${counter.label} (${counter.category})${formatCounterStats(counter)}`; +} + +/** + * Format a CounterListResult as plain text. + */ +export function formatCounterListResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + if (result.counters.length === 0) { + return `${contextHeader}\n\nNo counters in this profile.`; + } + const lines = result.counters.map(formatCounterSummaryLine); + return `${contextHeader}\n\nCounters (${result.counters.length}):\n${lines.join('\n')}`; +} + +/** + * Format a CounterInfoResult as plain text. + */ +export function formatCounterInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines = [ + contextHeader, + '', + `Counter ${result.counterHandle}: ${result.label}`, + ` Name: ${result.name}`, + ` Category: ${result.category}`, + ]; + if (result.description) { + lines.push(` Description: ${result.description}`); + } + lines.push(` Unit: ${result.unit || '(none)'}`); + lines.push(` Graph type: ${result.graphType}`); + lines.push( + ` Main thread: ${result.mainThreadHandle} (${result.mainThreadName})` + ); + lines.push( + ` Samples: ${result.sampleCount} total, ${result.rangeSampleCount} in current range` + ); + if (result.rangeStart !== null && result.rangeEnd !== null) { + const zeroAt = result.context.rootRange.start; + lines.push( + ` Time span: ${formatDuration(result.rangeStart - zeroAt)} → ${formatDuration(result.rangeEnd - zeroAt)}` + ); + } + if (result.stats.length > 0) { + lines.push(' Stats (current range):'); + for (const stat of result.stats) { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + lines.push(` ${stat.label}: ${value}`); + } + } + return lines.join('\n'); +} + /** * Helper function to format a call tree node recursively. * diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 05b7a640a7..c637516b9b 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -37,6 +37,7 @@ import { registerProfileCommand } from './commands/profile'; import { registerThreadCommand } from './commands/thread'; import { registerMarkerCommand } from './commands/marker'; import { registerFunctionCommand } from './commands/function'; +import { registerCounterCommand } from './commands/counter'; import { registerZoomCommand } from './commands/zoom'; import { registerFilterCommand } from './commands/filter'; import { registerSessionCommand } from './commands/session'; @@ -85,6 +86,8 @@ Examples: profiler-cli thread samples profiler-cli thread functions --search GC --min-self 1 profiler-cli thread markers --search DOMEvent --category Graphics + profiler-cli counter list + profiler-cli counter info c-0 profiler-cli zoom push 2.7,3.1 profiler-cli filter push --excludes-function f-184 profiler-cli status @@ -180,6 +183,7 @@ Examples: registerThreadCommand(program, SESSION_DIR); registerMarkerCommand(program, SESSION_DIR); registerFunctionCommand(program, SESSION_DIR); + registerCounterCommand(program, SESSION_DIR); registerZoomCommand(program, SESSION_DIR); registerFilterCommand(program, SESSION_DIR); registerSessionCommand(program, SESSION_DIR); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 36d8fc6345..a983f136cb 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -28,6 +28,8 @@ import { formatProfileLogsResult, formatThreadPageLoadResult, formatThreadSelectResult, + formatCounterListResult, + formatCounterInfoResult, } from './formatters'; /** @@ -88,6 +90,10 @@ export function formatOutput( return formatThreadPageLoadResult(result); case 'thread-select': return formatThreadSelectResult(result); + case 'counter-list': + return formatCounterListResult(result); + case 'counter-info': + return formatCounterInfoResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 330dfe479f..3e817f9b43 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -50,6 +50,9 @@ export type { ProfileInfoResult, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -79,6 +82,8 @@ import type { FilterStackResult, ProfileLogsResult, ThreadSelectResult, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -141,6 +146,11 @@ export type ClientCommand = subcommand: 'info' | 'select' | 'stack'; marker?: string; } + | { + command: 'counter'; + subcommand: 'list' | 'info'; + counter?: string; + } | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } | { command: 'function'; @@ -195,7 +205,9 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts new file mode 100644 index 0000000000..a9b7ca0958 --- /dev/null +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + formatCounterListResult, + formatCounterInfoResult, +} from '../../formatters'; +import type { + CounterSummary, + CounterListResult, + CounterInfoResult, + SessionContext, + WithContext, +} from 'firefox-profiler/profile-query/types'; + +function createContext(): SessionContext { + return { + selectedThreadHandle: 't-0', + selectedThreads: [{ threadIndex: 0, name: 'GeckoMain' }], + currentViewRange: null, + rootRange: { start: 0, end: 3000 }, + }; +} + +function makeCounter(overrides: Partial = {}): CounterSummary { + return { + counterHandle: 'c-0', + counterIndex: 0, + name: 'malloc', + label: 'Memory', + category: 'Memory', + unit: 'bytes', + graphType: 'line-accumulated', + color: 'orange', + pid: '123', + mainThreadIndex: 0, + mainThreadHandle: 't-0', + mainThreadName: 'GeckoMain', + rangeSampleCount: 7, + stats: [ + { + source: 'count-range', + label: 'memory range in graph', + value: 27, + formattedValue: '27B', + }, + ], + ...overrides, + }; +} + +describe('formatCounterListResult', function () { + it('renders one line per counter with its stats', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [ + makeCounter(), + makeCounter({ + counterHandle: 'c-1', + name: 'eth0', + label: 'Bandwidth', + category: 'Bandwidth', + graphType: 'line-rate', + stats: [ + { + source: 'count-range', + label: 'Data transferred in the visible range', + value: 2048, + formattedValue: '2KB', + }, + ], + }), + ], + }; + + const output = formatCounterListResult(result); + expect(output).toContain('Counters (2):'); + expect(output).toContain('c-0: Memory (Memory)'); + expect(output).toContain('memory range in graph: 27B'); + expect(output).toContain('[7 samples]'); + expect(output).toContain('c-1: Bandwidth (Bandwidth)'); + expect(output).toContain('Data transferred in the visible range: 2KB'); + }); + + it('reports when there are no counters', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [], + }; + + expect(formatCounterListResult(result)).toContain( + 'No counters in this profile.' + ); + }); +}); + +describe('formatCounterInfoResult', function () { + function makeInfo( + overrides: Partial = {} + ): WithContext { + return { + context: createContext(), + ...makeCounter(), + type: 'counter-info', + description: 'Amount of allocated memory', + sampleCount: 7, + rangeStart: 0, + rangeEnd: 10, + ...overrides, + }; + } + + it('renders the counter detail block', function () { + const output = formatCounterInfoResult(makeInfo()); + expect(output).toContain('Counter c-0: Memory'); + expect(output).toContain('Name: malloc'); + expect(output).toContain('Category: Memory'); + expect(output).toContain('Unit: bytes'); + expect(output).toContain('Graph type: line-accumulated'); + expect(output).toContain('Main thread: t-0 (GeckoMain)'); + expect(output).toContain('Description: Amount of allocated memory'); + expect(output).toContain('memory range in graph: 27B'); + }); + + it('shows a CO2e estimate next to a stat when present', function () { + const output = formatCounterInfoResult( + makeInfo({ + label: 'Power', + category: 'power', + unit: 'pWh', + graphType: 'line-rate', + stats: [ + { + source: 'committed-range-total', + label: 'Energy used in the visible range', + value: 5, + formattedValue: '5 Wh', + carbon: '2 g CO₂e', + }, + ], + }) + ); + expect(output).toContain( + 'Energy used in the visible range: 5 Wh (2 g CO₂e)' + ); + }); +}); diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs index 9e90b69c28..a8f8775c44 100644 --- a/scripts/build-profiler-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -5,7 +5,7 @@ import esbuild from 'esbuild'; import { chmodSync, readFileSync } from 'fs'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; -const { version } = JSON.parse( +const { name, version } = JSON.parse( readFileSync(new URL('../profiler-cli/package.json', import.meta.url), 'utf8') ); @@ -22,6 +22,7 @@ const profilerCliConfig = { }, define: { __BUILD_HASH__: JSON.stringify(BUILD_HASH), + __PACKAGE_NAME__: JSON.stringify(name), __VERSION__: JSON.stringify(version), // SOURCE_MAP_WORKER_PATH is injected by the browser build. The CLI doesn't // use source map workers but the shared code references this constant. diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 51ff3dcdb4..0c51a7a172 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2015,6 +2015,7 @@ export function toggleBottomBoxFullscreen(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -2023,7 +2024,6 @@ export function handleCallNodeTransformShortcut( } const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); const unfilteredThread = threadSelectors.getThread(getState()); - const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); const implementation = getImplementationFilter(getState()); const inverted = getInvertCallstack(getState()); const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); @@ -2051,6 +2051,15 @@ export function handleCallNodeTransformShortcut( }) ); break; + case 'S': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }) + ); + break; case 'M': dispatch( addTransformToStack(threadsKey, { diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index 6cba739b59..8d39768163 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -12,7 +12,7 @@ export const GECKO_PROFILE_VERSION = 34; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 64; +export const PROCESSED_PROFILE_VERSION = 65; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. diff --git a/src/components/app/AppViewRouter.tsx b/src/components/app/AppViewRouter.tsx index fc04a205ff..8c025eab8b 100644 --- a/src/components/app/AppViewRouter.tsx +++ b/src/components/app/AppViewRouter.tsx @@ -87,6 +87,9 @@ class AppViewRouterImpl extends PureComponent { if (view.error) { if (view.error.name === 'SafariLocalhostHTTPLoadError') { message = 'AppViewRouter--error-from-localhost-url-safari'; + } else if (view.error.name === 'ProfileVersionError') { + message = 'AppViewRouter--error-profile-version'; + additionalMessage =

{view.error.toString()}

; } else { console.error(view.error); additionalMessage = ( diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 5c24522df0..083505c802 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -6,22 +6,23 @@ contain: size; } -.DetailsContainer .resizableWithSplitterInner > * { - min-width: 0; - flex: 1; -} - .DetailsContainerResizableSidebarWrapper { max-width: 600px; } /* overriding defaults from ResizableWithSplitter.css */ -.DetailsContainer .resizableWithSplitterSplitter { +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterSplitter { border-top: 1px solid var(--panel-border-color); border-left: 1px solid var(--panel-border-color); background: var(--panel-background-color); /* Same background as sidebars */ } -.DetailsContainer .resizableWithSplitterSplitter:hover { +.DetailsContainerResizableSidebarWrapper + > .resizableWithSplitterSplitter:hover { background: var(--panel-background-color); /* same as the border above */ } + +.DetailsContainerResizableSidebarWrapper > .resizableWithSplitterInner > * { + min-width: 0; + flex: 1; +} diff --git a/src/components/app/ProfileViewer.css b/src/components/app/ProfileViewer.css index 447647b6a1..36d0c4d8e8 100644 --- a/src/components/app/ProfileViewer.css +++ b/src/components/app/ProfileViewer.css @@ -47,10 +47,7 @@ } .profileViewer { - /* Create a stacking context, so that the KeyboardShortcut can overlay the profile - viewer. */ position: relative; - z-index: 0; display: flex; min-width: 0; /* This allows Flexible Layout to shrink this further than its min-content */ flex: 1; diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 401247eee5..a86053f130 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -2,11 +2,9 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { PureComponent } from 'react'; -import memoize from 'memoize-immutable'; import explicitConnect from 'firefox-profiler/utils/connect'; import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; -import { Icon } from 'firefox-profiler/components/shared/Icon'; import { getInvertCallstack, getImplementationFilter, @@ -30,7 +28,6 @@ import { changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, } from 'firefox-profiler/actions/profile-view'; -import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import type { State, @@ -46,13 +43,10 @@ import type { import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; -import type { - Column, - MaybeResizableColumn, -} from 'firefox-profiler/components/shared/TreeView'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallTree.css'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; type StateProps = { readonly threadsKey: ThreadsKey; @@ -86,124 +80,11 @@ type DispatchProps = { type Props = ConnectedProps<{}, StateProps, DispatchProps>; class CallTreeImpl extends PureComponent { - _mainColumn: Column = { - propName: 'name', - titleL10nId: '', - }; - _appendageColumn: Column = { - propName: 'lib', - titleL10nId: '', - }; _treeView: TreeView | null = null; _takeTreeViewRef = (treeView: TreeView | null) => { this._treeView = treeView; }; - /** - * Call Trees can have different types of "weights" for the data. Choose the - * appropriate labels for the call tree based on this weight. - */ - _weightTypeToColumns = memoize( - (weightType: WeightType): MaybeResizableColumn[] => { - switch (weightType) { - case 'tracing-ms': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--tracing-ms-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--tracing-ms-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - case 'samples': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--samples-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--samples-self', - minWidth: 40, - initialWidth: 80, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - case 'bytes': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 55, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--bytes-total', - minWidth: 30, - initialWidth: 140, - resizable: true, - headerWidthAdjustment: 55 /* totalPercent initialWidth */, - }, - { - propName: 'self', - titleL10nId: 'CallTree--bytes-self', - minWidth: 40, - initialWidth: 100, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 20, - }, - ]; - default: - throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); - } - }, - // Use a Map cache, as the function only takes one argument, which is a simple string. - { cache: new Map() } - ); - override componentDidMount() { this.focus(); this.maybeProcureInterestingInitialSelection(); @@ -276,6 +157,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -284,7 +166,7 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { @@ -366,9 +248,9 @@ class CallTreeImpl extends PureComponent { return ( = { + propName: 'name', + titleL10nId: '', +}; + +export const libColumn: Column = { + propName: 'lib', + titleL10nId: '', +}; + +export const treeColumnsForTracingMs: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--tracing-ms-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForSamples: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--samples-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 40, + initialWidth: 80, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export const treeColumnsForBytes: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--bytes-total', + minWidth: 30, + initialWidth: 140, + resizable: true, + headerWidthAdjustment: 55 /* totalPercent initialWidth */, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 40, + initialWidth: 100, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 20, + }, + ]; + +export function treeColumnsForWeightType( + weightType: WeightType +): MaybeResizableColumn[] { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 1c163512b2..73dd9394da 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -70,7 +70,6 @@ export type OwnProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; - readonly isInverted: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; @@ -244,13 +243,13 @@ class FlameGraphCanvasImpl extends React.PureComponent { // Only draw the stack frames that are vertically within view. // The graph is drawn from bottom to top, in order of increasing depth. for (let depth = startDepth; depth < endDepth; depth++) { - // Get the timing information for a row of stack frames. - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { continue; } + // Get the timing information for a row of stack frames. + const stackTiming = flameGraphTiming.getRow(depth); + const cssRowTop: CssPixels = (maxStackDepthPlusOne - depth - 1) * ROW_HEIGHT - viewportTop; const cssRowBottom: CssPixels = @@ -373,10 +372,11 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const stackTiming = flameGraphTiming[depth]; - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } + + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; if (callNodeIndex === undefined) { return null; @@ -443,7 +443,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const { depth, flameGraphTimingIndex } = hoveredItem; const { flameGraphTiming } = this.props; - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.getRow(depth); const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; return callNodeIndex; } @@ -477,12 +477,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { const depth = Math.floor( maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT ); - const stackTiming = flameGraphTiming[depth]; - - if (!stackTiming) { + if (depth < 0 || depth >= flameGraphTiming.rowCount) { return null; } - + const stackTiming = flameGraphTiming.getRow(depth); for (let i = 0; i < stackTiming.length; i++) { const start = stackTiming.start[i]; const end = stackTiming.end[i]; diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx new file mode 100644 index 0000000000..8895b8812b --- /dev/null +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -0,0 +1,241 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from '../../utils/connect'; +import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { getSelectedThreadsKey } from '../../selectors/url-state'; +import { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +export type ConnectedFlameGraphHandle = FlameGraphHandle; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: typeof changeSelectedCallNode; + readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class ConnectedFlameGraphImpl + extends React.PureComponent + implements ConnectedFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via ConnectedFlameGraphHandle ref from FlameGraphViewImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const ConnectedFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + ConnectedFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + // Use the filtered call node max depth, rather than the preview filtered one, so + // that the viewport height is stable across preview selections. + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), + callTree: selectedThreadSelectors.getCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + options: { forwardRef: true }, + component: ConnectedFlameGraphImpl, +}); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..159aaa0ea5 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -3,30 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { explicitConnectWithForwardRef } from '../../utils/connect'; import { FlameGraphCanvas } from './Canvas'; - -import { - getCategories, - getCommittedRange, - getPreviewSelection, - getScrollToSelectionGeneration, - getProfileInterval, - getInnerWindowIDToPageMap, - getProfileUsesMultipleStackTypes, -} from 'firefox-profiler/selectors/profile'; -import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { - getSelectedThreadsKey, - getInvertCallstack, -} from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; -import { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, -} from 'firefox-profiler/actions/profile-view'; import { extractNonInvertedCallTreeTimings } from 'firefox-profiler/profile-logic/call-tree'; import { ensureExists } from 'firefox-profiler/utils/types'; @@ -54,8 +32,6 @@ import type { CallTreeTimings, } from 'firefox-profiler/profile-logic/call-tree'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - import './FlameGraph.css'; const STACK_FRAME_HEIGHT = 16; @@ -67,7 +43,7 @@ const STACK_FRAME_HEIGHT = 16; */ const SELECTABLE_THRESHOLD = 0.001; -type StateProps = { +export type Props = { readonly thread: Thread; readonly weightType: WeightType; readonly innerWindowIDToPageMap: Map | null; @@ -83,26 +59,32 @@ type StateProps = { readonly scrollToSelectionGeneration: number; readonly categories: CategoryList; readonly interval: Milliseconds; - readonly isInverted: boolean; readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; readonly ctssSamples: SamplesLikeTable; readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly contextMenuId?: string; + readonly onSelectedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onRightClickedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onCallNodeEnterOrDoubleClick: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onKeyboardTransformShortcut: ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => void; }; -type DispatchProps = { - readonly changeSelectedCallNode: typeof changeSelectedCallNode; - readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; - readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; - readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; export interface FlameGraphHandle { focus(): void; } -class FlameGraphImpl +export class FlameGraph extends React.PureComponent implements FlameGraphHandle { @@ -116,44 +98,13 @@ class FlameGraphImpl document.removeEventListener('copy', this._onCopy, false); } - _onSelectedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onRightClickedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; - changeRightClickedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onCallNodeEnterOrDoubleClick = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - if (callNodeIndex === null) { - return; - } - const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; - const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); - }; - _shouldDisplayTooltips = () => this.props.rightClickedCallNodeIndex === null; _takeViewportRef = (viewport: HTMLDivElement | null) => { this._viewport = viewport; }; - /* This method is called from MaybeFlameGraph. */ + /* This method is called from ConnectedFlameGraph. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus = () => { if (this._viewport) { @@ -169,7 +120,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; }; @@ -194,7 +145,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.getRow(depth); let columnIndex = row.callNode.indexOf(callNodeIndex); do { @@ -215,13 +166,13 @@ class FlameGraphImpl _handleKeyDown = (event: React.KeyboardEvent) => { const { - threadsKey, callTree, callNodeInfo, selectedCallNodeIndex, rightClickedCallNodeIndex, - changeSelectedCallNode, - handleCallNodeTransformShortcut, + onSelectedCallNodeChange, + onCallNodeEnterOrDoubleClick, + onKeyboardTransformShortcut, } = this.props; const callNodeTable = callNodeInfo.getCallNodeTable(); @@ -231,10 +182,7 @@ class FlameGraphImpl ) { if (selectedCallNodeIndex === null) { // Just select the "root" node if we've got no prior selection. - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(0) - ); + onSelectedCallNodeChange(0); return; } @@ -242,10 +190,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -257,10 +202,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -272,10 +214,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -298,11 +237,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -336,13 +275,16 @@ class FlameGraphImpl callTreeSummaryStrategy, categories, interval, - isInverted, innerWindowIDToPageMap, weightType, ctssSamples, ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + contextMenuId = 'CallNodeContextMenu', + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -363,7 +305,7 @@ class FlameGraphImpl return (
({ - mapStateToProps: (state) => ({ - thread: selectedThreadSelectors.getFilteredThread(state), - weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), - // Use the filtered call node max depth, rather than the preview filtered one, so - // that the viewport height is stable across preview selections. - maxStackDepthPlusOne: - selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), - flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), - callTree: selectedThreadSelectors.getCallTree(state), - timeRange: getCommittedRange(state), - previewSelection: getPreviewSelection(state), - callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), - threadsKey: getSelectedThreadsKey(state), - selectedCallNodeIndex: - selectedThreadSelectors.getSelectedCallNodeIndex(state), - rightClickedCallNodeIndex: - selectedThreadSelectors.getRightClickedCallNodeIndex(state), - scrollToSelectionGeneration: getScrollToSelectionGeneration(state), - interval: getProfileInterval(state), - isInverted: getInvertCallstack(state), - callTreeSummaryStrategy: - selectedThreadSelectors.getCallTreeSummaryStrategy(state), - innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), - ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), - ctssSampleCategoriesAndSubcategories: - selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( - state - ), - tracedTiming: selectedThreadSelectors.getTracedTiming(state), - displayStackType: getProfileUsesMultipleStackTypes(state), - }), - mapDispatchToProps: { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, - }, - options: { forwardRef: true }, - component: FlameGraphImpl, -}); diff --git a/src/components/flame-graph/MaybeFlameGraph.css b/src/components/flame-graph/MaybeFlameGraph.css deleted file mode 100644 index fcc1267de5..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.css +++ /dev/null @@ -1,19 +0,0 @@ -.flameGraphDisabledMessage { - --internal-background-color: var(--grey-20); - - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 10px; - background-color: var(--internal-background-color); - box-shadow: inset 0 0 150px var(--base-shadow-color); - font-size: 120%; -} - -:root.dark-mode { - .flameGraphDisabledMessage { - --internal-background-color: var(--grey-80); - } -} diff --git a/src/components/flame-graph/MaybeFlameGraph.tsx b/src/components/flame-graph/MaybeFlameGraph.tsx deleted file mode 100644 index 5dd0f0f227..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as React from 'react'; - -import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; -import { getInvertCallstack } from '../../selectors/url-state'; -import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { changeInvertCallstack } from '../../actions/profile-view'; -import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; -import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './MaybeFlameGraph.css'; - -// TODO: This component isn't needed any more. Whenever the selected tab -// is "flame-graph", `invertCallstack` will be `false`. is -// only used in the "flame-graph" tab. - -type StateProps = { - readonly isPreviewSelectionEmpty: boolean; - readonly invertCallstack: boolean; -}; -type DispatchProps = { - readonly changeInvertCallstack: typeof changeInvertCallstack; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; - -class MaybeFlameGraphImpl extends React.PureComponent { - _flameGraph: React.RefObject = React.createRef(); - - _onSwitchToNormalCallstackClick = () => { - this.props.changeInvertCallstack(false); - }; - - override componentDidMount() { - const flameGraph = this._flameGraph.current; - if (flameGraph) { - flameGraph.focus(); - } - } - - override render() { - const { isPreviewSelectionEmpty, invertCallstack } = this.props; - - if (isPreviewSelectionEmpty) { - return ; - } - - if (invertCallstack) { - return ( -
-

The Flame Graph is not available for inverted call stacks

-

- {' '} - to show the Flame Graph. -

-
- ); - } - return ; - } -} - -export const MaybeFlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - MaybeFlameGraphImpl ->({ - mapStateToProps: (state) => { - return { - invertCallstack: getInvertCallstack(state), - isPreviewSelectionEmpty: - !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), - }; - }, - mapDispatchToProps: { - changeInvertCallstack, - }, - component: MaybeFlameGraphImpl, -}); diff --git a/src/components/flame-graph/index.tsx b/src/components/flame-graph/index.tsx index 141147302c..70af6f60c0 100644 --- a/src/components/flame-graph/index.tsx +++ b/src/components/flame-graph/index.tsx @@ -2,21 +2,63 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + import { StackSettings } from '../shared/StackSettings'; import { TransformNavigator } from '../shared/TransformNavigator'; -import { MaybeFlameGraph } from './MaybeFlameGraph'; - -const FlameGraphView = () => ( -
- - - -
-); - -export const FlameGraph = FlameGraphView; +import { + ConnectedFlameGraph, + type ConnectedFlameGraphHandle, +} from './ConnectedFlameGraph'; +import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly isPreviewSelectionEmpty: boolean; +}; + +type Props = ConnectedProps<{}, StateProps, {}>; + +class FlameGraphViewImpl extends React.PureComponent { + _connectedFlameGraph: React.RefObject = + React.createRef(); + + override componentDidMount() { + this._connectedFlameGraph.current?.focus(); + } + + override render() { + const { isPreviewSelectionEmpty } = this.props; + + return ( +
+ + + {isPreviewSelectionEmpty ? ( + + ) : ( + + )} +
+ ); + } +} + +const FlameGraphViewConnected = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + isPreviewSelectionEmpty: + !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), + }), + mapDispatchToProps: {}, + component: FlameGraphViewImpl, +}); + +export const FlameGraph = FlameGraphViewConnected; diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ea20211a15..eeb1347034 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -179,7 +179,7 @@ class StackChartImpl extends React.PureComponent { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onDoubleClick = (callNodeIndex: IndexIntoCallNodeTable | null) => { diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 49c62f5e0f..88858225e9 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -105,7 +105,8 @@ interface CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean; createChildren(nodeIndex: IndexIntoCallNodeTable): CallNodeChildren; createRoots(): CallNodeChildren; - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal; + getSelf(nodeIndex: IndexIntoCallNodeTable): number; + getTotal(nodeIndex: IndexIntoCallNodeTable): number; findHeaviestPathInSubtree( callNodeIndex: IndexIntoCallNodeTable ): CallNodePath; @@ -171,10 +172,12 @@ export class CallTreeInternalNonInverted implements CallTreeInternal { return this._callNodeHasChildren[callNodeIndex] !== 0; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - const self = this._callTreeTimings.self[callNodeIndex]; - const total = this._callTreeTimings.total[callNodeIndex]; - return { self, total }; + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.self[callNodeIndex]; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.total[callNodeIndex]; } findHeaviestPathInSubtree( @@ -216,11 +219,12 @@ export class CallTreeInternalFunctionList implements CallTreeInternal { return this._timings.sortedFuncs; } - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - return { - self: this._timings.funcSelf[nodeIndex], - total: this._timings.funcTotal[nodeIndex], - }; + getSelf(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcSelf[nodeIndex]; + } + + getTotal(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcTotal[nodeIndex]; } findHeaviestPathInSubtree( @@ -288,13 +292,19 @@ class CallTreeInternalInverted implements CallTreeInternal { return children; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + if (this._callNodeInfo.isRoot(callNodeIndex)) { + return this._totalPerRootFunc[callNodeIndex]; + } + return 0; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - const total = this._totalPerRootFunc[callNodeIndex]; - return { self: total, total }; + return this._totalPerRootFunc[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); - return { self: 0, total }; + return total; } _getTotalAndHasChildren( @@ -445,7 +455,8 @@ export class CallTree { this._thread.funcTable.name[funcIndex] ); - const { self, total } = this._internal.getSelfAndTotal(callNodeIndex); + const total = this._internal.getTotal(callNodeIndex); + const self = this._internal.getSelf(callNodeIndex); const totalRelative = total / this._rootTotalSummary; const selfRelative = self / this._rootTotalSummary; diff --git a/src/profile-logic/data-structures.ts b/src/profile-logic/data-structures.ts index 18f8ab12fc..0599e2153e 100644 --- a/src/profile-logic/data-structures.ts +++ b/src/profile-logic/data-structures.ts @@ -27,6 +27,8 @@ import type { CallNodeTable, SourceTable, SourceLocationTable, + IndexIntoFrameTable, + IndexIntoStackTable, } from 'firefox-profiler/types'; /** @@ -47,7 +49,13 @@ export function getEmptySamplesTable(): RawSamplesTable { }; } -export function getEmptyRawStackTable(): RawStackTable { +export type RawStackTableBuilder = { + frame: IndexIntoFrameTable[]; + prefix: Array; + length: number; +}; + +export function getRawStackTableBuilder(): RawStackTableBuilder { return { // Important! // If modifying this structure, please update all callers of this function to ensure @@ -59,6 +67,27 @@ export function getEmptyRawStackTable(): RawStackTable { }; } +export function getRawStackTableBuilderWithExistingContents( + existing: RawStackTable +): RawStackTableBuilder { + return { + frame: [...existing.frame], + prefix: [...existing.prefix], + length: existing.length, + }; +} + +export function finishRawStackTableBuilder( + builder: RawStackTableBuilder +): RawStackTable { + const { frame, prefix, length } = builder; + return { + frame: new Int32Array(frame), + prefix, + length, + }; +} + /** * Returns an empty samples table with eventDelay field instead of responsiveness. * eventDelay is a new field and it replaced responsiveness. We should still @@ -393,7 +422,7 @@ export function getEmptyThread(overrides?: Partial): RawThread { export function getEmptySharedData(): RawProfileSharedData { return { - stackTable: getEmptyRawStackTable(), + stackTable: finishRawStackTableBuilder(getRawStackTableBuilder()), frameTable: getEmptyFrameTable(), funcTable: getEmptyFuncTable(), resourceTable: getEmptyResourceTable(), diff --git a/src/profile-logic/errors.ts b/src/profile-logic/errors.ts index 863abc0a2f..30d8324079 100644 --- a/src/profile-logic/errors.ts +++ b/src/profile-logic/errors.ts @@ -21,3 +21,30 @@ export class SymbolsNotFoundError extends Error { this.errors = errors; } } + +// Thrown when a profile's format version is newer than the most recent version +// understood by this build. The message is deliberately neutral and only states +// the facts. Consumers (the web app, the CLI) detect this by name and append +// their own advice on how to update, since that advice is frontend-specific. +export class ProfileVersionError extends Error { + formatName: string; + profileVersion: number; + supportedVersion: number; + + constructor( + formatName: string, + profileVersion: number, + supportedVersion: number + ) { + super( + `Unable to parse a ${formatName} profile of version ${profileVersion}. ` + + `The most recent version understood by this build is version ${supportedVersion}.` + ); + // Workaround for a babel issue when extending Errors + (this as any).__proto__ = ProfileVersionError.prototype; + this.name = 'ProfileVersionError'; + this.formatName = formatName; + this.profileVersion = profileVersion; + this.supportedVersion = supportedVersion; + } +} diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2cf8e3701d..14f118e843 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -16,9 +16,8 @@ export type FlameGraphDepth = number; export type IndexIntoFlameGraphTiming = number; /** - * FlameGraphTiming is an array containing data used for rendering the - * flame graph. Each element in the array describes one row in the - * graph. Each such element in turn contains one or more functions, + * FlameGraphTimingRow contains the data used for rendering a single + * row of the flame graph. Each row contains one or more functions, * drawn as boxes with start and end positions, represented as unit * intervals of the profile range. It should be noted that start and * end does not represent units of time, but only positions on the @@ -30,13 +29,130 @@ export type IndexIntoFlameGraphTiming = number; * selfRelative contains the self time relative to the total time, * which is used to color the drawn functions. */ -export type FlameGraphTiming = Array<{ +export type FlameGraphTimingRow = { start: UnitIntervalOfProfileRange[]; end: UnitIntervalOfProfileRange[]; selfRelative: Array; callNode: IndexIntoCallNodeTable[]; length: number; -}>; +}; + +/** + * Used by the flame graph canvas to know which boxes to render where. + * + * The flame graph only calls getRow(depth) for on-screen rows; this allows + * the implementation to generate rows lazily as the user scrolls towards + * deeper calls. + */ +export class FlameGraphTiming { + _flameGraphRows: FlameGraphRows; + _callNodeTable: CallNodeTable; + _callTreeTimings: CallTreeTimingsNonInverted; + + // Populated lazily by _buildNextTimingRow(). + _timingRows: FlameGraphTimingRow[]; + + // Used to position the children call node boxes: For a given parent box, its + // first (left-most) child box starts at the same x position as the parent. + _startPerCallNode: Float32Array; + + constructor( + flameGraphRows: FlameGraphRows, + callNodeTable: CallNodeTable, + callTreeTimings: CallTreeTimingsNonInverted + ) { + this._flameGraphRows = flameGraphRows; + this._callNodeTable = callNodeTable; + this._callTreeTimings = callTreeTimings; + + this._timingRows = []; + this._startPerCallNode = new Float32Array(callNodeTable.length); + } + + get rowCount(): number { + return this._flameGraphRows.length; + } + + getRow(depth: number): FlameGraphTimingRow { + if (depth < 0 || depth >= this.rowCount) { + throw new Error( + `Out-of-bounds call to getRow: ${depth} is outside 0..${this.rowCount}` + ); + } + while (this._timingRows.length <= depth) { + this._buildNextTimingRow(); + } + return this._timingRows[depth]; + } + + // Convenience method for tests, don't call in production + getAllRowsForTesting(): FlameGraphTimingRow[] { + const rows = []; + for (let depth = 0; depth < this.rowCount; depth++) { + rows.push(this.getRow(depth)); + } + return rows; + } + + _buildNextTimingRow(): void { + const { total, self, rootTotalSummary } = this._callTreeTimings; + const { prefix } = this._callNodeTable; + + const depth = this._timingRows.length; + const rowNodes = this._flameGraphRows[depth]; + const startPerCallNode = this._startPerCallNode; + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 + const abs = Math.abs; + + const start: UnitIntervalOfProfileRange[] = []; + const end: UnitIntervalOfProfileRange[] = []; + const selfRelative: number[] = []; + const timingCallNodes: IndexIntoCallNodeTable[] = []; + + // Sibling boxes are adjacent. Whenever the prefix changes, jump ahead to + // the new parent's start so children stay aligned under their parent. + // + // Previous row: [B ][D ] [G ] + // Current row: [C][E][F] [I ] + // (Note: upside down from how the flame graph is usually displayed.) + let currentStart = 0; + let previousPrefixCallNode = -1; + for (let i = 0; i < rowNodes.length; i++) { + const nodeIndex = rowNodes[i]; + const totalVal = total[nodeIndex]; + if (totalVal === 0) { + continue; + } + + const nodePrefix = prefix[nodeIndex]; + if (nodePrefix !== previousPrefixCallNode) { + currentStart = nodePrefix === -1 ? 0 : startPerCallNode[nodePrefix]; + previousPrefixCallNode = nodePrefix; + } + startPerCallNode[nodeIndex] = currentStart; + + const totalRelative = abs(totalVal / rootTotalSummary); + const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(nodeIndex); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); + } +} /** * FlameGraphRows is an array of rows, where each row is an array of call node @@ -225,85 +341,12 @@ export function computeFlameGraphRows( } /** - * Build a FlameGraphTiming table from a call tree. + * Build a FlameGraphTiming from a call tree. */ export function getFlameGraphTiming( flameGraphRows: FlameGraphRows, callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted ): FlameGraphTiming { - const { total, self, rootTotalSummary } = callTreeTimings; - const { prefix } = callNodeTable; - - // This is where we build up the return value, one row at a time. - const timing = []; - - // This is used to adjust the start position of a call node's box based on the - // start position of its prefix node's box. - const startPerCallNode = new Float32Array(callNodeTable.length); - - // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 - const abs = Math.abs; - - // Go row by row. - for (let depth = 0; depth < flameGraphRows.length; depth++) { - const rowNodes = flameGraphRows[depth]; - - const start = []; - const end = []; - const selfRelative = []; - const timingCallNodes = []; - - // Process the call nodes in this row. Sibling boxes are adjacent to each other. - // Whenever the prefix changes, we need to add a gap so that the child boxes - // start at the same position as the parent box. - // - // Previous row: [B ][D ] [G ] - // Current row: [C][E][F] [I ] - // (Note that this is upside down from how the flame graph is usually displayed) - let currentStart = 0; - let previousPrefixCallNode = -1; - for (let indexInRow = 0; indexInRow < rowNodes.length; indexInRow++) { - const nodeIndex = rowNodes[indexInRow]; - const totalVal = total[nodeIndex]; - if (totalVal === 0) { - // Skip boxes with zero width. - continue; - } - - const nodePrefix = prefix[nodeIndex]; - if (nodePrefix !== previousPrefixCallNode) { - // We have advanced to a node with a different parent, so we need to - // jump ahead to the parent box's start position. - currentStart = startPerCallNode[nodePrefix]; - previousPrefixCallNode = nodePrefix; - } - - // Write down the start position of this call node so that it can be - // checked later by this node's children. - startPerCallNode[nodeIndex] = currentStart; - - // Take the absolute value, as native deallocations can be negative. - const totalRelativeVal = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); - - const currentEnd = currentStart + totalRelativeVal; - start.push(currentStart); - end.push(currentEnd); - selfRelative.push(selfRelativeVal); - timingCallNodes.push(nodeIndex); - - // The start position of the next box is the end position of the current box. - currentStart = currentEnd; - } - timing[depth] = { - start, - end, - selfRelative, - callNode: timingCallNodes, - length: timingCallNodes.length, - }; - } - - return timing; + return new FlameGraphTiming(flameGraphRows, callNodeTable, callTreeTimings); } diff --git a/src/profile-logic/gecko-profile-versioning.ts b/src/profile-logic/gecko-profile-versioning.ts index b15d93c3ba..38923e9c45 100644 --- a/src/profile-logic/gecko-profile-versioning.ts +++ b/src/profile-logic/gecko-profile-versioning.ts @@ -14,6 +14,7 @@ import { StringTable } from '../utils/string-table'; import { GECKO_PROFILE_VERSION } from '../app-logic/constants'; +import { ProfileVersionError } from './errors'; // Gecko profiles before version 1 did not have a profile.meta.version field. // Treat those as version zero. @@ -45,10 +46,10 @@ export function upgradeGeckoProfileToCurrentVersion(json: unknown) { } if (profileVersion > GECKO_PROFILE_VERSION) { - throw new Error( - `Unable to parse a Gecko profile of version ${profileVersion}, most likely profiler.firefox.com needs to be refreshed. ` + - `The most recent version understood by this version of profiler.firefox.com is version ${GECKO_PROFILE_VERSION}.\n` + - 'You can try refreshing this page in case profiler.firefox.com has updated in the meantime.' + throw new ProfileVersionError( + 'Gecko', + profileVersion, + GECKO_PROFILE_VERSION ); } diff --git a/src/profile-logic/global-data-collector.ts b/src/profile-logic/global-data-collector.ts index 78b5b38543..c52e170293 100644 --- a/src/profile-logic/global-data-collector.ts +++ b/src/profile-logic/global-data-collector.ts @@ -4,13 +4,14 @@ import { StringTable } from '../utils/string-table'; import { + finishRawStackTableBuilder, getEmptyFrameTable, getEmptyFuncTable, getEmptyNativeSymbolTable, - getEmptyRawStackTable, getEmptyResourceTable, getEmptySourceTable, getEmptySourceLocationTable, + getRawStackTableBuilder, } from './data-structures'; import type { @@ -22,7 +23,6 @@ import type { RawProfileSharedData, SourceTable, FrameTable, - RawStackTable, FuncTable, ResourceTable, NativeSymbolTable, @@ -34,6 +34,7 @@ import type { Bytes, } from 'firefox-profiler/types'; import { ResourceType } from 'firefox-profiler/types'; +import type { RawStackTableBuilder } from './data-structures'; /** * GlobalDataCollector collects data which is global in the processed profile @@ -50,7 +51,7 @@ export class GlobalDataCollector { _stringTable: StringTable = StringTable.withBackingArray(this._stringArray); _sources: SourceTable = getEmptySourceTable(); _frameTable: FrameTable = getEmptyFrameTable(); - _stackTable: RawStackTable = getEmptyRawStackTable(); + _stackTableBuilder: RawStackTableBuilder = getRawStackTableBuilder(); _funcTable: FuncTable = getEmptyFuncTable(); _resourceTable: ResourceTable = getEmptyResourceTable(); _nativeSymbols: NativeSymbolTable = getEmptyNativeSymbolTable(); @@ -302,15 +303,15 @@ export class GlobalDataCollector { return this._frameTable; } - getStackTable(): RawStackTable { - return this._stackTable; + getStackTableBuilder(): RawStackTableBuilder { + return this._stackTableBuilder; } // Package up all de-duplicated global tables so that they can be embedded in // the profile. finish(): { libs: Lib[]; shared: RawProfileSharedData } { const shared: RawProfileSharedData = { - stackTable: this._stackTable, + stackTable: finishRawStackTableBuilder(this._stackTableBuilder), frameTable: this._frameTable, funcTable: this._funcTable, resourceTable: this._resourceTable, diff --git a/src/profile-logic/import/chrome.ts b/src/profile-logic/import/chrome.ts index 96add0a137..be4253f0ce 100644 --- a/src/profile-logic/import/chrome.ts +++ b/src/profile-logic/import/chrome.ts @@ -501,7 +501,7 @@ async function processTracingEvents( const stringTable = globalDataCollector.getStringTable(); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let profileEvents: (ProfileEvent | CpuProfileEvent)[] = (eventsByName.get( 'Profile' diff --git a/src/profile-logic/import/dhat.ts b/src/profile-logic/import/dhat.ts index dc56c06f17..6f883c2e29 100644 --- a/src/profile-logic/import/dhat.ts +++ b/src/profile-logic/import/dhat.ts @@ -183,7 +183,7 @@ export function attemptToConvertDhat(json: unknown): Profile | null { profile.meta.product = dhat.cmd + ' (dhat)'; profile.meta.importedFrom = `dhat`; const globalDataCollector = new GlobalDataCollector(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const frameTable = globalDataCollector.getFrameTable(); const stringTable = globalDataCollector.getStringTable(); diff --git a/src/profile-logic/import/flame-graph.ts b/src/profile-logic/import/flame-graph.ts index 148a777bd2..b468d4d008 100644 --- a/src/profile-logic/import/flame-graph.ts +++ b/src/profile-logic/import/flame-graph.ts @@ -60,7 +60,7 @@ export function convertFlameGraphProfile(profileText: string): Profile { }); const frameTable = globalDataCollector.getFrameTable(); - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); const { samples } = thread; // Maps to deduplicate stacks, frames, and functions. diff --git a/src/profile-logic/import/simpleperf.ts b/src/profile-logic/import/simpleperf.ts index 89b632cd85..83c38d8929 100644 --- a/src/profile-logic/import/simpleperf.ts +++ b/src/profile-logic/import/simpleperf.ts @@ -24,7 +24,9 @@ import { getEmptyFuncTable, getEmptyResourceTable, getEmptyFrameTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, + type RawStackTableBuilder, getEmptySamplesTable, getEmptyRawMarkerTable, getEmptyNativeSymbolTable, @@ -189,7 +191,7 @@ class FirefoxFrameTable { class FirefoxSampleTable { strings: StringTable; - stackTable: RawStackTable = getEmptyRawStackTable(); + stackTable: RawStackTableBuilder = getRawStackTableBuilder(); stackMap: Map = new Map(); constructor(strings: StringTable) { @@ -197,7 +199,7 @@ class FirefoxSampleTable { } toJson(): RawStackTable { - return this.stackTable; + return finishRawStackTableBuilder(this.stackTable); } findOrAddStack( diff --git a/src/profile-logic/js-tracer.ts b/src/profile-logic/js-tracer.ts index 8993ddfbca..e4664aee95 100644 --- a/src/profile-logic/js-tracer.ts +++ b/src/profile-logic/js-tracer.ts @@ -4,6 +4,8 @@ import { getEmptySamplesTableWithEventDelay, getEmptyRawMarkerTable, + finishRawStackTableBuilder, + getRawStackTableBuilderWithExistingContents, } from './data-structures'; import { StringTable } from '../utils/string-table'; import { ensureExists } from '../utils/types'; @@ -512,7 +514,10 @@ export function convertJsTracerToThreadWithoutSamples( samples, }; - const { funcTable, frameTable, stackTable } = shared; + const { funcTable, frameTable } = shared; + const stackTable = getRawStackTableBuilderWithExistingContents( + shared.stackTable + ); // Keep a stack of js tracer events, and end timings, that will be used to find // the stack prefixes. Once a JS tracer event starts past another event end, the @@ -620,6 +625,9 @@ export function convertJsTracerToThreadWithoutSamples( unmatchedEventEnds[unmatchedIndex] = end; } + // Write the augmented stackTable back to the shared data. + shared.stackTable = finishRawStackTableBuilder(stackTable); + return { thread, stackMap }; } diff --git a/src/profile-logic/merge-compare.ts b/src/profile-logic/merge-compare.ts index 31b4f03988..1eeb32e0ca 100644 --- a/src/profile-logic/merge-compare.ts +++ b/src/profile-logic/merge-compare.ts @@ -13,7 +13,8 @@ import { getEmptyNativeSymbolTable, getEmptyFrameTable, getEmptyFuncTable, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyRawMarkerTable, getEmptySamplesTableWithEventDelay, shallowCloneRawMarkerTable, @@ -1122,7 +1123,7 @@ function mergeStackTables( translationMapsForFrames: TranslationMapForFrames[] ): { stackTable: RawStackTable; translationMaps: TranslationMapForStacks[] } { const translationMaps: TranslationMapForStacks[] = []; - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); profiles.forEach((profile, profileIndex) => { const { stackTable } = profile.shared; @@ -1148,7 +1149,10 @@ function mergeStackTables( translationMaps.push(oldStackToNewStackPlusOne); }); - return { stackTable: newStackTable, translationMaps }; + return { + stackTable: finishRawStackTableBuilder(newStackTable), + translationMaps, + }; } /** diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index 712dda0c52..eaf192e0e7 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -16,12 +16,14 @@ import { getEmptyRawMarkerTable, getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, + type RawStackTableBuilder, } from './data-structures'; import { immutableUpdate, ensureExists } from '../utils/types'; import { verifyMagic, SIMPLEPERF as SIMPLEPERF_MAGIC } from '../utils/magic'; import { attemptToUpgradeProcessedProfileThroughMutation } from './processed-profile-versioning'; import type { ProfileUpgradeInfo } from './processed-profile-versioning'; import { upgradeGeckoProfileToCurrentVersion } from './gecko-profile-versioning'; +import { ProfileVersionError } from './errors'; import { isPerfScriptFormat, convertPerfScriptProfile, @@ -53,7 +55,6 @@ import type { FrameTable, RawCounterSamplesTable, RawSamplesTable, - RawStackTable, RawMarkerTable, LibMapping, IndexIntoStackTable, @@ -107,6 +108,7 @@ import type { CounterDisplayConfig, } from 'firefox-profiler/types'; import { decompress, isGzip } from 'firefox-profiler/utils/gz'; +import { jsonEncodeObjectWithTypedArraysAsRegularArrays } from 'firefox-profiler/utils/json-with-typed-arrays'; type RegExpResult = null | string[]; /** @@ -548,7 +550,7 @@ function _processFrameTable( */ function _processStackTable( geckoStackTable: GeckoStackStruct, - sharedStackTable: RawStackTable, + sharedStackTable: RawStackTableBuilder, frameIndexOffset: IndexIntoFrameTable ): IndexIntoStackTable { const stackIndexOffset = sharedStackTable.length; @@ -1336,7 +1338,7 @@ function _processThread( ); const stackIndexOffset = _processStackTable( geckoStackTable, - globalDataCollector.getStackTable(), + globalDataCollector.getStackTableBuilder(), frameIndexOffset ); @@ -2056,7 +2058,7 @@ export function processGeckoProfile(geckoProfile: GeckoProfile): Profile { * Take a processed profile and convert it to a string. */ export function serializeProfileToJsonString(profile: Profile): string { - return JSON.stringify(profile); + return jsonEncodeObjectWithTypedArraysAsRegularArrays(profile); } /** @@ -2312,6 +2314,11 @@ export async function unserializeProfileOfArbitraryFormat( return processGeckoOrDevToolsProfile(json); } catch (e) { console.error('UnserializationError:', e); + // A version mismatch is already a clear, user-facing error. Re-throw it + // as-is so each frontend can detect it and add its own update advice. + if (e instanceof ProfileVersionError) { + throw e; + } throw new Error(`Unserializing the profile failed: ${e}`); } } diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index bcf3931179..62dd36070d 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -18,6 +18,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { StringTable } from '../utils/string-table'; import { timeCode } from '../utils/time-code'; import { PROCESSED_PROFILE_VERSION } from '../app-logic/constants'; +import { ProfileVersionError } from './errors'; import type { Profile } from 'firefox-profiler/types'; export type ProfileUpgradeInfo = { @@ -85,10 +86,10 @@ export function attemptToUpgradeProcessedProfileThroughMutation( } if (profileVersion > PROCESSED_PROFILE_VERSION) { - throw new Error( - `Unable to parse a processed profile of version ${profileVersion}, most likely profiler.firefox.com needs to be refreshed. ` + - `The most recent version understood by this version of profiler.firefox.com is version ${PROCESSED_PROFILE_VERSION}.\n` + - 'You can try refreshing this page in case profiler.firefox.com has updated in the meantime.' + throw new ProfileVersionError( + 'processed', + profileVersion, + PROCESSED_PROFILE_VERSION ); } @@ -3255,6 +3256,11 @@ const _upgraders: { }; sources.content = new Array(sources.length).fill(null); }, + [65]: (_profile: any) => { + // The type of `profile.shared.stackTable.frame` was changed from + // `IndexIntoFrameTable[]` to `IndexIntoFrameTable[] | Int32Array`. + // All valid v64 profiles are valid v65 profiles, so no upgrader is needed. + }, // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/profile-logic/profile-compacting.ts b/src/profile-logic/profile-compacting.ts index 492f914716..e7300a3551 100644 --- a/src/profile-logic/profile-compacting.ts +++ b/src/profile-logic/profile-compacting.ts @@ -48,15 +48,22 @@ type ColumnDescription = null extends ( | { type: 'INDEX_REF_OR_NULL'; referencedTable: TableCompactionState } | { type: 'SELF_INDEX_REF_OR_NULL' } | { type: 'NO_REF' } - : - | { type: 'INDEX_REF'; referencedTable: TableCompactionState } - | { type: 'INDEX_REF_OR_NEG_ONE'; referencedTable: TableCompactionState } - | { type: 'NO_REF' }; + : Int32Array extends TCol + ? + | { type: 'INDEX_REF_INT32'; referencedTable: TableCompactionState } + | { type: 'NO_REF' } + : + | { type: 'INDEX_REF'; referencedTable: TableCompactionState } + | { + type: 'INDEX_REF_OR_NEG_ONE'; + referencedTable: TableCompactionState; + } + | { type: 'NO_REF' }; type TableDescription = { - [K in keyof T as T[K] extends Array ? K : never]: ColumnDescription< - T[K] - >; + [K in keyof T as T[K] extends Array | Int32Array + ? K + : never]: ColumnDescription; }; const ColDesc = { @@ -64,6 +71,10 @@ const ColDesc = { type: 'INDEX_REF' as const, referencedTable, }), + indexRefInt32: (referencedTable: TableCompactionState) => ({ + type: 'INDEX_REF_INT32' as const, + referencedTable, + }), indexRefOrNull: (referencedTable: TableCompactionState) => ({ type: 'INDEX_REF_OR_NULL' as const, referencedTable, @@ -145,7 +156,7 @@ export function computeCompactedProfile( }; const stackTableDesc: TableDescription = { - frame: ColDesc.indexRef(tcs.frameTable), + frame: ColDesc.indexRefInt32(tcs.frameTable), prefix: ColDesc.selfIndexRefOrNull(), }; const frameTableDesc: TableDescription = { @@ -326,6 +337,7 @@ function _markTableAndComputeTranslation( const col = (table as any)[key]; switch (desc.type) { case 'INDEX_REF': + case 'INDEX_REF_INT32': markColumn(col, markBuffer, desc.referencedTable.markBuffer); break; case 'INDEX_REF_OR_NULL': @@ -354,7 +366,13 @@ function _markTableAndComputeTranslation( thisTableCompactionState.computeIndexTranslation(); } -function markColumn(col: Array, shouldMark: BitSet, markBuf: BitSet) { +function markColumn( + col: Array | Int32Array, + shouldMark: BitSet, + markBuf: BitSet +) { + // Polymorphic: indexing works the same on Int32Array as on number[], so the + // INDEX_REF and INDEX_REF_INT32 cases share this function. for (let i = 0; i < col.length; i++) { if (checkBit(shouldMark, i)) { const val = col[i]; @@ -499,6 +517,14 @@ function _compactTable( newLength ); break; + case 'INDEX_REF_INT32': + result[key] = _compactColIndexInt32( + oldCol, + markBuffer, + desc.referencedTable.oldIndexToNewIndexPlusOne, + newLength + ); + break; case 'INDEX_REF_OR_NULL': result[key] = _compactColIndexOrNull( oldCol, @@ -564,6 +590,22 @@ function _compactColIndex( return newCol; } +function _compactColIndexInt32( + oldCol: Int32Array, + markBuffer: BitSet, + oldIndexToNewIndexPlusOne: Int32Array, + newLength: number +): Int32Array { + const newCol = new Int32Array(newLength); + let newIndex = 0; + for (let i = 0; i < oldCol.length; i++) { + if (checkBit(markBuffer, i)) { + newCol[newIndex++] = oldIndexToNewIndexPlusOne[oldCol[i]] - 1; + } + } + return newCol; +} + function _compactColIndexOrNull( oldCol: (number | null)[], markBuffer: BitSet, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 787fdc7fda..3b6e424499 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -6,7 +6,8 @@ import memoize from 'memoize-immutable'; import MixedTupleMap from 'mixedtuplemap'; import { oneLine } from 'common-tags'; import { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, getEmptyCallNodeTable, shallowCloneFrameTable, shallowCloneFuncTable, @@ -1688,7 +1689,7 @@ export function createStackTableBySkippingDiscarded( keepStack: BitSet ): StackTable { const newStackCount = newPrefixCol.length; - const newFrameCol = new Array(newStackCount); + const newFrameCol = new Int32Array(newStackCount); const newCategoryCol = new Uint8Array(newStackCount); const newSubcategoryCol = stackTable.subcategory instanceof Uint16Array @@ -4343,7 +4344,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { // Now the frame table contains adjusted / "nudged" addresses. // Make a new stack table which refers to the adjusted frames. - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const mapForSamplingSelfStacks = new Map< null | IndexIntoStackTable, null | IndexIntoStackTable @@ -4385,7 +4386,7 @@ export function nudgeReturnAddresses(profile: Profile): Profile { const newShared: RawProfileSharedData = { ...profile.shared, frameTable: newFrameTable, - stackTable: newStackTable, + stackTable: finishRawStackTableBuilder(newStackTable), }; const newThreads = updateRawThreadStacksSeparate( @@ -4764,8 +4765,14 @@ export function computeStackTableFromRawStackTable( subcategoryColumn[stackIndex] = stackSubcategory; } + // The frame column is a typed array in the derived stack table. + const frame = + rawStackTable.frame instanceof Int32Array + ? rawStackTable.frame + : new Int32Array(rawStackTable.frame); + return { - frame: rawStackTable.frame, + frame, category: categoryColumn, subcategory: subcategoryColumn, prefix: rawStackTable.prefix, diff --git a/src/profile-logic/sanitize.ts b/src/profile-logic/sanitize.ts index a69bec579f..851f919f7f 100644 --- a/src/profile-logic/sanitize.ts +++ b/src/profile-logic/sanitize.ts @@ -61,10 +61,23 @@ export function sanitizePII( maybePIIToBeRemoved: RemoveProfileInformation | null, markerSchemaByName: MarkerSchemaByName ): SanitizeProfileResult { + // FIXME: Currently we always sanitize the source contents while publishing. + // But we should have a sharing option in the publish panel to be able to + // include sources. This needs to happen before the early return below, so + // that the source contents are removed even when no other PII removal is + // requested. + const unconditionallySanitizedShared = { + ...profile.shared, + sources: { + ...profile.shared.sources, + content: Array(profile.shared.sources.content.length).fill(null), + }, + }; + if (maybePIIToBeRemoved === null) { - // Nothing is sanitized. + // Nothing else is sanitized, but the source contents are still removed. return { - profile, + profile: { ...profile, shared: unconditionallySanitizedShared }, isSanitized: false, translationMaps: null, committedRanges: null, @@ -122,18 +135,10 @@ export function sanitizePII( } const stringTable = StringTable.withBackingArray(stringArray); const newShared = { - ...profile.shared, + ...unconditionallySanitizedShared, stringArray, }; - // FIXME: Currently we always sanitize the source contents while publishing. - // But we should have a sharing option in the publish panel to be able to - // include sources. - newShared.sources = { - ...newShared.sources, - content: Array(newShared.sources.content.length).fill(null), - }; - let stackFlags: Uint8Array | null = null; if (windowIdFromPrivateBrowsing.size > 0) { diff --git a/src/profile-logic/symbolication.ts b/src/profile-logic/symbolication.ts index a6370bc6fa..dfbfd345c2 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -2,7 +2,8 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, shallowCloneFuncTable, shallowCloneNativeSymbolTable, shallowCloneFrameTable, @@ -420,7 +421,7 @@ function _computeStackTableWithAddedExpansionStacks( if (frameIndexToInlineExpansionFrames.size === 0) { return null; } - const newStackTable = getEmptyRawStackTable(); + const newStackTable = getRawStackTableBuilder(); const oldStackToNewStack = new Int32Array(stackTable.length); for (let stack = 0; stack < stackTable.length; stack++) { const oldFrame = stackTable.frame[stack]; @@ -452,7 +453,10 @@ function _computeStackTableWithAddedExpansionStacks( } oldStackToNewStack[stack] = prefix ?? -1; } - return { newStackTable, oldStackToNewStack }; + return { + newStackTable: finishRawStackTableBuilder(newStackTable), + oldStackToNewStack, + }; } /** diff --git a/src/profile-query/counter-map.ts b/src/profile-query/counter-map.ts new file mode 100644 index 0000000000..8e6ae8a5f1 --- /dev/null +++ b/src/profile-query/counter-map.ts @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { CounterIndex } from 'firefox-profiler/types'; + +/** + * A handle like "c-2" always refers to counter index 2 for this profile, + * making handles stable across sessions for the same processed profile data. + */ +export function getCounterHandle(counterIndex: CounterIndex): `c-${number}` { + return `c-${counterIndex}`; +} + +/** + * Parse a counter handle and validate it against the number of counters. + */ +export function parseCounterHandle( + counterHandle: string, + counterCount: number +): CounterIndex { + const match = /^c-(\d+)$/.exec(counterHandle); + if (match === null) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + const counterIndex = Number(match[1]); + if ( + !Number.isInteger(counterIndex) || + counterIndex < 0 || + counterIndex >= counterCount + ) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + return counterIndex; +} diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts new file mode 100644 index 0000000000..57ad58f0bd --- /dev/null +++ b/src/profile-query/formatters/counter-info.ts @@ -0,0 +1,257 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + getProfile, + getProfileRootRange, + getCounters, + getCounterSelectors, + getMeta, +} from 'firefox-profiler/selectors/profile'; +import { + formatBytes, + formatNumber, + formatPercent, +} from 'firefox-profiler/utils/format-numbers'; +import { + pwhToWh, + carbonForBytes, + carbonForWattHours, + POWER_LADDER, + ENERGY_LADDER, + pickTier, +} from 'firefox-profiler/components/timeline/TrackCounterTooltipFormat'; +import { getCounterHandle, parseCounterHandle } from '../counter-map'; +import type { + CounterIndex, + CounterTooltipDataSource, + CounterTooltipFormat, + ProfileMeta, +} from 'firefox-profiler/types'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { + CounterStat, + CounterSummary, + CounterListResult, + CounterInfoResult, +} from '../types'; + +// The tooltip schema describes many per-sample and preview-selection rows that +// only make sense at a hover point. These are the two sources that aggregate +// over the whole committed range, so they are the only ones a CLI summary can +// resolve without a cursor. +const RANGE_AGGREGATE_SOURCES: Set = new Set([ + 'count-range', + 'committed-range-total', +]); + +const naturalSort = new Intl.Collator('en-US', { numeric: true }); + +export function getSortedCounterIndexes(store: Store): CounterIndex[] { + const state = store.getState(); + const counters = getCounters(state) ?? []; + const order = counters.map((_, index) => index); + if (getMeta(state).keepProfileThreadOrder) { + return order; + } + return order.sort((a, b) => { + const sortWeightDiff = + counters[a].display.sortWeight - counters[b].display.sortWeight; + if (sortWeightDiff !== 0) { + return sortWeightDiff; + } + return naturalSort.compare(counters[a].name, counters[b].name); + }); +} + +/** + * Format a resolved counter value exactly as the timeline tooltip does, minus + * the React/localization wrapping. Only the range-aggregate sources reach this, + * so the power-`scale` ladder normalization (which needs a per-sample dt) never + * applies; energy values are pWh sums converted to watt-hours. + */ +function formatCounterRowValue( + value: number, + format: CounterTooltipFormat, + meta: ProfileMeta +): { formattedValue: string; carbon?: string } { + if (format.scale) { + const valueForLadder = format.scale === 'energy' ? pwhToWh(value) : value; + const ladder = format.scale === 'power' ? POWER_LADDER : ENERGY_LADDER; + const tier = pickTier(valueForLadder, ladder); + const formattedValue = `${formatNumber(valueForLadder * tier.multiplier, tier.valueSignificantDigits)} ${tier.unitText}`; + let carbon: string | undefined; + if (format.co2 === 'per-watthour' && format.scale === 'energy') { + const grams = carbonForWattHours(valueForLadder, meta); + carbon = `${formatNumber(grams * tier.carbonMultiplier, tier.carbonSignificantDigits)} ${tier.carbonUnitText}`; + } + return { formattedValue, carbon }; + } + + let formattedValue: string; + switch (format.unit) { + case 'bytes': + formattedValue = formatBytes(value); + break; + case 'bytes-per-second': + formattedValue = `${formatBytes(value * 1000)} per second`; + break; + case 'percent': + formattedValue = formatPercent(value); + break; + case 'number': + formattedValue = formatNumber(value, 2, 0); + break; + default: + formattedValue = formatNumber(value); + break; + } + + let carbon: string | undefined; + if (format.co2 === 'per-byte') { + const bytesForCarbon = + format.unit === 'bytes-per-second' ? value * 1000 : value; + carbon = `${formatNumber(carbonForBytes(bytesForCarbon))} g CO₂e`; + } + return { formattedValue, carbon }; +} + +/** + * Resolve the counter's range-aggregate tooltip rows into formatted stats. + */ +function collectCounterStats( + store: Store, + counterIndex: CounterIndex +): CounterStat[] { + const state = store.getState(); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const accumulated = selectors.getAccumulateCounterSamples(state); + const meta = getMeta(state); + + const stats: CounterStat[] = []; + for (const row of counter.display.tooltipRows) { + if (row.type !== 'value') { + continue; + } + if (row.requiresPreviewSelection) { + continue; + } + if (!RANGE_AGGREGATE_SOURCES.has(row.source)) { + continue; + } + + const value = + row.source === 'count-range' + ? accumulated.countRange + : selectors.getCommittedRangeCounterSampleSum(state); + + const { formattedValue, carbon } = formatCounterRowValue( + value, + row.format, + meta + ); + stats.push({ + source: row.source, + label: row.label, + value, + formattedValue, + carbon, + }); + } + return stats; +} + +/** + * Build the shared summary for a single counter. The stats cover the current + * committed (zoom) range, since the underlying counter selectors are + * range-aware. + */ +export function collectCounterSummary( + store: Store, + threadMap: ThreadMap, + counterIndex: CounterIndex +): CounterSummary { + const state = store.getState(); + const profile = getProfile(state); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const { display } = counter; + + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const mainThreadName = profile.threads[counter.mainThreadIndex]?.name ?? ''; + + return { + counterHandle: getCounterHandle(counterIndex), + counterIndex, + name: counter.name, + label: display.label || counter.name, + category: counter.category, + unit: display.unit, + graphType: display.graphType, + color: display.color, + pid: counter.pid, + mainThreadIndex: counter.mainThreadIndex, + mainThreadHandle: threadMap.handleForThreadIndex(counter.mainThreadIndex), + mainThreadName, + rangeSampleCount: Math.max(0, rangeEndIndex - rangeStartIndex), + stats: collectCounterStats(store, counterIndex), + }; +} + +/** + * Build summaries for every counter in the profile. Returns an empty list when + * the profile has no counters. + */ +export function collectCounterList( + store: Store, + threadMap: ThreadMap +): CounterListResult { + return { + type: 'counter-list', + counters: getSortedCounterIndexes(store).map((index) => + collectCounterSummary(store, threadMap, index) + ), + }; +} + +/** + * Build detailed information about a single counter, resolved by handle. + */ +export function collectCounterInfo( + store: Store, + threadMap: ThreadMap, + counterHandle: string +): CounterInfoResult { + const state = store.getState(); + const counters = getCounters(state) ?? []; + const counterIndex = parseCounterHandle(counterHandle, counters.length); + + const summary = collectCounterSummary(store, threadMap, counterIndex); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const zeroAt = getProfileRootRange(state).start; + const hasRange = rangeEndIndex > rangeStartIndex; + const rangeStart = hasRange + ? counter.samples.time[rangeStartIndex] + zeroAt + : null; + const rangeEnd = hasRange + ? counter.samples.time[rangeEndIndex - 1] + zeroAt + : null; + + return { + ...summary, + type: 'counter-info', + description: counter.description, + sampleCount: counter.samples.length, + rangeStart, + rangeEnd, + }; +} diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 1cce4668d4..664738a18b 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -10,11 +10,12 @@ import { import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state'; import { buildProcessThreadList } from '../process-thread-list'; import { collectSliceTree } from '../cpu-activity'; +import { collectCounterSummary, getSortedCounterIndexes } from './counter-info'; import type { Store } from '../../types/store'; import type { ThreadInfo, ProcessListItem } from '../process-thread-list'; import type { TimestampManager } from '../timestamps'; import type { ThreadMap } from '../thread-map'; -import type { ProfileInfoResult } from '../types'; +import type { ProfileInfoResult, CounterSummary } from '../types'; /** * Filter a list of processes by a search string. @@ -30,8 +31,7 @@ function applySearchFilter( for (const process of processes) { const processMatches = - process.name.toLowerCase().includes(query) || - String(process.pid).includes(query); + process.name.toLowerCase().includes(query) || process.pid.includes(query); const matchingThreads = processMatches ? process.threads @@ -108,6 +108,14 @@ export function collectProfileInfo( ? applySearchFilter(result.processes, search) : result.processes; + const countersByPid = new Map(); + for (const index of getSortedCounterIndexes(store)) { + const counter = collectCounterSummary(store, threadMap, index); + const list = countersByPid.get(counter.pid) ?? []; + list.push(counter); + countersByPid.set(counter.pid, list); + } + const processesData: ProfileInfoResult['processes'] = processesToShow.map( (processItem) => { let startTimeName: string | undefined; @@ -141,6 +149,7 @@ export function collectProfileInfo( cpuMs: thread.cpuMs, })), remainingThreads: processItem.remainingThreads, + counters: countersByPid.get(processItem.pid), }; } ); diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 5ff640589d..73d9959263 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -62,6 +62,10 @@ import { collectProfileLogs, } from './formatters/marker-info'; import { collectThreadPageLoad } from './formatters/page-load'; +import { + collectCounterList, + collectCounterInfo, +} from './formatters/counter-info'; import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; @@ -92,6 +96,8 @@ import type { ThreadFunctionsResult, ThreadPageLoadResult, ProfileLogsResult, + CounterListResult, + CounterInfoResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -202,7 +208,7 @@ export class ProfileQuerier { showAll: boolean = false, search?: string ): Promise> { - const result = await collectProfileInfo( + const result = collectProfileInfo( this._store, this._timestampManager, this._threadMap, @@ -213,6 +219,22 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + async counterList(): Promise> { + const result = collectCounterList(this._store, this._threadMap); + return { ...result, context: this._getContext() }; + } + + async counterInfo( + counterHandle: string + ): Promise> { + const result = collectCounterInfo( + this._store, + this._threadMap, + counterHandle + ); + return { ...result, context: this._getContext() }; + } + async threadInfo( threadHandle?: string ): Promise> { diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5cc2983a99..65e1135b3a 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -7,7 +7,11 @@ * These types are used by both profile-query (the library) and profiler-cli. */ -import type { Transform } from 'firefox-profiler/types'; +import type { + Transform, + CounterGraphType, + CounterTooltipDataSource, +} from 'firefox-profiler/types'; // ===== Utility types ===== @@ -662,6 +666,59 @@ export type ThreadPageLoadResult = { jankPeriods: JankPeriod[]; // limited by jankLimit }; +// ===== Counter Commands ===== + +/** + * A single range-aggregate stat for a counter, derived from one of the + * counter's own `display.tooltipRows`. Only the rows whose data source is a + * whole-range aggregate (`count-range`, `committed-range-total`) are surfaced; + * per-sample and preview-selection rows have no CLI equivalent and are skipped. + * `label`, `value`, and `formattedValue` come straight from the tooltip schema, + * so the CLI and the timeline tooltips stay in lockstep. + */ +export type CounterStat = { + source: CounterTooltipDataSource; + label: string; + value: number; + formattedValue: string; + carbon?: string; +}; + +/** + * One-line summary of a counter, shared by `counter list`, `counter info`, and + * the `profile info --counters` section. The `stats` cover the current + * committed (zoom) range, or the whole profile when not zoomed. + */ +export type CounterSummary = { + counterHandle: string; // e.g. "c-0" + counterIndex: number; + name: string; // raw counter name, e.g. "malloc" + label: string; // display.label || name, e.g. "Memory" + category: string; // e.g. "Memory" + unit: string; // display.unit, e.g. "bytes" + graphType: CounterGraphType; + color: string; + pid: string; + mainThreadIndex: number; + mainThreadHandle: string; // e.g. "t-0" + mainThreadName: string; + rangeSampleCount: number; // samples within the current range + stats: CounterStat[]; // range-aggregate stats from the tooltip schema +}; + +export type CounterListResult = { + type: 'counter-list'; + counters: CounterSummary[]; +}; + +export type CounterInfoResult = CounterSummary & { + type: 'counter-info'; + description: string; + sampleCount: number; // total samples in the counter (whole profile) + rangeStart: number | null; // absolute time of first in-range sample + rangeEnd: number | null; // absolute time of last in-range sample +}; + // ===== Profile Commands ===== export type ProfileInfoResult = { @@ -694,6 +751,7 @@ export type ProfileInfoResult = { combinedCpuMs: number; maxCpuMs: number; }; + counters?: CounterSummary[]; }>; remainingProcesses?: { count: number; diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 75b3121bdb..896ca26b27 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -16,6 +16,7 @@ import { getFriendlyThreadName, processCounter, getInclusiveSampleIndexRangeForSelection, + getSampleIndexRangeForSelection, computeInnerWindowIDToTabMap, computeTabToThreadIndexesMap, computeStackTableFromRawStackTable, @@ -379,6 +380,23 @@ function _createCounterSelectors(counterIndex: CounterIndex) { ) ); + const getCommittedRangeCounterSampleSum: Selector = createSelector( + getCounter, + getCommittedRange, + (counter, range) => { + const [begin, end] = getSampleIndexRangeForSelection( + counter.samples, + range.start, + range.end + ); + let sum = 0; + for (let i = begin; i < end; i++) { + sum += counter.samples.count[i]; + } + return sum; + } + ); + return { getCounter, getDescription, @@ -387,6 +405,7 @@ function _createCounterSelectors(counterIndex: CounterIndex) { getMaxCounterSampleCountPerMs, getMaxRangeCounterSampleCountPerMs, getCommittedRangeCounterSampleRange, + getCommittedRangeCounterSampleSum, }; } diff --git a/src/test/components/Root-history.test.tsx b/src/test/components/Root-history.test.tsx index 13a130aa71..adacbd6510 100644 --- a/src/test/components/Root-history.test.tsx +++ b/src/test/components/Root-history.test.tsx @@ -13,6 +13,7 @@ import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { fireFullClick } from '../fixtures/utils'; import { getProfileUrlForHash } from '../../utils/profile-fetch'; import { blankStore } from '../fixtures/stores'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { autoMockFullNavigation, @@ -198,5 +199,5 @@ describe('Root with history', function () { function mockFetchProfileAtUrl(url: string, profile: Profile): void { window.fetchMock .catch(404) // catchall - .get(url, profile); + .get(url, serializeProfileToJsonString(profile)); } diff --git a/src/test/components/UrlManager.test.tsx b/src/test/components/UrlManager.test.tsx index 86d9f4ef3d..e5811d062a 100644 --- a/src/test/components/UrlManager.test.tsx +++ b/src/test/components/UrlManager.test.tsx @@ -18,6 +18,7 @@ import { waitUntilState } from '../fixtures/utils'; import { createGeckoProfile } from '../fixtures/profiles/gecko-profile'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { CURRENT_URL_VERSION } from '../../app-logic/url-handling'; +import { serializeProfileToJsonString } from '../../profile-logic/process-profile'; import { autoMockFullNavigation } from '../fixtures/mocks/window-navigation'; import { profilePublished } from 'firefox-profiler/actions/publish'; import { @@ -35,7 +36,7 @@ describe('UrlManager', function () { autoMockFullNavigation(); function getSerializableProfile() { - return getProfileFromTextSamples('A').profile; + return serializeProfileToJsonString(getProfileFromTextSamples('A').profile); } function setup(urlPath?: string) { diff --git a/src/test/fixtures/profiles/call-nodes.ts b/src/test/fixtures/profiles/call-nodes.ts index 909b48e7e7..0ffed2dd52 100644 --- a/src/test/fixtures/profiles/call-nodes.ts +++ b/src/test/fixtures/profiles/call-nodes.ts @@ -6,7 +6,8 @@ import type { FuncTable, FrameTable, Profile } from 'firefox-profiler/types'; import { getEmptyThread, getEmptyProfile, - getEmptyRawStackTable, + getRawStackTableBuilder, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { StringTable } from '../../../utils/string-table'; @@ -87,7 +88,7 @@ export default function getProfile(): Profile { length: frameFuncs.length, }; - const stackTable = getEmptyRawStackTable(); + const stackTable = getRawStackTableBuilder(); // Provide a utility function for readability. function addToStackTable(frame: any, prefix: any) { @@ -119,7 +120,12 @@ export default function getProfile(): Profile { length: 2, }; - profile.shared = { ...profile.shared, stackTable, funcTable, frameTable }; + profile.shared = { + ...profile.shared, + stackTable: finishRawStackTableBuilder(stackTable), + funcTable, + frameTable, + }; profile.threads.push(thread, { ...thread }, { ...thread }); diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 07ef1d7bab..708018511f 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -8,6 +8,8 @@ import { getEmptyJsAllocationsTable, getEmptyUnbalancedNativeAllocationsTable, getEmptyBalancedNativeAllocationsTable, + getRawStackTableBuilderWithExistingContents, + finishRawStackTableBuilder, } from '../../../profile-logic/data-structures'; import { mergeProfilesForDiffing } from '../../../profile-logic/merge-compare'; import { computeReferenceCPUDeltaPerMs } from '../../../profile-logic/cpu'; @@ -983,7 +985,7 @@ function _buildThreadFromTextOnlyStacks( } // Attempt to find a stack that satisfies the given frameIndex and prefix. - const stackTable = globalDataCollector.getStackTable(); + const stackTable = globalDataCollector.getStackTableBuilder(); let stackIndex; for (let i = 0; i < stackTable.length; i++) { if ( @@ -1994,8 +1996,10 @@ function getStackIndexForCallNodePath( /** * Use this function to add window id information to frames, using call node * paths to point to frames using stacks. + * This mutates `shared`. * * @param thread The thread to mutate. + * @param shared The shared profile data to mutate. * @param listOfOperations A list of pairs { innerWindowID, callNodes } * indicating which call nodes this innerWindowID will * be assigned to. @@ -2037,6 +2041,9 @@ export function addInnerWindowIdToStacks( IndexIntoStackTable >(); + const stackTableBuilder = + getRawStackTableBuilderWithExistingContents(stackTable); + for (const callNode of callNodesToDupe) { const stackIndex = getStackIndexForCallNodePath(shared, callNode); const foundFrameIndex = stackTable.frame[stackIndex]; @@ -2062,14 +2069,16 @@ export function addInnerWindowIdToStacks( frameTable.innerWindowID.push(listOfOperations[1].innerWindowID); // Clone the stack - const newStackIndex = stackTable.length++; - stackTable.prefix.push(stackTable.prefix[stackIndex]); + const newStackIndex = stackTableBuilder.length++; + stackTableBuilder.prefix.push(stackTable.prefix[stackIndex]); // Using the cloned frame index. - stackTable.frame.push(newFrameIndex); + stackTableBuilder.frame.push(newFrameIndex); mapStackIndexToDupe.set(stackIndex, newStackIndex); } + shared.stackTable = finishRawStackTableBuilder(stackTableBuilder); + const sampleTimes = ensureExists(samples.time); for (let sampleIndex = samples.length; sampleIndex >= 0; sampleIndex--) { // We're looping from the end because we'll push some samples to the end diff --git a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap index 9e919e59d0..8ffda5719f 100644 --- a/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap +++ b/src/test/integration/profiler-edit/__snapshots__/profiler-edit.test.ts.snap @@ -87,7 +87,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -1452,7 +1452,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -2817,7 +2817,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { @@ -4182,7 +4182,7 @@ Object { "markerSchema": Array [], "oscpu": "macOS 14.6.1", "pausedRanges": Array [], - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "a.out", "sampleUnits": Object { diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index c342a59f5a..9bf40c02a2 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -423,7 +423,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sourceURL": "", @@ -676,7 +676,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -2721,7 +2721,7 @@ CallTree { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3109,7 +3109,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3181,68 +3181,70 @@ Object { `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getFlameGraphTiming 1`] = ` -Array [ - Object { - "callNode": Array [ - 0, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 1, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 5, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 0, - ], - "start": Array [ - 0, - ], - }, - Object { - "callNode": Array [ - 6, - ], - "end": Array [ - 1, - ], - "length": 1, - "selfRelative": Array [ - 1, - ], - "start": Array [ - 0, - ], - }, -] +Object { + "rows": Array [ + Object { + "callNode": Array [ + 0, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 1, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 5, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 0, + ], + "start": Array [ + 0, + ], + }, + Object { + "callNode": Array [ + 6, + ], + "end": Array [ + 1, + ], + "length": 1, + "selfRelative": Array [ + 1, + ], + "start": Array [ + 0, + ], + }, + ], +} `; exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getJankMarkersForHeader 1`] = ` @@ -3563,7 +3565,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -3949,7 +3951,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -4335,7 +4337,7 @@ Object { 0, 0, ], - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/store/actions.test.ts b/src/test/store/actions.test.ts index 7c2b2f17c6..4d26391777 100644 --- a/src/test/store/actions.test.ts +++ b/src/test/store/actions.test.ts @@ -206,7 +206,9 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ callNode, end, length, start }) => { + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); + + return flameGraphTimingRows.map(({ callNode, end, length, start }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; @@ -239,8 +241,9 @@ describe('selectors/getFlameGraphTiming', function () { const flameGraphTiming = selectedThreadSelectors.getFlameGraphTiming( store.getState() ); + const flameGraphTimingRows = flameGraphTiming.getAllRowsForTesting(); - return flameGraphTiming.map(({ selfRelative, callNode, length }) => { + return flameGraphTimingRows.map(({ selfRelative, callNode, length }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 2b830e08c5..9fda33ec65 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -2385,9 +2385,13 @@ describe('snapshots of selectors/profile', function () { it('matches the last stored run of selectedThreadSelector.getFlameGraphTiming', function () { const { getState } = setupStore(); - expect( - selectedThreadSelectors.getFlameGraphTiming(getState()) - ).toMatchSnapshot(); + const timing = selectedThreadSelectors.getFlameGraphTiming(getState()); + // Build a plain shape with the flame graph timing contents, so that the + // snapshot doesn't contain the class name / class fields. + const plainTiming = { + rows: timing.getAllRowsForTesting(), + }; + expect(plainTiming).toMatchSnapshot(); }); it('matches the last stored run of selectedThreadSelector.getFriendlyThreadName', function () { diff --git a/src/test/store/receive-profile.test.ts b/src/test/store/receive-profile.test.ts index f480f467c9..7a660fa865 100644 --- a/src/test/store/receive-profile.test.ts +++ b/src/test/store/receive-profile.test.ts @@ -879,7 +879,10 @@ describe('actions/receive-profile', function () { const hash = 'c5e53f9ab6aecef926d4be68c84f2de550e2ac2f'; const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileFromStore(hash)); @@ -939,7 +942,7 @@ describe('actions/receive-profile', function () { const expectedUrl = `https://storage.googleapis.com/profile-store/${hash}`; window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1026,7 +1029,10 @@ describe('actions/receive-profile', function () { it('can retrieve a profile from the web and save it to state', async function () { const expectedUrl = 'https://profiles.club/shared.json'; - window.fetchMock.get(expectedUrl, _getSimpleProfile()); + window.fetchMock.get( + expectedUrl, + serializeProfileToJsonString(_getSimpleProfile()) + ); const store = blankStore(); await store.dispatch(retrieveProfileOrZipFromUrl(expectedUrl)); @@ -1063,7 +1069,7 @@ describe('actions/receive-profile', function () { // The first call will still be a 403 -- remember, it's the default return value. window.fetchMock .getOnce(expectedUrl, 403) - .get(expectedUrl, _getSimpleProfile()); + .get(expectedUrl, serializeProfileToJsonString(_getSimpleProfile())); const store = blankStore(); const views = ( @@ -1526,7 +1532,9 @@ describe('actions/receive-profile', function () { ]) ); } - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); const { dispatch, getState } = blankStore(); await dispatch(retrieveProfilesToCompare([url1, url2])); @@ -1793,7 +1801,9 @@ describe('actions/receive-profile', function () { it('gives a fatal error when the selected thread index is out of bounds', async function () { const { dispatch, getState } = blankStore(); const { profile1, profile2 } = getSomeProfiles(); - window.fetchMock.getOnce('*', profile1).getOnce('*', profile2); + window.fetchMock + .getOnce('*', serializeProfileToJsonString(profile1)) + .getOnce('*', serializeProfileToJsonString(profile2)); await dispatch( retrieveProfilesToCompare([ @@ -1813,7 +1823,10 @@ describe('actions/receive-profile', function () { location: Partial, requiredProfile: number = 1 ) { - const profile = _getSimpleProfile(); + const simpleProfile = _getSimpleProfile(); + // Create a profile object we can use with fetchMock, which uses JSON.stringify internally. + // `simpleProfile` may contain typed arrays which wouldn't survive JSON.stringfy. + const profile = JSON.parse(serializeProfileToJsonString(simpleProfile)); const geckoProfile = createGeckoProfile(); // Add mock fetch response for the required number of times. diff --git a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap index 1ef1824e9b..de79c9b9c5 100644 --- a/src/test/unit/__snapshots__/profile-conversion.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-conversion.test.ts.snap @@ -591,7 +591,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -43096,7 +43096,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -83833,7 +83833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "ART Trace (Android)", "sampleUnits": undefined, @@ -167262,7 +167262,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -329278,7 +329278,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -329817,7 +329817,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -364259,7 +364259,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 119159778.026, @@ -364798,7 +364798,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -399219,7 +399219,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -400087,7 +400087,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -401927,7 +401927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -401993,7 +401993,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [], + "frame": Int32Array [], "length": 0, "prefix": Array [], }, @@ -403765,7 +403765,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 355035987.653, @@ -406722,7 +406722,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -407776,7 +407776,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "sourceURL": "", @@ -411314,7 +411314,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -413399,7 +413399,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Chrome Trace", "profilingEndTime": 66155012.423, @@ -414267,7 +414267,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -414544,7 +414544,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -417285,7 +417285,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -418704,7 +418704,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -429901,7 +429901,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -434833,7 +434833,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -439076,7 +439076,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -441509,7 +441509,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -454858,7 +454858,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -459410,7 +459410,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -499592,7 +499592,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -521502,7 +521502,7 @@ Object { "keepProfileThreadOrder": true, "markerSchema": Array [], "platform": "Android", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "com.example.sampleapplication", "sourceCodeIsNotOnSearchfox": true, @@ -559132,7 +559132,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -587927,7 +587927,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "target/debug/examples/work_log (dhat)", "sourceURL": "", @@ -594273,7 +594273,7 @@ Object { ], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 56, 55, @@ -597177,7 +597177,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -600033,7 +600033,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -905382,7 +905382,7 @@ Object { "oscpu": "", "physicalCPUs": 0, "platform": "", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Flamegraph", "sourceURL": "", @@ -905556,7 +905556,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, diff --git a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap index bd079be0b9..3715a6c4fa 100644 --- a/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap +++ b/src/test/unit/__snapshots__/profile-upgrading.test.ts.snap @@ -40,7 +40,7 @@ Object { "oscpu": undefined, "physicalCPUs": undefined, "platform": undefined, - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "sampleUnits": undefined, @@ -2781,7 +2781,7 @@ Object { "startLine": Array [], }, "stackTable": Object { - "frame": Array [ + "frame": Int32Array [ 0, 1, 2, @@ -7643,7 +7643,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -9019,7 +9019,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, @@ -10563,7 +10563,7 @@ Object { "misc": "rv:48.0", "oscpu": "Intel Mac OS X 10.11", "platform": "Macintosh", - "preprocessedProfileVersion": 64, + "preprocessedProfileVersion": 65, "processType": 0, "product": "Firefox", "stackwalk": 1, diff --git a/src/test/unit/json-with-typed-arrays.test.ts b/src/test/unit/json-with-typed-arrays.test.ts new file mode 100644 index 0000000000..2ba9a2f070 --- /dev/null +++ b/src/test/unit/json-with-typed-arrays.test.ts @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { jsonEncodeObjectWithTypedArraysAsRegularArrays as encode } from '../../utils/json-with-typed-arrays'; + +describe('jsonEncodeObjectWithTypedArraysAsRegularArrays', function () { + it('encodes primitives the same as JSON.stringify', function () { + expect(encode(42)).toBe('42'); + expect(encode('hello')).toBe('"hello"'); + expect(encode(null)).toBe('null'); + expect(encode(true)).toBe('true'); + }); + + it('encodes a plain object the same as JSON.stringify', function () { + const obj = { a: 1, b: 'two', c: [1, 2, 3], d: { nested: true } }; + expect(encode(obj)).toBe(JSON.stringify(obj)); + }); + + it('encodes a top-level typed array as a regular array of numbers', function () { + const arr = new Int32Array([1, 2, 3, 4]); + expect(encode(arr)).toBe('[1,2,3,4]'); + }); + + it('encodes an empty typed array as an empty array', function () { + expect(encode(new Uint8Array())).toBe('[]'); + }); + + it('handles all typed array variants', function () { + expect(encode(new Int8Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint8Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Uint8ClampedArray([1, 2]))).toBe('[1,2]'); + expect(encode(new Int16Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint16Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Int32Array([1, -2]))).toBe('[1,-2]'); + expect(encode(new Uint32Array([1, 2]))).toBe('[1,2]'); + expect(encode(new Float32Array([1.5]))).toBe('[1.5]'); + expect(encode(new Float64Array([1.5]))).toBe('[1.5]'); + }); + + it('encodes typed arrays nested inside an object', function () { + const obj = { + name: 'data', + values: new Int32Array([10, 20, 30]), + }; + expect(encode(obj)).toBe('{"name":"data","values":[10,20,30]}'); + }); + + it('encodes typed arrays nested inside an array', function () { + const arr = [1, new Uint8Array([2, 3]), 4]; + expect(encode(arr)).toBe('[1,[2,3],4]'); + }); + + it('encodes deeply nested typed arrays', function () { + const obj = { + a: { + b: { + c: [ + { d: new Float32Array([1.5, 2.5]) }, + { e: new Int16Array([7, 8]) }, + ], + }, + }, + }; + expect(encode(obj)).toBe('{"a":{"b":{"c":[{"d":[1.5,2.5]},{"e":[7,8]}]}}}'); + }); + + it('does not mutate the original object', function () { + const typedArr = new Int32Array([1, 2, 3]); + const inner = { values: typedArr, label: 'x' }; + const obj = { inner, count: 2 }; + const originalKeys = Object.keys(obj); + const innerKeys = Object.keys(inner); + + encode(obj); + + expect(obj.inner).toBe(inner); + expect(inner.values).toBe(typedArr); + expect(inner.label).toBe('x'); + expect(obj.count).toBe(2); + expect(Object.keys(obj)).toEqual(originalKeys); + expect(Object.keys(inner)).toEqual(innerKeys); + }); + + it('does not mutate the original array', function () { + const typedArr = new Int32Array([1, 2, 3]); + const arr = [1, typedArr, 3]; + + encode(arr); + + expect(arr[1]).toBe(typedArr); + expect(arr).toEqual([1, typedArr, 3]); + }); + + it('leaves DataView alone (encoded as a regular object)', function () { + // DataView is excluded from typed-array handling and falls through + // to the regular object path. JSON.stringify on a DataView produces "{}". + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + expect(encode(view)).toBe(JSON.stringify(view)); + }); + + it('handles null values inside objects and arrays', function () { + const obj = { a: null, b: [null, 1, null], c: new Int32Array([5]) }; + expect(encode(obj)).toBe('{"a":null,"b":[null,1,null],"c":[5]}'); + }); + + it('handles an empty object and an empty array', function () { + expect(encode({})).toBe('{}'); + expect(encode([])).toBe('[]'); + }); + + it('preserves array element order with typed arrays interleaved', function () { + const arr = [ + new Uint8Array([1]), + 'middle', + new Uint8Array([2]), + 42, + new Uint8Array([3]), + ]; + expect(encode(arr)).toBe('[[1],"middle",[2],42,[3]]'); + }); + + it('encodes the same shared typed array twice when it appears in two places', function () { + const shared = new Int32Array([7, 8, 9]); + const obj = { first: shared, second: shared }; + expect(encode(obj)).toBe('{"first":[7,8,9],"second":[7,8,9]}'); + }); +}); diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index 185328c970..eec3b04b6e 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -17,7 +17,10 @@ */ import { ProfileQuerier } from 'firefox-profiler/profile-query'; -import { getProfileFromTextSamples } from '../../fixtures/profiles/processed-profile'; +import { + getProfileFromTextSamples, + getCounterForThread, +} from '../../fixtures/profiles/processed-profile'; import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; import { storeWithProfile } from '../../fixtures/stores'; @@ -344,6 +347,111 @@ describe('ProfileQuerier', function () { }); }); + describe('counters', function () { + function profileWithMemoryCounter() { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + B B B + `); + const counter = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + hasCountNumber: true, + }); + profile.counters = [counter]; + return { profile, counter }; + } + + function querierFor(profile: Parameters[0]) { + const store = storeWithProfile(profile); + return new ProfileQuerier(store, getProfileRootRange(store.getState())); + } + + it('counterList returns a schema-driven summary per counter', async function () { + const { profile } = profileWithMemoryCounter(); + const result = await querierFor(profile).counterList(); + + expect(result.counters).toHaveLength(1); + expect(result.counters[0].counterHandle).toBe('c-0'); + expect(result.counters[0].label).toBe('Memory'); + expect( + result.counters[0].stats.some((s) => s.source === 'count-range') + ).toBe(true); + }); + + it('orders counters by display.sortWeight, not profile order', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + `); + // Listed Memory-first, but Bandwidth (sortWeight 10) should sort before + // Memory (sortWeight 20), matching the timeline track order. + const memory = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + }); + const bandwidth = getCounterForThread(profile.threads[0], 0, { + name: 'eth0', + category: 'Bandwidth', + }); + profile.counters = [memory, bandwidth]; + + const result = await querierFor(profile).counterList(); + + expect(result.counters.map((c) => c.label)).toEqual([ + 'Bandwidth', + 'Memory', + ]); + expect(result.counters.map((c) => c.counterHandle)).toEqual([ + 'c-1', + 'c-0', + ]); + }); + + it('profileInfo nests each counter under its owning process', async function () { + const { profile, counter } = profileWithMemoryCounter(); + const info = await querierFor(profile).profileInfo(); + + const owningProcess = info.processes.find((p) => p.pid === counter.pid); + expect(owningProcess).toBeDefined(); + expect(owningProcess!.counters?.map((c) => c.counterHandle)).toEqual([ + 'c-0', + ]); + }); + + it('counterInfo resolves a counter by handle', async function () { + const { profile } = profileWithMemoryCounter(); + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.counterHandle).toBe('c-0'); + expect(info.description).toBe('My Description'); + expect(info.sampleCount).toBeGreaterThan(0); + }); + + it('counterInfo throws for an unknown handle', async function () { + const { profile } = profileWithMemoryCounter(); + await expect(querierFor(profile).counterInfo('c-9')).rejects.toThrow( + 'Unknown counter c-9' + ); + }); + + it('returns no counters for a profile without any', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 + A A A + `); + + const querier = querierFor(profile); + const list = await querier.counterList(); + + expect(list.counters).toEqual([]); + await expect(querier.counterInfo('c-0')).rejects.toThrow( + 'Unknown counter c-0' + ); + }); + }); + describe('threadSamples', function () { it('searches all roots when choosing the heaviest stack', async function () { const { profile } = getProfileFromTextSamples(` diff --git a/src/test/unit/sanitize.test.ts b/src/test/unit/sanitize.test.ts index 70d43c9ba6..c22f1ad41b 100644 --- a/src/test/unit/sanitize.test.ts +++ b/src/test/unit/sanitize.test.ts @@ -1275,4 +1275,21 @@ describe('sanitizePII', function () { 'file2.js' ); }); + + it('always removes source contents even when no PII removal is requested', function () { + const { profile } = getProfileFromTextSamples(`A[file:file1.js]`); + // There should be only one source. + expect(profile.shared.sources.content.length).toEqual(1); + // Pretend the source content have been loaded. + profile.shared.sources.content[0] = 'source code'; + + // A null `RemoveProfileInformation` means the user kept all sharing options + // checked, so no other sanitization happens. + const { profile: sanitizedProfile } = sanitizePII(profile, [], null, {}); + + expect(sanitizedProfile.shared.sources.content.length).toEqual( + profile.shared.sources.content.length + ); + expect(sanitizedProfile.shared.sources.content[0]).toBe(null); + }); }); diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 75325be75c..89e7cfab68 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -29,7 +29,6 @@ import type { JsTracerTable, IndexIntoStackTable, WeightType, - IndexIntoFrameTable, SourceTable, IndexIntoSourceTable, CounterDisplayConfig, @@ -200,7 +199,8 @@ export type Counter = { * The `StackTable` type of the derived thread. * * The only difference from the `RawStackTable` is that the `StackTable` has a - * `category` and a `subcategory` column. + * `category` and a `subcategory` column, and that the `StackTable` always uses + * a typed array for the `frame` column. * * The category of a stack node is always non-null and is derived from a stack's * frame and its prefix. Frames can have null categories, stacks cannot. If a @@ -225,8 +225,7 @@ export type Counter = { * nsAttrAndChildArray::InsertChildAt stack before transforms are applied. */ export type StackTable = { - // Same as in RawStackTable - frame: IndexIntoFrameTable[]; + frame: Int32Array; prefix: Array; length: number; diff --git a/src/types/profile.ts b/src/types/profile.ts index ad504ac933..fd1ce620f8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -60,7 +60,7 @@ export type Pid = string; * to storing them as actual lists of frames. */ export type RawStackTable = { - frame: IndexIntoFrameTable[]; + frame: IndexIntoFrameTable[] | Int32Array; prefix: Array; length: number; }; diff --git a/src/utils/json-with-typed-arrays.ts b/src/utils/json-with-typed-arrays.ts new file mode 100644 index 0000000000..9ab283f35c --- /dev/null +++ b/src/utils/json-with-typed-arrays.ts @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * JSON.stringify an object which may contain typed arrays somewhere in + * its structure (nested arbitrarily deeply), with those typed arrays + * serialized as regular arrays of numbers. + * + * Calling JSON.stringify on a typed array would normally give you something + * like `{"0": 1, "1": 2}` which is not what you want. + * + * This function does not mutate rootObject. + */ +export function jsonEncodeObjectWithTypedArraysAsRegularArrays( + rootObject: unknown +): string { + // We could use JSON.stringify with a "replacer" here. + // But instead, we do a full traversal of the object first, possibly + // creating new objects (so that the original doesn't get mutated), + // and then a regular JSON.stringify with no replacer. This is 5x faster. + return JSON.stringify(rewriteTypedArrays(rootObject)); +} + +function rewriteTypedArrays(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + // Found a typed array! Use Array.from to convert it to a regular + // array of numbers. + return Array.from(value as unknown as Iterable); + } + if (Array.isArray(value)) { + return rewriteTypedArraysInArray(value); + } + return rewriteTypedArraysInObject(value as Record); +} + +function rewriteTypedArraysInArray(arr: readonly unknown[]): unknown[] { + let result: unknown[] | null = null; + for (let i = 0; i < arr.length; i++) { + const el = arr[i]; + // Inline fast-path for primitives: an array of 1000 numbers walks this + // loop without any function call. + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = arr.slice(); + } + result[i] = replaced; + } + } + return result ?? (arr as unknown[]); +} + +function rewriteTypedArraysInObject( + obj: Record +): Record { + let result: Record | null = null; + for (const key in obj) { + if (!Object.hasOwn(obj, key)) { + continue; + } + const el = obj[key]; + if (el === null || typeof el !== 'object') { + continue; + } + const replaced = rewriteTypedArrays(el); + if (replaced !== el) { + if (result === null) { + result = { ...obj }; + } + result[key] = replaced; + } + } + return result ?? obj; +} diff --git a/yarn.lock b/yarn.lock index 500d485b0f..c7c896af88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1123,10 +1123,10 @@ dependencies: "@marijn/find-cluster-break" "^1.0.0" -"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.43.0": - version "6.43.0" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.0.tgz#a577da65f1d5d8f7cbf08e14849284c12f38365a" - integrity sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA== +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.43.1": + version "6.43.1" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.1.tgz#bfe3ea568e38812631749c1b6c9c7869784143a5" + integrity sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw== dependencies: "@codemirror/state" "^6.6.0" crelt "^1.0.6" @@ -1143,10 +1143,10 @@ resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.4.tgz#8473f63e2fcd6e459838dd412401d5948f224c65" integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== -"@csstools/css-calc@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.2.0.tgz#15ca1a80a026ced0f6c4e12124c398e3db8e1617" - integrity sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w== +"@csstools/css-calc@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.2.1.tgz#b30e061ca9f297ccb2b3b032bfee32fda02b1b27" + integrity sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg== "@csstools/css-color-parser@^3.0.9": version "3.0.10" @@ -1166,10 +1166,10 @@ resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164" integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w== -"@csstools/css-syntax-patches-for-csstree@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz#3204cf40deb97db83e225b0baa9e37d9c3bd344d" - integrity sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg== +"@csstools/css-syntax-patches-for-csstree@^1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz#b8e26e0fe25e9a6ec607d045470cc46d2f62731e" + integrity sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A== "@csstools/css-tokenizer@^3.0.3": version "3.0.4" @@ -1916,100 +1916,100 @@ resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-1.0.1.tgz#77ecd0a4fcb772ba6fe927e2e2e155fbec2e6b1a" integrity sha512-qq3oEfcLFwNfEYOQ8HLimRGKlD8WSeGEdtUa7hmzpR8Sa7haL1KVQrvgO6wqMjhWFFVjgtrh1gIxDz+P8sjUaA== -"@oxfmt/binding-android-arm-eabi@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.51.0.tgz#aa1b9ef3c637deff8d068f407c5025557a140327" - integrity sha512-Ni0sCqg5CIHaLIYFGj+ncbcumylvNC6FE4rfD0KfdmnWHbPJ+zev0qZCXKxy2hFVa0fYRK0yPzf5nzPbkZou7g== - -"@oxfmt/binding-android-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.51.0.tgz#608bc9ec7c1b715b1a17ee2081fc86b6eced0a2b" - integrity sha512-eu5lAZjuo0KAkp+M24EhDqfOwA8owQ8d7wyBlOUUGRbDLHpU3IRlDHp8Dif+YqGlxs6jra7yS6WQu/NkPhAxeg== - -"@oxfmt/binding-darwin-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.51.0.tgz#86051323e7d879e780baed2d0e7ee892a766c131" - integrity sha512-6LsUNIdURhhcIfIn8+xsOb61mSTa9msAHTeSGx9Jf4rsP/gN8PGCF+SKWPAQZbND2w/WBkqQ6303jqEEIXzMdQ== - -"@oxfmt/binding-darwin-x64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.51.0.tgz#56be4aac362bd6e18eec8ae6e327c94ab6849a91" - integrity sha512-9aUMGmVxdHjYMsEAW1tNRoieTJXlVNDFkRvIR1J7LttJXWjVYCu2ekclLij2KJtxBxSQOYSHd12ME/adVGVbZg== - -"@oxfmt/binding-freebsd-x64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.51.0.tgz#bc980f22a7e2fa1a176c2d222bb2857dc22aa00c" - integrity sha512-mkY1nhZTqYb+NHaAWxOCKISN6FwdrwMNsu17vTUA3wzUV2VJ+Paq15ZokRcsMU/2PUdHO73prxyeJpjXQ3MPpQ== - -"@oxfmt/binding-linux-arm-gnueabihf@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.51.0.tgz#de06f649a6407063a6459a7486991f0b64c12936" - integrity sha512-wtFwNwE4+YCNuPaWoGDZeGsKvD6D1YSUNBJNn/rJBh7CrDBThFE+TBI5kY7vRW9rIOQRsbW2IpyyL3Du4Zqwiw== - -"@oxfmt/binding-linux-arm-musleabihf@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.51.0.tgz#b663b0d55e00098618b2b09e7f884080e90c1d1e" - integrity sha512-rnOaNx86G7iRKM6lsCIQMux0SMGNC/TEbFR+r7lpruJ12bnrIWgxd5w1PLqOvgR9r8ZJbpK/zfRKctJnh8/Jfg== - -"@oxfmt/binding-linux-arm64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.51.0.tgz#e03c66a41d1767319f8bcc9801c8c37ad8f00d1c" - integrity sha512-jOgDzSqWcICGRjsp4mc08FxKMN8vzP2Kgs4E0d2HUP99F+nJDQKklRV4Zuj+0gcBgjrzx2CbpqaIdUVPepCojA== - -"@oxfmt/binding-linux-arm64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.51.0.tgz#1c8b05a590ceb4706aa233cacc3058605f6a4a1f" - integrity sha512-KBUCdrH5bwVrAvI9gU/1S55oH6fzXjr++J/oVocdu7bYTks1l7DNNT+rLd/1TDdAEjObGwmfWamn7LC1m8A0DQ== - -"@oxfmt/binding-linux-ppc64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.51.0.tgz#3f96b0b6dfb4fd5fc8085ca415b6d7f625f15179" - integrity sha512-NapfjYsABFqTJ1Dn9Efq6sN5esaHconVKwVLbDGNQLrwpOx/g17mkwErHzU72PutL67nf3wNAkbq122H+zLxag== - -"@oxfmt/binding-linux-riscv64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.51.0.tgz#1a8ac28e46c9bec9ea3e83608b9fc1f4e5e91d99" - integrity sha512-5dlDt1dUZCVi6elIhiK1PWg9wpTzTcIuj0IZnSurvIoMrhOWqqTcc1dSTxcSkNaBZhfsNqRZdINI1zAgbKkJNQ== - -"@oxfmt/binding-linux-riscv64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.51.0.tgz#d87cf83be567a24ee5cf66c88a630aaf5daa4d7f" - integrity sha512-pgdWUJn0S5nulyiVdlFV8DzCUnGXkU99W5PSkkmbaZW+LrZBPxpezun4G0DDHbQaVYuJeCuKsXsGKGo77CkUTQ== - -"@oxfmt/binding-linux-s390x-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.51.0.tgz#2c56624067b7a55443f9fbbbbf7c3947856acbd5" - integrity sha512-2XTFUe97CbDGAI8vjwDfZ1HdakO0XIADyJ24idEg64SC4/K4in/OisXVnrW4NMK7I6TgC7EqRhC0Ln/nKhAemA== - -"@oxfmt/binding-linux-x64-gnu@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.51.0.tgz#a5208625ff9000a47e5d1b1e32ff8442f7e81ffd" - integrity sha512-kQ1OuCqqt/yyf0ZN9VFxW1/JnlgJgii3Dr7pWf9vNBvrX1hv6g39/+mc5oGRHRGJFZtl3zsGDWR9c5N2B/gwBw== - -"@oxfmt/binding-linux-x64-musl@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.51.0.tgz#52e2f148abb8114d879b141f23c48685c25873e1" - integrity sha512-ARTYqxHF475o96Gbn41hvSWSSRygPlRDXZZgZ9I2scU1y0qiWpCQyZCoefaQa0mwv+wwtZ+luS4YOzsRzM/izg== - -"@oxfmt/binding-openharmony-arm64@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.51.0.tgz#deca8a3a3cc4df3e797ce8ed46b8dfd04b432e63" - integrity sha512-QiC1XrCl6a6BmqMzduO8hdIRMf1m44hCkt2Q68KWkTvUB/E7fd2iomyNh6KnnRca5w6eBrRAAtLFqTh+xjsjJA== - -"@oxfmt/binding-win32-arm64-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.51.0.tgz#87bf318e600b5c922e88bd0c96a521d1e4e0bd0d" - integrity sha512-NC/hJb9dtU23Zf8L7IVK95xnFjiQ7AfcLO2l5pb69TDEr958qxrtnB2CveeeNSCBFNIkgaTCfd/vHNSoG78l9g== - -"@oxfmt/binding-win32-ia32-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.51.0.tgz#f1bf8ab8cc5ea6a551f8be87f7e06966bbe137c3" - integrity sha512-2C45za4Rj36n8YIbhRL1PQbxmXJYf81WEcAgvj5I4ptRROG+A+81hREEN5bmCHADE1UfYaN312U6tkILoZZy6w== - -"@oxfmt/binding-win32-x64-msvc@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.51.0.tgz#396678bccf40060a685cbfe8fa1061cb66a1b5d6" - integrity sha512-73RqdAuVKQTkjZIDw08JaDHUM4lav5Qu+CaPwg4QbbA7k8o7LEW0p3UsfZ/F8dsO/pwVYh3RzFcanwLRTTahbQ== +"@oxfmt/binding-android-arm-eabi@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.56.0.tgz#beb31cb658cc73b560b69b82beda80c6f673968b" + integrity sha512-CSCxi7ovYojgfdPOdUb9T508HKeAdDIKeRGg7x8IZwVJrWz9gVgX7MbUnFqtQAE4QvoNo07mj2JlwnOzJw4qqA== + +"@oxfmt/binding-android-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.56.0.tgz#61d1043c4e818457b079fab2e4b3a4a906f1ed8d" + integrity sha512-HYJFnd+PkDwf6S9ZPGzXXtjNqvRWFnnhdbWaouh4mi/SxU8wmDuzlMn3xo/wDTGnr4Q1VA7ZzOaE/D4biW0W6A== + +"@oxfmt/binding-darwin-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.56.0.tgz#d5af60434c52e2f742b31f7b85feb9543aff8501" + integrity sha512-sftR/bEOr+t1gs+evwsHi/Xbq2FAPA2uU3VMr8n6ZU9PoK/IMSfnfu7+OEe/uy1+knhrFl4Wvy7Vkm3uo9mJ7g== + +"@oxfmt/binding-darwin-x64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.56.0.tgz#1ccbfd8a11bacd771e32de616837f43eec04bcee" + integrity sha512-z66SdjLqa3MUPKvTp3Mbb5nSjKSbnYxJGeB+Wx987s8T5hPcIRiBMfnJ6zcPgYtQn3x5xjvdzNVkXrSeYH6ZFg== + +"@oxfmt/binding-freebsd-x64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.56.0.tgz#79a87da2b83711460402672c8079010191e7e940" + integrity sha512-t2tkrV1vtZyaItSQ71dTi2ZVKZEI39b/LqLT12V5KMfIeXK6N32TUC1jhOXKVQmhECq9j2ZXMQV3JeT1kh9Vmg== + +"@oxfmt/binding-linux-arm-gnueabihf@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.56.0.tgz#c7a505a6267d015cd21fa3340f0d763bcba4c2ee" + integrity sha512-+gCy+Tp3RHeXQ9y/QrS76lXIpZkbziTyp6hIgjB2MssCwfMph3vG/GEfkhO34Rai1vhYIaUkvv8UT1BcDorJPw== + +"@oxfmt/binding-linux-arm-musleabihf@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.56.0.tgz#b92464412a4e69105094e651364a3a7f9dcc0588" + integrity sha512-0kKkVvQ2I+FJ2sxQyUu1zJ0yWP5kcWse/yVFnGQSFCXMwSSkfEaUGu0dW774O7nyy3jrcBGap7OSc8dZmU/CdA== + +"@oxfmt/binding-linux-arm64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.56.0.tgz#b95377af041ace991f261dfe16e2f3bbf47b070a" + integrity sha512-npkA2siMbyWRh+wEhi1aTAx4RirukGcGNt8V4Ch86pG+xU9aurqS1MZOnKYMu03ISwat3rB6zkQx51SsB9obNw== + +"@oxfmt/binding-linux-arm64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.56.0.tgz#2f3f643f3f47384e9394057a4e5e4e88df9d17e2" + integrity sha512-UekqOjGkV4/MkqreCV9SPIB2jlR3/HbXrmhV1rVXJZ9wfDXMyCMriLtq3tHqLY4PkbVWNtfcm1kMojJ26KLSJw== + +"@oxfmt/binding-linux-ppc64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.56.0.tgz#4ac2db360a2dd44e79a11280c7b0ed2b424f9259" + integrity sha512-XSzveSpeZMD5XJpew5lRFVtNnT04xd3rJxENXmk7wkZzN9oWzv2aFJyoNDhOtoz69BYaS/fg4SYl+CfEZRpB0Q== + +"@oxfmt/binding-linux-riscv64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.56.0.tgz#4d30a2e63631b1743667c88817ffddd56fa45205" + integrity sha512-EkQ0nJa7k7HDDIVuPF7WY+k4k+bzdclLYtyIXNt7/OqVghfNiMym6YGppFBgx1XRIHW6QylxBz5OogumPjPJbQ== + +"@oxfmt/binding-linux-riscv64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.56.0.tgz#9aa3ba74a8268a25c7e69844aeb9baa3b1d9c05a" + integrity sha512-dyjAGW8jKRge0ik6U/dgvQG0nVpA3iBlRskQTz5qJLvQWIrySxX5jpqzPetLBNIIZ231KA82fDdi1nLTk8ENCw== + +"@oxfmt/binding-linux-s390x-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.56.0.tgz#f568fed2a60d0aaf4178e44c93d8156f57d15d95" + integrity sha512-60ZGH3LtfqlW8X6vcLdSFY4lvCQYINurttYBKaALnHCDVAUCYJ1LsUgS6p1XOzVlzEDx3yNUZvDF1Lvt59zoZw== + +"@oxfmt/binding-linux-x64-gnu@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.56.0.tgz#123967369c4b559f9b704826715111d79980ae32" + integrity sha512-u1suj1tgJHK4ZqB7buCtdbNef2n8+d0lXTPJwLHNmtyK6p+DTpsaoDvmqhQrA56fgKYv4LuRxNtL8YooebKOew== + +"@oxfmt/binding-linux-x64-musl@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.56.0.tgz#78ef9fb6e66c5c633ff5458793dd8164708e8380" + integrity sha512-aYGLvlQHt80y+qKEtfJY/Nm27G0125Lv+qyh9SJ4Cjc6lXnXjD+ndfhqQnbV24POpMi7rNRi0jvx/0d70FRpCQ== + +"@oxfmt/binding-openharmony-arm64@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.56.0.tgz#bfc7a984bc8c2838de1fc0615b5297bb7b809697" + integrity sha512-H/re/gO+7ysVc+kywHNuzY3C33EN9sQcZhg0kp1ZwOZl7y998ZE5mhnBiuGR/nYI0pqLL5xQfrHVUOJ/cIJsCA== + +"@oxfmt/binding-win32-arm64-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.56.0.tgz#b9e2bbce68ef363742313baf9da5fbff8a416e18" + integrity sha512-6qLNXfXmtAs8jXDvYMkxk6Wec5SUJoew+ZX1uOZmqaR7ks0EJFbAohuOCELDyJMWyVlxotVG8Xf8m74Bfq0O2w== + +"@oxfmt/binding-win32-ia32-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.56.0.tgz#1d96284cb5e6df6d219bd5bdbf9e0a8c5a8daf8c" + integrity sha512-UXEXuKphAe15bsob4AswNMArCw38XSmUIs3wk1s6e6MX9OWGW/IRWU95s1hZDiVg09STy1jHgyN2qkqbu1FT0w== + +"@oxfmt/binding-win32-x64-msvc@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.56.0.tgz#6c43da44642523b45fa8c0d152375fe96880f779" + integrity sha512-HPyNDjky+NIOuaMvHZflR+kst3YWdUOH2JUQYkf99grqZ5mEBTQM6h9iGy501Z8Xt5xMScrwHOuVCOlqDrktRw== "@pkgjs/parseargs@^0.11.0": version "0.11.0" @@ -2542,10 +2542,10 @@ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== -"@types/react@^19.2.15": - version "19.2.15" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.15.tgz#9e2c6a4251a290f5c525c3143caa872fa6e01e0d" - integrity sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q== +"@types/react@^19.2.17": + version "19.2.17" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" + integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== dependencies: csstype "^3.2.2" @@ -3560,10 +3560,10 @@ camelcase@^8.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-8.0.0.tgz#c0d36d418753fb6ad9c5e0437579745c1c14a534" integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA== -caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001793: - version "1.0.30001793" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72" - integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA== +caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001799: + version "1.0.30001799" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz#5c909138c27f1a61219d3e092071c1cc7d32dc55" + integrity sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw== ccount@^2.0.0: version "2.0.1" @@ -8748,32 +8748,32 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" -oxfmt@^0.51.0: - version "0.51.0" - resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.51.0.tgz#8e2919e8fb9632abcecc5b65bb20f4829f6fc4c8" - integrity sha512-l/AoAnaEOV7Q5/Z9kHOMDehVJnCgYN7wRoooWCTUMBMi16BJhLZqd9cmCnwcVFfVlzkt53zK2KLPFNp8vSsoDg== +oxfmt@^0.56.0: + version "0.56.0" + resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.56.0.tgz#ef72d7687392a5638e8c91cc930c7236c7341f7e" + integrity sha512-9Dv0wV3zKiyvhjD7bRKaInKmHQ1sPx3RGOjQkGFJbbdQ16576yf8qhMSO9Q9cvHcs+1NpBsRTkuDDYFFPTJ6gw== dependencies: tinypool "2.1.0" optionalDependencies: - "@oxfmt/binding-android-arm-eabi" "0.51.0" - "@oxfmt/binding-android-arm64" "0.51.0" - "@oxfmt/binding-darwin-arm64" "0.51.0" - "@oxfmt/binding-darwin-x64" "0.51.0" - "@oxfmt/binding-freebsd-x64" "0.51.0" - "@oxfmt/binding-linux-arm-gnueabihf" "0.51.0" - "@oxfmt/binding-linux-arm-musleabihf" "0.51.0" - "@oxfmt/binding-linux-arm64-gnu" "0.51.0" - "@oxfmt/binding-linux-arm64-musl" "0.51.0" - "@oxfmt/binding-linux-ppc64-gnu" "0.51.0" - "@oxfmt/binding-linux-riscv64-gnu" "0.51.0" - "@oxfmt/binding-linux-riscv64-musl" "0.51.0" - "@oxfmt/binding-linux-s390x-gnu" "0.51.0" - "@oxfmt/binding-linux-x64-gnu" "0.51.0" - "@oxfmt/binding-linux-x64-musl" "0.51.0" - "@oxfmt/binding-openharmony-arm64" "0.51.0" - "@oxfmt/binding-win32-arm64-msvc" "0.51.0" - "@oxfmt/binding-win32-ia32-msvc" "0.51.0" - "@oxfmt/binding-win32-x64-msvc" "0.51.0" + "@oxfmt/binding-android-arm-eabi" "0.56.0" + "@oxfmt/binding-android-arm64" "0.56.0" + "@oxfmt/binding-darwin-arm64" "0.56.0" + "@oxfmt/binding-darwin-x64" "0.56.0" + "@oxfmt/binding-freebsd-x64" "0.56.0" + "@oxfmt/binding-linux-arm-gnueabihf" "0.56.0" + "@oxfmt/binding-linux-arm-musleabihf" "0.56.0" + "@oxfmt/binding-linux-arm64-gnu" "0.56.0" + "@oxfmt/binding-linux-arm64-musl" "0.56.0" + "@oxfmt/binding-linux-ppc64-gnu" "0.56.0" + "@oxfmt/binding-linux-riscv64-gnu" "0.56.0" + "@oxfmt/binding-linux-riscv64-musl" "0.56.0" + "@oxfmt/binding-linux-s390x-gnu" "0.56.0" + "@oxfmt/binding-linux-x64-gnu" "0.56.0" + "@oxfmt/binding-linux-x64-musl" "0.56.0" + "@oxfmt/binding-openharmony-arm64" "0.56.0" + "@oxfmt/binding-win32-arm64-msvc" "0.56.0" + "@oxfmt/binding-win32-ia32-msvc" "0.56.0" + "@oxfmt/binding-win32-x64-msvc" "0.56.0" p-cancelable@^3.0.0: version "3.0.0" @@ -9128,7 +9128,7 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.32, postcss@^8.5.14, postcss@^8.5.15: +postcss@^8.4.32, postcss@^8.5.15: version "8.5.15" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== @@ -9210,10 +9210,10 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== -protobufjs@^8.6.3: - version "8.6.3" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.3.tgz#67dadb85a6802fe5df5e2221ab50cee1017b77c6" - integrity sha512-alQyzT0j401LGBtwsqu6uprjR6pfNH1UJf9N6GBFMjIcd+HzTe0/HrjAbFCqun+zvnfLarrxAtMM2xvZ+kFZ5A== +protobufjs@^8.6.5: + version "8.6.5" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-8.6.5.tgz#7709c9ff77914c00010129888e620c9ccf7c294c" + integrity sha512-zeE5LPpencAGXvsxyOYmEgJhxzHY8IsmPAFzstZVhDSVT8QH03q6gMZwZRaQGApevZbAL6u28ugs4CC+YKB2jQ== dependencies: long "^5.3.2" @@ -10635,14 +10635,14 @@ stylelint-order@^6.0.2: postcss "^8.4.32" postcss-sorting "^8.0.2" -stylelint@^17.12.0: - version "17.12.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.12.0.tgz#388d3fb56bebbb816f1043c8cc1c290bf5a889ab" - integrity sha512-KIlzWXMHUvgfPUR0R7TK3H80yCIi0uoivUwf+6Az4yrHJD1Q3c1qIkh/H5Z0i/K3QXgtq/UMEkWyBUSUwnpnOg== +stylelint@^17.13.0: + version "17.13.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-17.13.0.tgz#cc68d0ad83d84e29e5147d5f8a8100ccd26a7346" + integrity sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw== dependencies: - "@csstools/css-calc" "^3.2.0" + "@csstools/css-calc" "^3.2.1" "@csstools/css-parser-algorithms" "^4.0.0" - "@csstools/css-syntax-patches-for-csstree" "^1.1.3" + "@csstools/css-syntax-patches-for-csstree" "^1.1.4" "@csstools/css-tokenizer" "^4.0.0" "@csstools/media-query-list-parser" "^5.0.0" "@csstools/selector-resolve-nested" "^4.0.0" @@ -10666,7 +10666,7 @@ stylelint@^17.12.0: micromatch "^4.0.8" normalize-path "^3.0.0" picocolors "^1.1.1" - postcss "^8.5.14" + postcss "^8.5.15" postcss-safe-parser "^7.0.1" postcss-selector-parser "^7.1.1" postcss-value-parser "^4.2.0"