@ogygia/devtools 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/app.d.ts +3 -0
  2. package/dist/app.js +9 -0
  3. package/dist/devtools/BoundaryOverlay.svelte +10 -6
  4. package/dist/devtools/Devtools.svelte +24 -18
  5. package/dist/devtools/IslandDetail.svelte +7 -5
  6. package/dist/devtools/PageTab.svelte +4 -2
  7. package/dist/devtools/ProfilerTab.svelte +8 -2
  8. package/dist/devtools/SessionTab.svelte +21 -8
  9. package/dist/devtools/highlight.js +14 -7
  10. package/dist/devtools/ledger-dom.js +6 -9
  11. package/dist/devtools/names.js +18 -17
  12. package/dist/devtools/page.d.ts +2 -0
  13. package/dist/devtools/page.js +37 -17
  14. package/dist/devtools/profile-store.d.ts +4 -9
  15. package/dist/devtools/profile-store.js +26 -21
  16. package/dist/devtools/session.d.ts +2 -0
  17. package/dist/devtools/session.js +48 -42
  18. package/dist/devtools/testing-engine.js +10 -1
  19. package/dist/devtools/ui-app.d.ts +3 -1
  20. package/dist/devtools/ui-app.js +9 -3
  21. package/dist/devtools/ui.js +83 -40
  22. package/dist/messages/build.d.ts +21 -0
  23. package/dist/messages/build.js +56 -0
  24. package/dist/messages/index.d.ts +3 -0
  25. package/dist/messages/index.js +3 -0
  26. package/dist/messages/runtime.d.ts +63 -0
  27. package/dist/messages/runtime.js +122 -0
  28. package/dist/profiler/async-io.d.ts +12 -7
  29. package/dist/profiler/async-io.js +94 -82
  30. package/dist/profiler/beacon-standalone.js +15 -6
  31. package/dist/profiler/busy.d.ts +13 -0
  32. package/dist/profiler/busy.js +45 -0
  33. package/dist/profiler/client.js +5 -24
  34. package/dist/profiler/embedded-maps.js +9 -5
  35. package/dist/profiler/gc.d.ts +5 -0
  36. package/dist/profiler/gc.js +75 -11
  37. package/dist/profiler/index.d.ts +23 -2
  38. package/dist/profiler/index.js +648 -373
  39. package/dist/profiler/net.d.ts +14 -6
  40. package/dist/profiler/net.js +61 -45
  41. package/dist/profiler/patterns.js +29 -1
  42. package/dist/profiler/source-scan.js +24 -12
  43. package/dist/profiler/storage/index.d.ts +14 -3
  44. package/dist/profiler/storage/index.js +41 -41
  45. package/dist/profiler/storage/postgres.d.ts +18 -5
  46. package/dist/profiler/storage/postgres.js +124 -85
  47. package/dist/profiler/storage/redis.d.ts +18 -5
  48. package/dist/profiler/storage/redis.js +160 -131
  49. package/dist/profiler/storage/sqlite.d.ts +15 -2
  50. package/dist/profiler/storage/sqlite.js +54 -48
  51. package/dist/profiler/ui/ComponentsTable.svelte +2 -2
  52. package/dist/profiler/ui/Dashboard.svelte +2 -3
  53. package/dist/profiler/ui/DownloadHtml.svelte +1 -0
  54. package/dist/profiler/ui/ExportButton.svelte +1 -0
  55. package/dist/profiler/ui/Flame.svelte +10 -4
  56. package/dist/profiler/ui/Flame.svelte.d.ts +0 -5
  57. package/dist/profiler/ui/FrameDetails.svelte +13 -2
  58. package/dist/profiler/ui/FrameDetails.svelte.d.ts +0 -6
  59. package/dist/profiler/ui/FunctionsTable.svelte +2 -2
  60. package/dist/profiler/ui/IslandsTable.svelte +2 -2
  61. package/dist/profiler/ui/OnThisPage.svelte +12 -4
  62. package/dist/profiler/ui/RunView.svelte +31 -27
  63. package/dist/profiler/ui/ShareLink.svelte +6 -1
  64. package/dist/profiler/ui/Sidebar.svelte +14 -9
  65. package/dist/profiler/ui/Treemap.svelte +11 -4
  66. package/dist/profiler/ui/Treemap.svelte.d.ts +0 -6
  67. package/dist/profiler/ui/WaitingTable.svelte +2 -2
  68. package/dist/profiler/ui/fake-progress.svelte.d.ts +7 -12
  69. package/dist/profiler/ui/fake-progress.svelte.js +57 -36
  70. package/dist/profiler/ui/press.js +4 -2
  71. package/dist/profiler/ui/row-anchor.svelte.d.ts +0 -6
  72. package/dist/profiler/ui/row-anchor.svelte.js +13 -8
  73. package/dist/profiler/ui/sort.svelte.d.ts +9 -6
  74. package/dist/profiler/ui/sort.svelte.js +34 -28
  75. package/dist/profiler/visit.d.ts +0 -1
  76. package/dist/profiler/visit.js +1 -2
  77. package/dist/server.js +27 -10
  78. package/dist/vite.js +3 -2
  79. package/package.json +13 -4
@@ -0,0 +1,122 @@
1
+ import { define_messages, dev_or_prerender, prose } from "@ogygia/core/internal/messages";
2
+ // src/messages/runtime.ts
3
+ /**
4
+ * EVERY MESSAGE THE PROFILER'S SERVER HALF AND THE DOCK PRINT, in one place: its code, its wording,
5
+ * what to do about it (core's `@ogygia/core/internal/messages` says how the system works).
6
+ *
7
+ * console.error(new OgygiaError('devtools_profiler_store_init_failed'), e);
8
+ * warn('devtools_profiler_session_rejected', { path, diagnosis, reason });
9
+ *
10
+ * It runs in the app's server build (and the dock's launcher in its browser bundle), so the wording is
11
+ * only passed in dev: a production build prints the code's page alone. The Vite plugin's messages are
12
+ * in ./build.ts.
13
+ */
14
+ /** The errors: `throw new OgygiaError(code, args)`, or `console.error(new OgygiaError(…), err)` for a
15
+ * failure that is caught. */
16
+ const DEVTOOLS_ERRORS = {
17
+ devtools_profiler_store_init_failed: {
18
+ text: () => "store init failed; falling back to in-memory:",
19
+ details: () => prose`
20
+ The profiler's \`store\` threw while it initialized (a missing driver, a bad path, a
21
+ database it cannot reach; the error follows the message). The profiler keeps its reports
22
+ in memory instead, so they are gone when the server restarts.
23
+ `
24
+ },
25
+ devtools_profiler_mount_failed: {
26
+ text: () => "the profiler could not start; requests are served without it:",
27
+ details: () => prose`
28
+ The profiler (\`devtools({ profiler })\`) loads on the first request, and loading it
29
+ failed (the error follows the message): a runtime without \`node:inspector\` (an edge
30
+ runtime), or a dev server in the middle of an edit. Every request is still answered,
31
+ unprofiled, and the next request tries to start it again. Said once per server.
32
+ `
33
+ },
34
+ devtools_profiler_alloc_sampling_unavailable: {
35
+ text: () => "allocation sampling unavailable, GC attribution off:",
36
+ details: () => prose`
37
+ Printed with \`OGYGIA_PROFILER_DEBUG\` set. The runtime refused the heap sampling the
38
+ profiler asked for (the error follows the message), so this recording does not say which
39
+ code caused its garbage collection.
40
+ `
41
+ },
42
+ devtools_profiler_gc_attribution_failed: {
43
+ text: () => "GC attribution failed:",
44
+ details: () => prose`
45
+ Printed with \`OGYGIA_PROFILER_DEBUG\` set. Working out which code caused the recording's
46
+ garbage collection threw (the error follows the message); the report leaves that part
47
+ out.
48
+ `
49
+ },
50
+ devtools_profiler_put_report_failed: {
51
+ text: () => "store.putReport failed:",
52
+ details: () => prose`
53
+ Printed with \`OGYGIA_PROFILER_DEBUG\` set. The profiler's \`store\` failed to save a
54
+ report (the error follows the message). The report stays in memory, but is gone when the
55
+ server restarts.
56
+ `
57
+ },
58
+ devtools_profiler_get_report_failed: {
59
+ text: () => "store.getReport failed:",
60
+ details: () => prose`
61
+ Printed with \`OGYGIA_PROFILER_DEBUG\` set. The profiler's \`store\` failed to read a
62
+ report back (the error follows the message), so the report is answered as not found.
63
+ `
64
+ },
65
+ devtools_dock_load_failed: {
66
+ text: () => "the devtools dock’s code failed to load (click its button to try again):",
67
+ details: () => prose`
68
+ The browser could not download the devtools dock's code (the network failed, or a deploy
69
+ removed the file; the error follows the message). The page is not affected, and the
70
+ dock's button stays: a click, or Alt+O, asks for the code again under a fresh URL.
71
+ `
72
+ }
73
+ };
74
+ /** The warnings: `warn(code, args, ...rest)`. */
75
+ const DEVTOOLS_WARNINGS = {
76
+ devtools_profiler_session_rejected: {
77
+ text: (a) => `session not accepted on ${a.path}: ${a.diagnosis} — ${a.reason}`,
78
+ details: () => prose`
79
+ A browser sent a profiler session cookie, or had just logged in, and the profiler still
80
+ did not accept it. \`diagnosis\` counts what the request carried (never a cookie's value)
81
+ and \`reason\` reads it: no cookie reached the server (a proxy or CDN in front of the app
82
+ drops it), the browser sent several session cookies, or the session is not valid (the
83
+ profiler secret differs between server instances).
84
+
85
+ Forward cookies to the profiler's path, clear the site's cookies and log in again, or
86
+ give every instance the same secret.
87
+ `
88
+ },
89
+ devtools_profiler_sink_undelivered: {
90
+ text: (a) => `the sink never took a post of ${a.rows} rows, after every try; they are dropped:`,
91
+ details: () => prose`
92
+ The profiler posts the rows it reads (\`sink: { url }\`) one post at a time, in order, and
93
+ tries a failed post again a few times before the next one goes. This post failed every
94
+ time (the last error follows the message), so its rows are not at the sink and the site
95
+ view misses those requests. The site page shows the sink's last answer.
96
+
97
+ Check the sink's URL, its key, and that it answers 2xx once it stored a post. A post
98
+ carries an \`idempotency-key\` header: a sink that stored a post already can answer ok
99
+ for its copy.
100
+ `
101
+ },
102
+ devtools_profiler_background_stopped: {
103
+ text: (a) => `the profiler's ${a.loop} stopped: a background window failed:`,
104
+ details: () => prose`
105
+ The trap (\`trap\`) and the always-on sampler (\`sample\`) record short windows in the
106
+ background with the process's inspector. One of those windows failed (the error follows
107
+ the message): most often the inspector is held by another debugger, or the host has none.
108
+ That loop records no more windows in this process; the dashboard says so under "In the
109
+ background", with what the trap caught before it stopped.
110
+
111
+ Detach the other debugger and restart the server, or turn \`trap\` / \`sample\` off on this
112
+ host.
113
+ `
114
+ }
115
+ };
116
+ const own = /* @__PURE__ */ define_messages(import.meta.env?.DEV || import.meta.env?.SSR ? DEVTOOLS_ERRORS : null, import.meta.env?.DEV || import.meta.env?.SSR ? DEVTOOLS_WARNINGS : null, dev_or_prerender);
117
+ /** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
118
+ * line as a string. Wording and link in dev and while prerendering, the link alone otherwise. */
119
+ const OgygiaError = own.OgygiaError;
120
+ const warn = own.warn;
121
+ const line = own.line;
122
+ export { DEVTOOLS_ERRORS, DEVTOOLS_WARNINGS, OgygiaError, line, warn };
@@ -26,8 +26,17 @@ export interface IoOp {
26
26
  * across the runs, before the io is scoped to one) — a timer each render starts and never ends */
27
27
  left_each_run?: true;
28
28
  }
29
- export interface IoRecorder {
30
- stop(): IoOp[];
29
+ /** `fn (dir/file:line)` from a generated position: what a promise origin reads as when no
30
+ * sourcemap maps it */
31
+ export declare function short_site(s: CallerSite): string;
32
+ /** Times I/O resources over one recording window: `IoRecorder.start()` at its start, `stop()` at
33
+ * its end. */
34
+ export declare class IoRecorder {
35
+ #private;
36
+ /** Start timing I/O resources; null without `async_hooks` (edge) — the fetch patch and wall/CPU
37
+ * split still work there. */
38
+ static start(): Promise<IoRecorder | null>;
39
+ constructor(async_hooks: typeof import("node:async_hooks"));
31
40
  /** promises created while the recorder ran, and who created them (one stack per
32
41
  * PROMISE_SAMPLE_EVERY promises — a counter otherwise) */
33
42
  promises(): {
@@ -38,12 +47,8 @@ export interface IoRecorder {
38
47
  site?: CallerSite;
39
48
  }[];
40
49
  };
50
+ stop(): IoOp[];
41
51
  }
42
- /** `fn (dir/file:line)` from a generated position: what a promise origin reads as when no
43
- * sourcemap maps it */
44
- export declare function short_site(s: CallerSite): string;
45
- /** Start timing I/O resources. Call `stop()` at the end of the window. */
46
- export declare function record_async_io(): Promise<IoRecorder | null>;
47
52
  /**
48
53
  * A timer a render starts and never ends: an interval (or a timeout of a second or more) still open
49
54
  * after every render, started from the same line in each of them. One per render is the proof it is
@@ -61,91 +61,103 @@ function short_site(s) {
61
61
  const clean = q === -1 ? s.file : s.file.slice(0, q);
62
62
  return `${s.fn} (${clean.split("/").slice(-2).join("/")}:${s.line})`;
63
63
  }
64
- /** Start timing I/O resources. Call `stop()` at the end of the window. */
65
- async function record_async_io() {
66
- let async_hooks;
67
- try {
68
- async_hooks = await import("node:async_hooks");
69
- } catch {
70
- return null;
71
- }
72
- const open = /* @__PURE__ */ new Map();
73
- const ops = [];
74
- let promise_count = 0;
64
+ /** Times I/O resources over one recording window: `IoRecorder.start()` at its start, `stop()` at
65
+ * its end. */
66
+ var IoRecorder = class IoRecorder {
67
+ #open = /* @__PURE__ */ new Map();
68
+ #ops = [];
69
+ #promise_count = 0;
75
70
  /** generated `fn\0file\0line` → how many sampled promises it made, and the site itself */
76
- const promise_sites = /* @__PURE__ */ new Map();
77
- const hook = async_hooks.createHook({
78
- init(asyncId, type, _trigger, resource) {
79
- if (type === "PROMISE") {
80
- if (++promise_count % PROMISE_SAMPLE_EVERY === 0 && promise_sites.size < 200) {
81
- const site = nearest_own_or_dep_site() ?? null;
82
- const k = site ? site.fn + "\0" + site.file + "\0" + site.line : "";
83
- const hit = promise_sites.get(k);
84
- if (hit) hit.n++;
85
- else promise_sites.set(k, {
86
- n: 1,
87
- site
88
- });
89
- }
90
- return;
91
- }
92
- if (open.size >= MAX || !TRACK.has(type)) return;
93
- const s = {
94
- t: performance.now(),
95
- type,
96
- site: nearest_app_site()
97
- };
98
- if (type === "Timeout") {
99
- const r = resource;
100
- if (typeof r._repeat === "number" && r._repeat > 0) s.repeat = r._repeat;
101
- else if (typeof r._idleTimeout === "number" && r._idleTimeout >= 0) s.delay = r._idleTimeout;
102
- s.timer = r;
71
+ #promise_sites = /* @__PURE__ */ new Map();
72
+ #hook;
73
+ /** Start timing I/O resources; null without `async_hooks` (edge) — the fetch patch and wall/CPU
74
+ * split still work there. */
75
+ static async start() {
76
+ let async_hooks;
77
+ try {
78
+ async_hooks = await import("node:async_hooks");
79
+ } catch {
80
+ return null;
81
+ }
82
+ return new IoRecorder(async_hooks);
83
+ }
84
+ constructor(async_hooks) {
85
+ this.#hook = async_hooks.createHook({
86
+ init: (asyncId, type, _trigger, resource) => this.#init(asyncId, type, resource),
87
+ destroy: (asyncId) => this.#destroy(asyncId)
88
+ });
89
+ this.#hook.enable();
90
+ }
91
+ /** promises created while the recorder ran, and who created them (one stack per
92
+ * PROMISE_SAMPLE_EVERY promises — a counter otherwise) */
93
+ promises() {
94
+ const sampled = [...this.#promise_sites.values()].reduce((a, b) => a + b.n, 0) || 1;
95
+ return {
96
+ count: this.#promise_count,
97
+ top: [...this.#promise_sites.values()].sort((a, b) => b.n - a.n).slice(0, 8).map(({ n, site }) => ({
98
+ caller: site ? short_site(site) : "(no frame outside node)",
99
+ share: Math.round(n / sampled * 100) / 100,
100
+ ...site ? { site } : {}
101
+ }))
102
+ };
103
+ }
104
+ stop() {
105
+ this.#hook.disable();
106
+ const ops = this.#ops;
107
+ const now = performance.now();
108
+ for (const s of this.#open.values()) if (ops.length < MAX) ops.push({
109
+ type: s.type,
110
+ caller_site: s.site,
111
+ ms: round2(now - s.t),
112
+ start: s.t,
113
+ open: true,
114
+ ...s.repeat !== void 0 ? { repeat: s.repeat } : {},
115
+ ...s.delay !== void 0 ? { delay: s.delay } : {},
116
+ ...s.timer?.hasRef?.() === false ? { unref: true } : {}
117
+ });
118
+ this.#open.clear();
119
+ return ops;
120
+ }
121
+ #init(asyncId, type, resource) {
122
+ if (type === "PROMISE") {
123
+ if (++this.#promise_count % PROMISE_SAMPLE_EVERY === 0 && this.#promise_sites.size < 200) {
124
+ const site = nearest_own_or_dep_site() ?? null;
125
+ const k = site ? site.fn + "\0" + site.file + "\0" + site.line : "";
126
+ const hit = this.#promise_sites.get(k);
127
+ if (hit) hit.n++;
128
+ else this.#promise_sites.set(k, {
129
+ n: 1,
130
+ site
131
+ });
103
132
  }
104
- open.set(asyncId, s);
105
- },
106
- destroy(asyncId) {
107
- const s = open.get(asyncId);
108
- if (!s) return;
109
- open.delete(asyncId);
110
- if (ops.length < MAX) ops.push({
111
- type: s.type,
112
- caller_site: s.site,
113
- ms: round2(performance.now() - s.t),
114
- start: s.t
115
- });
133
+ return;
116
134
  }
117
- });
118
- hook.enable();
119
- return {
120
- promises() {
121
- const sampled = [...promise_sites.values()].reduce((a, b) => a + b.n, 0) || 1;
122
- return {
123
- count: promise_count,
124
- top: [...promise_sites.values()].sort((a, b) => b.n - a.n).slice(0, 8).map(({ n, site }) => ({
125
- caller: site ? short_site(site) : "(no frame outside node)",
126
- share: Math.round(n / sampled * 100) / 100,
127
- ...site ? { site } : {}
128
- }))
129
- };
130
- },
131
- stop() {
132
- hook.disable();
133
- const now = performance.now();
134
- for (const s of open.values()) if (ops.length < MAX) ops.push({
135
- type: s.type,
136
- caller_site: s.site,
137
- ms: round2(now - s.t),
138
- start: s.t,
139
- open: true,
140
- ...s.repeat !== void 0 ? { repeat: s.repeat } : {},
141
- ...s.delay !== void 0 ? { delay: s.delay } : {},
142
- ...s.timer?.hasRef?.() === false ? { unref: true } : {}
143
- });
144
- open.clear();
145
- return ops;
135
+ if (this.#open.size >= MAX || !TRACK.has(type)) return;
136
+ const s = {
137
+ t: performance.now(),
138
+ type,
139
+ site: nearest_app_site()
140
+ };
141
+ if (type === "Timeout") {
142
+ const r = resource;
143
+ if (typeof r._repeat === "number" && r._repeat > 0) s.repeat = r._repeat;
144
+ else if (typeof r._idleTimeout === "number" && r._idleTimeout >= 0) s.delay = r._idleTimeout;
145
+ s.timer = r;
146
146
  }
147
- };
148
- }
147
+ this.#open.set(asyncId, s);
148
+ }
149
+ #destroy(asyncId) {
150
+ const s = this.#open.get(asyncId);
151
+ if (!s) return;
152
+ this.#open.delete(asyncId);
153
+ if (this.#ops.length < MAX) this.#ops.push({
154
+ type: s.type,
155
+ caller_site: s.site,
156
+ ms: round2(performance.now() - s.t),
157
+ start: s.t
158
+ });
159
+ }
160
+ };
149
161
  /**
150
162
  * A timer a render starts and never ends: an interval (or a timeout of a second or more) still open
151
163
  * after every render, started from the same line in each of them. One per render is the proof it is
@@ -184,4 +196,4 @@ function io_kind(type) {
184
196
  function round2(n) {
185
197
  return Math.round(n * 100) / 100;
186
198
  }
187
- export { io_kind, mark_left_each_run, record_async_io, short_site };
199
+ export { IoRecorder, io_kind, mark_left_each_run, short_site };
@@ -34,7 +34,7 @@ function standalone_beacon() {
34
34
  const seen = [];
35
35
  let off = false;
36
36
  let cls = 0;
37
- let sent = false;
37
+ let visit_state = "open";
38
38
  let resend;
39
39
  /** visits sent while the page lives (the early one, then re-sends): at most 8 */
40
40
  let early_sends = 0;
@@ -147,7 +147,7 @@ function standalone_beacon() {
147
147
  function send(body, slim) {
148
148
  const room = 6e4 - queued;
149
149
  if (body.length > room) {
150
- if (!sent && document.visibilityState === "visible") return post(body, false);
150
+ if (visit_state !== "sent" && document.visibilityState === "visible") return post(body, false);
151
151
  const smaller = slim?.();
152
152
  if (!smaller || smaller.length > room) return;
153
153
  body = smaller;
@@ -333,8 +333,9 @@ function standalone_beacon() {
333
333
  }
334
334
  /** the page is going away: the vitals (final only now), the visit, the CPU trace */
335
335
  function hide() {
336
- if (runtime_claimed() || sent) return;
337
- sent = true;
336
+ if (runtime_claimed() || visit_state === "sent") return;
337
+ visit_state = "sent";
338
+ clearTimeout(resend);
338
339
  if (Object.keys(vitals).length) send(JSON.stringify({
339
340
  page: location.pathname,
340
341
  at: Math.round(performance.timeOrigin),
@@ -353,6 +354,10 @@ function standalone_beacon() {
353
354
  }));
354
355
  cpu(true);
355
356
  }
357
+ /** the page is shown again (its tab, or a back/forward cache restore): the visit goes on */
358
+ function show() {
359
+ if (visit_state === "sent") visit_state = "reopened";
360
+ }
356
361
  /** a later paint or shift: the visit again a moment later (debounced), once one has gone */
357
362
  function again() {
358
363
  if (!early_sends || early_sends >= 8) return;
@@ -371,7 +376,7 @@ function standalone_beacon() {
371
376
  }
372
377
  /** the visit while the page lives: the early one, and each re-send */
373
378
  function early() {
374
- if (runtime_claimed() || early_sends >= 8) return;
379
+ if (runtime_claimed() || visit_state === "sent" || early_sends >= 8) return;
375
380
  early_sends++;
376
381
  const v = visit();
377
382
  if (v) send(JSON.stringify({
@@ -387,7 +392,7 @@ function standalone_beacon() {
387
392
  } catch {}
388
393
  if (profiler) {
389
394
  try {
390
- profiler.stop();
395
+ profiler.stop().catch(() => {});
391
396
  } catch {}
392
397
  profiler = null;
393
398
  }
@@ -396,8 +401,12 @@ function standalone_beacon() {
396
401
  if (document.querySelector("script[data-ogygia-runtime]")) return stand_down();
397
402
  document.addEventListener("visibilitychange", () => {
398
403
  if (document.visibilityState === "hidden") hide();
404
+ else show();
399
405
  });
400
406
  addEventListener("pagehide", hide);
407
+ addEventListener("pageshow", (e) => {
408
+ if (e.persisted) show();
409
+ });
401
410
  addEventListener("load", () => setTimeout(early, 1500));
402
411
  });
403
412
  }
@@ -0,0 +1,13 @@
1
+ import { Scope, Stale } from "@ogygia/machine";
2
+ /** What a caller shows while it waits: each ask as it starts, each wait for a busy worker (with
3
+ * the time waited so far). */
4
+ export interface BusyWatch {
5
+ asking?: () => void;
6
+ busy?: (waited_ms: number) => void;
7
+ }
8
+ /**
9
+ * `fetch(url)` until the answer is not a 409, or the wait runs out (the last 409 is the answer: the
10
+ * caller says so). `owner` owns the asks and the waits: once it ends, the request is aborted,
11
+ * nothing more is asked, and the answer is `STALE`.
12
+ */
13
+ export declare function fetch_when_free(owner: Scope, url: string, init?: RequestInit, { asking, busy }?: BusyWatch): Promise<Response | Stale>;
@@ -0,0 +1,45 @@
1
+ import { STALE, policy } from "@ogygia/machine";
2
+ // src/profiler/busy.ts
3
+ /**
4
+ * Waiting for a busy profiler. A worker records one profile at a time (the V8 profiler is
5
+ * process-wide): one that is recording answers `/page` with a 409. There is no queue on the server
6
+ * (a serverless worker is recycled within ~30 s), so the queue lives with the caller: ask again
7
+ * until a worker takes it. On serverless the next ask lands on a free worker; on one server it is
8
+ * taken once the profile ahead ends. Both the dock's tabs and the run page wait this one way.
9
+ */
10
+ /** How a profile waits for a busy profiler: asked again every 2 s (or when the 409's
11
+ * `Retry-After` says), for up to 2 min; a profile ahead longer than that is a stuck one (Reset). */
12
+ const PROFILER_BUSY = policy({
13
+ attempts: 61,
14
+ backoff: 2e3,
15
+ jitter: 0,
16
+ deadline: 12e4
17
+ });
18
+ /** The wait before the next ask, after a 409: its `Retry-After` (seconds), else the policy's. */
19
+ function busy_wait(res) {
20
+ const after = Number(res.headers.get("retry-after")) * 1e3;
21
+ return Number.isFinite(after) && after > 0 ? after : PROFILER_BUSY.backoff;
22
+ }
23
+ /**
24
+ * `fetch(url)` until the answer is not a 409, or the wait runs out (the last 409 is the answer: the
25
+ * caller says so). `owner` owns the asks and the waits: once it ends, the request is aborted,
26
+ * nothing more is asked, and the answer is `STALE`.
27
+ */
28
+ async function fetch_when_free(owner, url, init = {}, { asking, busy } = {}) {
29
+ const started = Date.now();
30
+ for (let n = 1;; n++) {
31
+ asking?.();
32
+ const res = await owner.task((signal) => fetch(url, {
33
+ ...init,
34
+ signal
35
+ }));
36
+ if (res === STALE || res.status !== 409) return res;
37
+ const waited = Date.now() - started;
38
+ if (n >= PROFILER_BUSY.attempts || waited > PROFILER_BUSY.deadline) return res;
39
+ busy?.(waited);
40
+ const wait = busy_wait(res);
41
+ res.body?.cancel().catch(() => {});
42
+ if (await owner.task(() => new Promise((r) => owner.timeout(r, wait))) === STALE) return STALE;
43
+ }
44
+ }
45
+ export { fetch_when_free };
@@ -1,32 +1,13 @@
1
1
  // src/profiler/client.ts
2
2
  const BEACON_KEY = Symbol.for("ogygia.beacon");
3
- const page_beacon = () => globalThis[BEACON_KEY];
4
- const waiting = [];
5
- let retrying = false;
3
+ const WAITING_KEY = Symbol.for("ogygia.beacon.waiting");
6
4
  function beacon_mark(...args) {
7
5
  if (typeof document === "undefined") return;
8
- const b = page_beacon();
9
- if (b) {
10
- for (const w of waiting.splice(0)) b.beacon_mark(...w);
11
- b.beacon_mark(...args);
12
- return;
13
- }
6
+ const g = globalThis;
7
+ const b = g[BEACON_KEY];
8
+ if (b) return b.beacon_mark(...args);
9
+ const waiting = g[WAITING_KEY] ??= [];
14
10
  if (waiting.length < 400) waiting.push(args);
15
- if (retrying) return;
16
- retrying = true;
17
- let tries = 0;
18
- const retry = () => {
19
- const later = page_beacon();
20
- if (later) {
21
- retrying = false;
22
- for (const w of waiting.splice(0)) later.beacon_mark(...w);
23
- } else if (++tries < 40) setTimeout(retry, 250);
24
- else {
25
- retrying = false;
26
- waiting.length = 0;
27
- }
28
- };
29
- setTimeout(retry, 250);
30
11
  }
31
12
  const now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
32
13
  const is_thenable = (v) => !!v && typeof v.then === "function";
@@ -1,9 +1,14 @@
1
+ import { STALE, flights } from "@ogygia/machine";
1
2
  import { PROFILER_MAPS_GZ_PREFIX } from "@ogygia/core/internal/compiler-ids";
2
3
  // src/profiler/embedded-maps.ts
3
- let loaded;
4
4
  let ready = null;
5
5
  function load_embedded_maps() {
6
- loaded ??= import("virtual:ogygia/profiler-maps").then(async (m) => {
6
+ return loaded.get().then((maps) => maps === STALE ? null : maps);
7
+ }
8
+ /** The read, once for the process (it never fails: an unreadable payload is no maps). */
9
+ const loaded = flights({
10
+ name: "profiler embedded maps",
11
+ run: () => import("virtual:ogygia/profiler-maps").then(async (m) => {
7
12
  let raw = m.default;
8
13
  if (typeof raw === "string" && raw.startsWith(PROFILER_MAPS_GZ_PREFIX)) raw = (await import("node:zlib")).gunzipSync(Buffer.from(raw.slice(PROFILER_MAPS_GZ_PREFIX.length), "base64")).toString("utf8");
9
14
  if (typeof raw !== "string" || !raw.startsWith("{")) return null;
@@ -14,9 +19,8 @@ function load_embedded_maps() {
14
19
  ...p.sources && typeof p.sources === "object" ? { sources: p.sources } : {},
15
20
  ...p.chunks && typeof p.chunks === "object" ? { chunks: p.chunks } : {}
16
21
  } : null;
17
- }).catch(() => null);
18
- return loaded;
19
- }
22
+ }).catch(() => null)
23
+ });
20
24
  /** the maps once loaded (synchronous readers: the module map lookup), else null */
21
25
  const embedded_maps = () => ready;
22
26
  /** a script's key in the payload: its path under the server output (`chunks/format.js`), whether it
@@ -127,6 +127,11 @@ export declare function gc_kind(kind: number): GcEvent["kind"];
127
127
  export declare function heap_sites(head: HeapNode, dict: Record<string, AllocSite>): Map<string, number>;
128
128
  /** What was allocated between two reads: per site, the growth of its cumulative bytes. */
129
129
  export declare function slice_delta(prev: Map<string, number>, cur: Map<string, number>): Record<string, number>;
130
+ /**
131
+ * Each pause of a profiled window charged to the allocations before it, and each allocator to
132
+ * the pause time it caused.
133
+ * @heisenbench n log n in input
134
+ */
130
135
  export declare function attribute_gc(input: {
131
136
  slices: AllocSlice[];
132
137
  events: GcEvent[];
@@ -97,20 +97,58 @@ function slice_delta(prev, cur) {
97
97
  const r1 = (n) => Math.round(n * 10) / 10;
98
98
  const r2 = (n) => Math.round(n * 100) / 100;
99
99
  const mb = (b) => r1(b / 1048576);
100
+ /**
101
+ * What was running at each moment of `times`: the first segment (in the timeline's order) that
102
+ * holds it, else the last to end before it, within 50 ms. A collector, idle or profiler segment is
103
+ * not the app's own work. The moments are swept in order, so the timeline is read about once
104
+ * instead of once per moment.
105
+ */
106
+ function running_at_each(segs, times) {
107
+ const work = segs.filter((s) => s.category !== "gc" && s.category !== "idle" && s.category !== "profiler");
108
+ const at = [...new Set(times)].filter((t) => !Number.isNaN(t)).sort((a, b) => a - b);
109
+ const found = [];
110
+ const open = Array.from({ length: at.length + 1 }, (_, k) => k);
111
+ const next_open = (k) => {
112
+ while (open[k] !== k) k = open[k] = open[open[k]];
113
+ return k;
114
+ };
115
+ for (const s of work) {
116
+ if (!(s.t0 <= s.t1)) continue;
117
+ let lo = 0;
118
+ let hi = at.length;
119
+ while (lo < hi) {
120
+ const mid = lo + hi >>> 1;
121
+ if (at[mid] < s.t0) lo = mid + 1;
122
+ else hi = mid;
123
+ }
124
+ for (let k = next_open(lo); k < at.length && at[k] <= s.t1; k = next_open(k + 1)) {
125
+ found[k] = s;
126
+ open[k] = k + 1;
127
+ }
128
+ }
129
+ const by_end = work.map((s, i) => ({
130
+ s,
131
+ i
132
+ })).filter(({ s }) => !Number.isNaN(s.t1)).sort((a, b) => a.s.t1 - b.s.t1 || a.i - b.i);
133
+ const out = /* @__PURE__ */ new Map();
134
+ let best;
135
+ for (let k = 0, j = 0; k < at.length; k++) {
136
+ for (; j < by_end.length && by_end[j].s.t1 <= at[k]; j++) if (!best || by_end[j].s.t1 > best.t1) best = by_end[j].s;
137
+ out.set(at[k], found[k] ?? (best && at[k] - best.t1 <= 50 ? best : void 0));
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * Each pause of a profiled window charged to the allocations before it, and each allocator to
143
+ * the pause time it caused.
144
+ * @heisenbench n log n in input
145
+ */
100
146
  function attribute_gc(input) {
101
147
  const { slices, dict } = input;
102
148
  const events = [...input.events].sort((a, b) => a.t - b.t);
103
149
  const single = slices.length === 1;
104
- const running_at = (t) => {
105
- const segs = input.running ?? [];
106
- let best;
107
- for (const s of segs) {
108
- if (s.category === "gc" || s.category === "idle" || s.category === "profiler") continue;
109
- if (s.t0 <= t && t <= s.t1) return s;
110
- if (s.t1 <= t && (!best || s.t1 > best.t1)) best = s;
111
- }
112
- return best && t - best.t1 <= 50 ? best : void 0;
113
- };
150
+ const run_by_t = running_at_each(input.running ?? [], events.map((e) => e.t));
151
+ const running_at = (t) => run_by_t.get(t);
114
152
  const ours = (k) => {
115
153
  const c = dict[k]?.category;
116
154
  return c !== "v8" && c !== "gc" && c !== "profiler";
@@ -121,10 +159,36 @@ function attribute_gc(input) {
121
159
  return d ? `${d.name}|${d.url}|${d.line}|${d.caller ?? ""}|${d.component ?? ""}` : k;
122
160
  };
123
161
  const first_key = {};
162
+ const by_t0 = slices.map((s, i) => ({
163
+ s,
164
+ i
165
+ })).filter(({ s }) => s.t1 - s.t0 > 0).sort((a, b) => a.s.t0 - b.s.t0 || a.i - b.i);
166
+ const reach = [];
167
+ for (const { s } of by_t0) reach.push(Math.max(reach[reach.length - 1] ?? -Infinity, s.t1));
168
+ const read_in_order = by_t0.every((x, j) => j === 0 || by_t0[j - 1].i < x.i);
169
+ const first_where = (holds) => {
170
+ let lo = 0;
171
+ let hi = by_t0.length;
172
+ while (lo < hi) {
173
+ const mid = lo + hi >>> 1;
174
+ if (holds(mid)) hi = mid;
175
+ else lo = mid + 1;
176
+ }
177
+ return lo;
178
+ };
124
179
  const between = (from, to) => {
125
180
  const acc = {};
126
181
  let profiler_bytes = 0;
127
- for (const s of slices) {
182
+ if (!(from < to)) return {
183
+ app: acc,
184
+ profiler_bytes
185
+ };
186
+ const hits = [];
187
+ const end = first_where((j) => by_t0[j].s.t0 >= to);
188
+ for (let j = first_where((j) => reach[j] > from); j < end; j++) if (by_t0[j].s.t1 > from) hits.push(by_t0[j].i);
189
+ if (!read_in_order) hits.sort((a, b) => a - b);
190
+ for (const i of hits) {
191
+ const s = slices[i];
128
192
  const len = s.t1 - s.t0;
129
193
  if (len <= 0 || s.t1 <= from || s.t0 > to) continue;
130
194
  const w = Math.max(0, Math.min(1, (Math.min(s.t1, to) - Math.max(s.t0, from)) / len));