@ogygia/devtools 0.11.4 → 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 (81) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +3 -0
  3. package/dist/app.js +9 -0
  4. package/dist/devtools/BoundaryOverlay.svelte +10 -6
  5. package/dist/devtools/Devtools.svelte +24 -18
  6. package/dist/devtools/IslandDetail.svelte +7 -5
  7. package/dist/devtools/PageTab.svelte +4 -2
  8. package/dist/devtools/ProfilerTab.svelte +8 -2
  9. package/dist/devtools/SessionTab.svelte +21 -8
  10. package/dist/devtools/highlight.js +14 -7
  11. package/dist/devtools/ledger-dom.js +6 -9
  12. package/dist/devtools/names.js +18 -17
  13. package/dist/devtools/page.d.ts +2 -0
  14. package/dist/devtools/page.js +37 -17
  15. package/dist/devtools/profile-store.d.ts +4 -9
  16. package/dist/devtools/profile-store.js +26 -21
  17. package/dist/devtools/session.d.ts +2 -0
  18. package/dist/devtools/session.js +48 -42
  19. package/dist/devtools/testing-engine.js +10 -1
  20. package/dist/devtools/ui-app.d.ts +3 -1
  21. package/dist/devtools/ui-app.js +9 -3
  22. package/dist/devtools/ui.js +83 -40
  23. package/dist/messages/build.d.ts +21 -0
  24. package/dist/messages/build.js +56 -0
  25. package/dist/messages/index.d.ts +3 -0
  26. package/dist/messages/index.js +3 -0
  27. package/dist/messages/runtime.d.ts +63 -0
  28. package/dist/messages/runtime.js +122 -0
  29. package/dist/profiler/async-io.d.ts +12 -7
  30. package/dist/profiler/async-io.js +94 -82
  31. package/dist/profiler/beacon-standalone.js +15 -6
  32. package/dist/profiler/busy.d.ts +13 -0
  33. package/dist/profiler/busy.js +45 -0
  34. package/dist/profiler/client.js +5 -24
  35. package/dist/profiler/embedded-maps.js +9 -5
  36. package/dist/profiler/gc.d.ts +5 -0
  37. package/dist/profiler/gc.js +75 -11
  38. package/dist/profiler/index.d.ts +23 -2
  39. package/dist/profiler/index.js +648 -373
  40. package/dist/profiler/net.d.ts +14 -6
  41. package/dist/profiler/net.js +61 -45
  42. package/dist/profiler/patterns.js +29 -1
  43. package/dist/profiler/source-scan.js +24 -12
  44. package/dist/profiler/standalone.js +12 -4
  45. package/dist/profiler/storage/index.d.ts +14 -3
  46. package/dist/profiler/storage/index.js +41 -41
  47. package/dist/profiler/storage/postgres.d.ts +18 -5
  48. package/dist/profiler/storage/postgres.js +124 -85
  49. package/dist/profiler/storage/redis.d.ts +18 -5
  50. package/dist/profiler/storage/redis.js +160 -131
  51. package/dist/profiler/storage/sqlite.d.ts +15 -2
  52. package/dist/profiler/storage/sqlite.js +54 -48
  53. package/dist/profiler/ui/ComponentsTable.svelte +2 -2
  54. package/dist/profiler/ui/Dashboard.svelte +2 -3
  55. package/dist/profiler/ui/DownloadHtml.svelte +1 -0
  56. package/dist/profiler/ui/ExportButton.svelte +1 -0
  57. package/dist/profiler/ui/Flame.svelte +10 -4
  58. package/dist/profiler/ui/Flame.svelte.d.ts +0 -5
  59. package/dist/profiler/ui/FrameDetails.svelte +13 -2
  60. package/dist/profiler/ui/FrameDetails.svelte.d.ts +0 -6
  61. package/dist/profiler/ui/FunctionsTable.svelte +2 -2
  62. package/dist/profiler/ui/IslandsTable.svelte +2 -2
  63. package/dist/profiler/ui/OnThisPage.svelte +12 -4
  64. package/dist/profiler/ui/RunView.svelte +31 -27
  65. package/dist/profiler/ui/ShareLink.svelte +6 -1
  66. package/dist/profiler/ui/Sidebar.svelte +14 -9
  67. package/dist/profiler/ui/Treemap.svelte +11 -4
  68. package/dist/profiler/ui/Treemap.svelte.d.ts +0 -6
  69. package/dist/profiler/ui/WaitingTable.svelte +2 -2
  70. package/dist/profiler/ui/fake-progress.svelte.d.ts +7 -12
  71. package/dist/profiler/ui/fake-progress.svelte.js +57 -36
  72. package/dist/profiler/ui/press.js +4 -2
  73. package/dist/profiler/ui/row-anchor.svelte.d.ts +0 -6
  74. package/dist/profiler/ui/row-anchor.svelte.js +13 -8
  75. package/dist/profiler/ui/sort.svelte.d.ts +9 -6
  76. package/dist/profiler/ui/sort.svelte.js +34 -28
  77. package/dist/profiler/visit.d.ts +0 -1
  78. package/dist/profiler/visit.js +1 -2
  79. package/dist/server.js +27 -10
  80. package/dist/vite.js +3 -2
  81. package/package.json +18 -9
@@ -6,9 +6,9 @@ import { is_ogp, ogp_decode, ogp_encode, recover_ogp_bytes } from "./crypto.js";
6
6
  import { AFTER_LOGIN_PARAM, SESSION_COOKIE, describe_cookie_diagnosis, raw_cookie_values, session_cookie_values } from "./session-cookie.js";
7
7
  import { label_call, load_lane_of, phase_of_frame } from "./timeline.js";
8
8
  import { merge_visits, parse_visit } from "./visit.js";
9
- import { own_requests } from "./own-requests.js";
10
9
  import { MAX_VISITS_PER_KEY } from "./storage/visits.js";
11
10
  import { hole_slots } from "./hole-slots.js";
11
+ import { own_requests } from "./own-requests.js";
12
12
  import { client_windows, interaction_windows } from "./client-windows.js";
13
13
  import { byte_strip } from "./byte-strip.js";
14
14
  import { stream_tail } from "./stream-tail.js";
@@ -22,6 +22,7 @@ import { build_lineage, data_prop_names, data_reads, whole_read_line } from "./l
22
22
  import { build_ledger, pkg_of } from "./ledger.js";
23
23
  import { at_line, build_drill, call_group, tag_fills } from "./drill.js";
24
24
  import { forecast_of } from "./forecast.js";
25
+ import { OgygiaError, warn } from "../messages/runtime.js";
25
26
  import { site_fixes } from "./site-fixes.js";
26
27
  import { may_be_chunk, module_category, module_lookup } from "./module-map.js";
27
28
  import { embedded_chunk, embedded_files, embedded_map_of, embedded_source, load_embedded_maps } from "./embedded-maps.js";
@@ -35,12 +36,14 @@ import { gzip_large } from "./compress.js";
35
36
  import { io_kind, mark_left_each_run } from "./async-io.js";
36
37
  import { set_span_recorder } from "./span.js";
37
38
  import { register_profiler_file } from "./frames.js";
39
+ import { devtools_app } from "../app.js";
38
40
  import { app_asset_rel, client_dir_candidates, client_file_finder } from "./client-files.js";
39
41
  import { build_profiler_router } from "./profiler-router.js";
40
42
  import { detect_dev } from "./env.js";
41
43
  import { refresh_target } from "./refresh-stub.js";
42
44
  import { assets_diff, engine_of, hydration_hazards, vital_parts, weigh_assets } from "@ogygia/core/insights";
43
- import { hole_stats_of, mark_personal, request_stats_of, set_batch_hole_listener, set_request_stats_detail } from "@ogygia/core/internal/server";
45
+ import { STALE, flights, lock, machine, outbox, policy, scope } from "@ogygia/machine";
46
+ import { current_request_scope, hold_request_stats_detail, hole_stats_of, mark_personal, request_scope, request_stats_of, set_batch_hole_listener } from "@ogygia/core/internal/server";
44
47
  import { fnv1a32 } from "@ogygia/core/plugin/utils";
45
48
  import { chunkBarrels, chunkContents, chunkDuplicates, chunkHeavy, islandHazards, islandPageKeys, islandPageWhy } from "virtual:ogygia/island-deps";
46
49
  import { error, isHttpError, isRedirect } from "@ogygia/router";
@@ -84,6 +87,22 @@ async function read_timed(res, t0) {
84
87
  chunks
85
88
  };
86
89
  }
90
+ /** How a sink post is tried again: a sink restarting is ridden out, and a sink that is down costs
91
+ * each post 30 s at most, so the posts after it are not held long. */
92
+ const SINK_POLICY = /* @__PURE__ */ policy({
93
+ attempts: 4,
94
+ backoff: [
95
+ 1e3,
96
+ 5e3,
97
+ 1e4
98
+ ],
99
+ jitter: .2,
100
+ deadline: 3e4
101
+ });
102
+ /** The longest one try at a post waits for the sink's answer. */
103
+ const SINK_TRY_MS = 5e3;
104
+ const error_text = (error) => error instanceof Error ? error.message : String(error);
105
+ const noop = () => {};
87
106
  register_profiler_file();
88
107
  const MAX_VITALS_PATHS = 200;
89
108
  const MAX_VITALS_SAMPLES = 30;
@@ -226,36 +245,52 @@ function is_hex_id(s) {
226
245
  }
227
246
  return true;
228
247
  }
248
+ /** A status that says the file is not there for good: a client error, but not a timeout or a
249
+ * rate limit (408, 429), which say "not now". */
250
+ const gone_for_good = (status) => status >= 400 && status < 500 && status !== 408 && status !== 429;
251
+ /** The process's URL weights: an answer (bytes, or a file that is not there) is kept; a failure
252
+ * (a 5xx, a timeout, the network) is not, so the next report asks again. Reports weighing the
253
+ * same file at once share one fetch. */
254
+ const url_weights = () => flights.keyed()({
255
+ name: "profiler url weights",
256
+ key: ["url"],
257
+ ignore: { fetch_url: "every report fetches the same hashed file through the app" },
258
+ run: async ({ url, fetch_url }, signal) => {
259
+ const res = await fetch_url(url, signal);
260
+ if (!res.ok) {
261
+ res.body?.cancel().catch(noop);
262
+ if (gone_for_good(res.status)) return null;
263
+ throw new Error(`status ${res.status}`);
264
+ }
265
+ const len = Number(res.headers.get("content-length"));
266
+ if (!res.headers.get("content-encoding") && Number.isFinite(len) && len > 0) {
267
+ res.body?.cancel().catch(noop);
268
+ return len;
269
+ }
270
+ return (await res.arrayBuffer()).byteLength;
271
+ }
272
+ });
229
273
  /** GET each URL through the app's own fetch and count its bytes — the JS weight of an island's
230
- * closure. Cached per process (immutable hashed assets), a handful at a time, capped in time so
231
- * a slow static host can never hold a report hostage. */
232
- async function weigh_urls(urls, fetch_url, cache, budget_ms = 4e3) {
233
- const todo = [...new Set(urls)].filter((u) => u && !cache.has(u));
234
- const deadline = Date.now() + budget_ms;
274
+ * closure. Kept per process (`url_weights`), a handful at a time, capped in time so a slow static
275
+ * host can never hold a report hostage: a URL not weighed within the budget is left out. */
276
+ async function weigh_urls(urls, fetch_url, weights, budget_ms = 4e3) {
277
+ const todo = [...new Set(urls)].filter((u) => u);
278
+ const budget = AbortSignal.timeout(budget_ms);
279
+ const out = {};
235
280
  let i = 0;
236
281
  const worker = async () => {
237
- while (i < todo.length && Date.now() < deadline) {
238
- const u = todo[i++];
282
+ while (i < todo.length && !budget.aborted) {
283
+ const url = todo[i++];
239
284
  try {
240
- const res = await with_timeout(fetch_url(u), Math.max(200, deadline - Date.now()), "weigh");
241
- if (!res.ok) {
242
- cache.set(u, null);
243
- continue;
244
- }
245
- const len = Number(res.headers.get("content-length"));
246
- const bytes = !res.headers.get("content-encoding") && Number.isFinite(len) && len > 0 ? len : (await res.arrayBuffer()).byteLength;
247
- cache.set(u, bytes);
248
- } catch {
249
- cache.set(u, null);
250
- }
285
+ const bytes = await weights.get({
286
+ url,
287
+ fetch_url
288
+ }, { signal: budget });
289
+ if (typeof bytes === "number") out[url] = bytes;
290
+ } catch {}
251
291
  }
252
292
  };
253
293
  await Promise.all(Array.from({ length: Math.min(6, todo.length) }, worker));
254
- const out = {};
255
- for (const u of new Set(urls)) {
256
- const b = cache.get(u);
257
- if (typeof b === "number") out[u] = b;
258
- }
259
294
  return out;
260
295
  }
261
296
  const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0;
@@ -282,6 +317,62 @@ function summarize_measures(entries) {
282
317
  const MAX_SPANS_PER_REQUEST = 2e3;
283
318
  const MAX_WINDOW_SPANS = 2e4;
284
319
  const MAX_TAGS = 16;
320
+ /** The span recorder one recording window installs (`span()` / `tag()` from ogygia/profiler): every
321
+ * span the window sees, nested through the request's async context, or through the window's own
322
+ * store where no request is attributed. */
323
+ var WindowSpans = class {
324
+ /** every span recorded during the window, any request */
325
+ spans = [];
326
+ #seq = 0;
327
+ #als;
328
+ #span_als;
329
+ #netmod;
330
+ constructor(als, span_als, netmod) {
331
+ this.#als = als;
332
+ this.#span_als = span_als;
333
+ this.#netmod = netmod;
334
+ }
335
+ begin(name, attrs) {
336
+ const ctx = this.#als?.getStore();
337
+ const parent = ctx?.span ?? this.#span_als.getStore();
338
+ const rec = {
339
+ id: ++this.#seq,
340
+ name: String(name).slice(0, 120),
341
+ start: performance.now(),
342
+ ms: -1,
343
+ ...attrs ? { attrs: { ...attrs } } : {},
344
+ ...parent !== void 0 ? { parent } : {},
345
+ caller_site: this.#netmod?.nearest_app_site(),
346
+ route: ctx?.route ?? null,
347
+ path: ctx?.path ?? null
348
+ };
349
+ if (this.spans.length < MAX_WINDOW_SPANS) this.spans.push(rec);
350
+ if (ctx && ctx.spans.length < MAX_SPANS_PER_REQUEST) ctx.spans.push(rec);
351
+ return rec;
352
+ }
353
+ within(s, fn) {
354
+ const als = this.#als;
355
+ const ctx = als?.getStore();
356
+ return ctx && als ? als.run({
357
+ ...ctx,
358
+ span: s.id
359
+ }, fn) : this.#span_als.run(s.id, fn);
360
+ }
361
+ end(s, attrs, error) {
362
+ s.ms = round2(performance.now() - s.start);
363
+ if (attrs) s.attrs = {
364
+ ...s.attrs,
365
+ ...attrs
366
+ };
367
+ if (error !== void 0) s.error = error instanceof Error ? error.message : String(error);
368
+ }
369
+ tag(key, value) {
370
+ const ctx = this.#als?.getStore();
371
+ if (!ctx) return;
372
+ const tags = ctx.entry.tags ??= {};
373
+ if (key in tags || Object.keys(tags).length < MAX_TAGS) tags[key] = value;
374
+ }
375
+ };
285
376
  /** the process's live profiler (the newest): an older one's background timers stand down */
286
377
  const LIVE_PROFILER = Symbol.for("ogygia.profiler.live");
287
378
  const MAX_NET_PER_REQUEST = 100;
@@ -290,6 +381,102 @@ const MAX_BACKGROUND_NET = 300;
290
381
  const RECORDING_MAX_MS = 35e3;
291
382
  const RECORDING_HARD_CAP_MS = 3e4;
292
383
  const PER_RENDER_TIMEOUT_MS = 15e3;
384
+ /**
385
+ * What the recorder's owner records (the process's one inspector and attribution context). Its
386
+ * lock says who owns it; this says how: `background` (the trap, the sampler, a started window: a
387
+ * request is matched to it by time, and runs in no request context) or `recording` (a page profile
388
+ * or a header-profiled request: every request is attributed). A recording still running past the
389
+ * hard cap is `cut`: it keeps the inspector until it ends, but the site stops paying for its
390
+ * attribution context.
391
+ */
392
+ const recorder_machine = machine()({
393
+ name: "profiler-recorder",
394
+ initial: "free",
395
+ steps: {
396
+ free: ["background", "recording"],
397
+ background: ["free"],
398
+ recording: ["free", "cut"],
399
+ cut: ["free"]
400
+ },
401
+ after: { recording: {
402
+ ms: 32e3,
403
+ to: "cut",
404
+ data: (s) => ({
405
+ token: s.token,
406
+ since: s.since
407
+ })
408
+ } }
409
+ });
410
+ /**
411
+ * A window you start and stop. `recording` lasts at most WINDOW_MAX_MS (a stop that never comes);
412
+ * leaving it, however, ends the capture's work. `stopping` makes the report, and holds the
413
+ * recorder until it is made.
414
+ */
415
+ const window_machine = machine()({
416
+ name: "profiler-window",
417
+ initial: "idle",
418
+ steps: {
419
+ idle: ["recording"],
420
+ recording: ["stopping", "idle"],
421
+ stopping: ["idle"]
422
+ },
423
+ after: { recording: {
424
+ ms: WINDOW_MAX_MS,
425
+ to: "idle"
426
+ } }
427
+ });
428
+ /**
429
+ * One background loop (the trap, the always-on sampler): waiting for its next window (the visit
430
+ * owns the timer), taking one, or ended. `caught` is what it kept so far (the trap's slow requests;
431
+ * the sampler keeps none). It ends `done` (the trap kept all it was asked to), `refused` (a window
432
+ * failed: the inspector refused it), or `retired` (a newer profiler took the process over).
433
+ */
434
+ const loop_machine = machine()({
435
+ name: "profiler-background-loop",
436
+ initial: "idle",
437
+ steps: {
438
+ idle: ["scheduled", "retired"],
439
+ scheduled: [
440
+ "scheduled",
441
+ "capturing",
442
+ "retired"
443
+ ],
444
+ capturing: [
445
+ "scheduled",
446
+ "done",
447
+ "refused",
448
+ "retired"
449
+ ],
450
+ done: ["retired"],
451
+ refused: ["retired"],
452
+ retired: []
453
+ }
454
+ });
455
+ const caught_of = (loop) => {
456
+ const s = loop.now;
457
+ return s.kind === "idle" ? 0 : s.caught;
458
+ };
459
+ /** The trap as the dashboard shows it: waiting for or taking a window, stopped by a refusal, or
460
+ * not running (done, or not armed yet). */
461
+ function trap_state(loop) {
462
+ const kind = loop.now.kind;
463
+ if (kind === "scheduled" || kind === "capturing") return "armed";
464
+ return kind === "refused" ? "stopped" : "done";
465
+ }
466
+ /** The span recorders of the windows recording now, newest last. Spans go to the newest; a window
467
+ * that ends hands them back to the one before it, never to nothing while another still records
468
+ * (two profilers in one dev process). */
469
+ const span_windows = [];
470
+ function own_spans(r) {
471
+ span_windows.push(r);
472
+ set_span_recorder(r);
473
+ }
474
+ function disown_spans(r) {
475
+ const at = span_windows.lastIndexOf(r);
476
+ if (at === -1) return;
477
+ span_windows.splice(at, 1);
478
+ set_span_recorder(span_windows.length > 0 ? span_windows[span_windows.length - 1] : null);
479
+ }
293
480
  /** the heap's fill (of its limit) past which no timed render is made: one more could end the process */
294
481
  const HEAP_STOP = .85;
295
482
  /** …and past which the extra renders (what a render keeps, the growth check) are skipped */
@@ -426,36 +613,64 @@ var Profiler = class {
426
613
  #sink_url = null;
427
614
  #sink_key = null;
428
615
  #sink_every = 1e4;
616
+ /** The background work's owner (the sink's timer, the trap's and the sampler's, their windows):
617
+ * closed when a newer profiler takes the process over (`#retire`). */
618
+ #background = scope();
619
+ /** The sink's timer, while it runs: its cancel. */
429
620
  #sink_timer = null;
430
- #sink_inflight = false;
621
+ /** The sink's posts, one at a time in the order their rows were read: a failed one is tried
622
+ * again before the next goes, so rows reach the sink in order and only once (#293). */
623
+ #sink_posts = outbox({
624
+ name: "profiler sink",
625
+ send: (post, signal) => this.#sink_post(post, signal),
626
+ id: (post) => post.id,
627
+ retry: SINK_POLICY,
628
+ inOrder: true,
629
+ giveUp: (post, error) => {
630
+ this.#sink_last = {
631
+ at: Date.now(),
632
+ ok: false,
633
+ rows: post.rows,
634
+ error: error_text(error)
635
+ };
636
+ warn("devtools_profiler_sink_undelivered", { rows: String(post.rows) }, error);
637
+ },
638
+ waitUntil: (work) => devtools_app()?.keepAlive(work)
639
+ });
431
640
  #sink_last = null;
432
- #trap_caught = 0;
433
- #trap_running = false;
434
- #trap_timer = null;
641
+ /** the trap's loop (see `loop_machine`) */
642
+ #trap_loop = loop_machine.create();
435
643
  #sample;
436
644
  #sampled = [];
437
- #sample_timer = null;
645
+ /** the always-on sampler's loop */
646
+ #sample_loop = loop_machine.create();
647
+ /** why the background loops are off on this host (serverless), when they are */
438
648
  #background_note = null;
439
649
  /** bytes per asset URL, measured once per process */
440
- #weights = /* @__PURE__ */ new Map();
650
+ #weights = url_weights();
441
651
  /** every page asset weighed so far (hashed assets never change): page-assets.ts */
442
652
  #asset_weights = /* @__PURE__ */ new Map();
443
653
  #background_net = [];
444
- #window_net = null;
654
+ /** each capture's own network log while it records (two only after a reset left one running) */
655
+ #window_nets = /* @__PURE__ */ new Set();
445
656
  #inflight = 0;
446
- #recording_since = 0;
447
657
  /** performance.now() when this instance's first request arrived (its cold start, on Lambda) */
448
658
  #first_request_pt;
449
659
  /** requests this instance has handled */
450
660
  #requests_seen = 0;
451
- #recorder_busy = false;
661
+ #recorder = lock();
662
+ /** what the owner records, and since when (see `recorder_machine`) */
663
+ #holder = recorder_machine.create();
664
+ /** the window you start and stop (see `window_machine`) */
665
+ #win = window_machine.create();
452
666
  /** page profiles waiting for a background window to give the recorder back */
453
667
  #page_waiting = 0;
454
668
  /** the durable report store (SQLite/Redis/Postgres/custom), when configured — else null and the
455
669
  * in-memory map is all there is (the prior behaviour) */
456
670
  #store = null;
671
+ /** the store's `init`: kept once it succeeds, tried again by the next use after a failure */
672
+ #store_init = null;
457
673
  #als = null;
458
- #init_done = null;
459
674
  /** Re-assert the `globalThis.fetch` patch before a profile (self-heal if it was replaced). */
460
675
  #ensure_net = null;
461
676
  /** wraps Kit's `event.fetch` (in-process same-origin dispatch) with the recorder, per attributed request */
@@ -479,21 +694,22 @@ var Profiler = class {
479
694
  this.#disabled = options.enabled === false;
480
695
  this.#trap = options.trap && options.trap.over > 0 ? options.trap : null;
481
696
  if (options.store) {
482
- this.#store = options.store;
483
- (async () => this.#store.init?.())().catch((e) => {
484
- console.error("[ogygia/profiler] store init failed; falling back to in-memory:", e);
485
- this.#store = null;
697
+ const store = options.store;
698
+ this.#store = store;
699
+ this.#store_init = flights({
700
+ name: "profiler store init",
701
+ run: async () => {
702
+ await store.init?.();
703
+ }
486
704
  });
705
+ this.#store_ready();
487
706
  }
488
707
  this.#client_cpu = options.clientCpu !== false;
489
708
  if (options.sink?.url) {
490
709
  this.#sink_url = options.sink.url;
491
710
  this.#sink_key = options.sink.key ?? this.secret ?? null;
492
711
  this.#sink_every = Math.max(1e3, options.sink.every ?? 1e4);
493
- if (!Number.isFinite(serverless_work_budget_ms())) {
494
- this.#sink_timer = setInterval(() => void this.#sink_flush(), this.#sink_every);
495
- this.#sink_timer.unref?.();
496
- }
712
+ if (!Number.isFinite(serverless_work_budget_ms())) this.#sink_timer = this.#background.every(() => void this.#sink_flush(), this.#sink_every);
497
713
  }
498
714
  this.#sample = options.sample ? options.sample : null;
499
715
  globalThis[LIVE_PROFILER] = this;
@@ -531,15 +747,19 @@ var Profiler = class {
531
747
  }
532
748
  /** Stand down: the background timers stop (a sink sends what it holds first). */
533
749
  #retire() {
534
- if (this.#trap_timer) clearTimeout(this.#trap_timer);
535
- if (this.#sample_timer) clearTimeout(this.#sample_timer);
536
- this.#trap_timer = null;
537
- this.#sample_timer = null;
538
- if (this.#sink_timer) {
539
- clearInterval(this.#sink_timer);
540
- this.#sink_timer = null;
541
- this.#sink_flush();
542
- }
750
+ const sinking = this.#sink_timer !== null;
751
+ this.#sink_timer = null;
752
+ for (const loop of [this.#trap_loop, this.#sample_loop]) if (loop.now.kind !== "retired") loop.go("retired", { caught: caught_of(loop) });
753
+ this.#background.close();
754
+ if (sinking) this.#sink_flush();
755
+ }
756
+ /** A background window's length: `ms`, or less when the profiler stands down meanwhile. */
757
+ #pause(ms) {
758
+ return new Promise((done) => {
759
+ const wait = this.#background.child();
760
+ wait.defer(done);
761
+ wait.timeout(() => wait.close(), ms);
762
+ });
543
763
  }
544
764
  /** Arm the background recorders (once, from the first request). Both need a real server: on a
545
765
  * serverless host an instance lives for one request and the inspector is often unavailable. */
@@ -549,32 +769,52 @@ var Profiler = class {
549
769
  this.#background_note = "trap / sampling are off on this serverless host (an instance lives for one request).";
550
770
  return;
551
771
  }
552
- if (this.#trap && !this.#trap_timer) this.#schedule_trap(1e3);
553
- if (this.#sample && !this.#sample_timer) this.#schedule_sample((this.#sample.every ?? 60) * 1e3);
772
+ if (this.#trap && this.#trap_loop.now.kind === "idle") this.#schedule(this.#trap_loop, () => this.#trap_cycle(), 1e3);
773
+ if (this.#sample && this.#sample_loop.now.kind === "idle") this.#schedule(this.#sample_loop, () => this.#sample_cycle(), (this.#sample.every ?? 60) * 1e3);
554
774
  }
555
- #schedule_trap(delay_ms) {
556
- this.#trap_timer = setTimeout(() => void this.#trap_cycle(), delay_ms);
557
- this.#trap_timer.unref?.();
775
+ /** The loop's next window, `delay_ms` from now: a timer of that wait's own visit. */
776
+ #schedule(loop, cycle, delay_ms) {
777
+ const s = loop.now;
778
+ if (s.kind !== "idle" && s.kind !== "scheduled" && s.kind !== "capturing") return;
779
+ loop.go("scheduled", { caught: caught_of(loop) });
780
+ loop.scope.timeout(() => void cycle(), delay_ms);
558
781
  }
559
- #schedule_sample(delay_ms) {
560
- this.#sample_timer = setTimeout(() => void this.#sample_cycle(), delay_ms);
561
- this.#sample_timer.unref?.();
782
+ /** A window failed: the loop stops, and says why (on the dashboard, and once as a message). */
783
+ #refused(loop, name, caught, error) {
784
+ if (!loop.move("capturing", "refused", {
785
+ caught,
786
+ error: error_text(error)
787
+ })) return;
788
+ warn("devtools_profiler_background_stopped", { loop: name }, error);
789
+ }
790
+ /** The background loops that stopped, and why the loops are off, when they are: one note. */
791
+ #background_notes() {
792
+ const notes = [];
793
+ if (this.#background_note !== null) notes.push(this.#background_note);
794
+ const t = this.#trap_loop.now;
795
+ if (t.kind === "refused") notes.push(`the trap stopped: ${t.error}.`);
796
+ const s = this.#sample_loop.now;
797
+ if (s.kind === "refused") notes.push(`always-on sampling stopped: ${s.error}.`);
798
+ return notes.length === 0 ? null : notes.join(" ");
562
799
  }
563
800
  /** One rolling trap window: sample coarsely, then keep the window only if a request in it blew
564
801
  * past the threshold. The slowest such request is the report's request; the timeline is its. */
565
802
  async #trap_cycle() {
566
803
  const t = this.#trap;
567
- this.#trap_timer = null;
804
+ const loop = this.#trap_loop;
805
+ const s = loop.now;
806
+ if (s.kind !== "scheduled") return;
568
807
  if (this.#superseded()) return this.#retire();
569
808
  const keep = t.keep ?? 3;
570
- if (this.#trap_caught >= keep) return;
571
- if (this.#page_waiting) return this.#schedule_trap(1e3);
572
- if (!this.#try_acquire_recorder()) return this.#schedule_trap(2e3);
573
- this.#trap_running = true;
574
- this.#recording_since = Date.now();
809
+ const again = (ms) => this.#schedule(loop, () => this.#trap_cycle(), ms);
810
+ if (this.#page_waiting) return again(1e3);
811
+ const token = this.#take("background");
812
+ if (token === null) return again(2e3);
813
+ loop.go("capturing", { caught: s.caught });
814
+ let caught = s.caught;
575
815
  try {
576
816
  const window_ms = t.window ?? 5e3;
577
- const cap = await this.#capture_window((t.interval ?? 10) * 1e3, () => new Promise((r) => setTimeout(r, window_ms)), { light: true });
817
+ const cap = await this.#capture_window((t.interval ?? 10) * 1e3, () => this.#pause(window_ms), { light: true });
578
818
  const slow = this.#ring.filter((e) => !e.internal && e.pt !== void 0 && e.ts >= cap.t0 && e.ts + e.ms <= cap.t1 && e.ms >= t.over).sort((x, y) => y.ms - x.ms)[0];
579
819
  if (slow) {
580
820
  cap.window = {
@@ -607,31 +847,31 @@ var Profiler = class {
607
847
  });
608
848
  this.#sink_flush();
609
849
  }
610
- this.#trap_caught++;
850
+ caught++;
611
851
  }
612
- } catch {
613
- this.#trap_caught = keep;
852
+ } catch (error) {
853
+ return this.#refused(loop, "trap", caught, error);
614
854
  } finally {
615
- this.#trap_running = false;
616
- this.#recording_since = 0;
617
- this.#release_als();
618
- this.#release_recorder();
855
+ this.#free(token);
619
856
  }
620
- if (this.#trap_caught < keep) this.#schedule_trap(1e3);
857
+ if (caught >= keep) loop.move("capturing", "done", { caught });
858
+ else again(1e3);
621
859
  }
622
860
  /** One always-on sample: a short coarse window folded into the rolling hot-functions table. */
623
861
  async #sample_cycle() {
624
862
  const s = this.#sample;
625
- this.#sample_timer = null;
863
+ const loop = this.#sample_loop;
864
+ if (loop.now.kind !== "scheduled") return;
626
865
  if (this.#superseded()) return this.#retire();
627
866
  const every_ms = (s.every ?? 60) * 1e3;
628
- if (this.#page_waiting) return this.#schedule_sample(Math.min(every_ms, 2e3));
629
- if (!this.#try_acquire_recorder()) return this.#schedule_sample(Math.min(every_ms, 5e3));
630
- this.#trap_running = true;
631
- this.#recording_since = Date.now();
867
+ const again = (ms) => this.#schedule(loop, () => this.#sample_cycle(), ms);
868
+ if (this.#page_waiting) return again(Math.min(every_ms, 2e3));
869
+ const token = this.#take("background");
870
+ if (token === null) return again(Math.min(every_ms, 5e3));
871
+ loop.go("capturing", { caught: 0 });
632
872
  try {
633
873
  const window_ms = s.window ?? 2e3;
634
- const cap = await this.#capture_window((s.interval ?? 10) * 1e3, () => new Promise((r) => setTimeout(r, window_ms)), { light: true });
874
+ const cap = await this.#capture_window((s.interval ?? 10) * 1e3, () => this.#pause(window_ms), { light: true });
635
875
  const resolver = await this.#make_resolver();
636
876
  const a = analyze(cap.profile, resolver, void 0, void 0, void 0, this.#module_hint);
637
877
  this.#sampled.push({
@@ -663,15 +903,12 @@ var Profiler = class {
663
903
  }))
664
904
  });
665
905
  }
666
- } catch {
667
- return;
906
+ } catch (error) {
907
+ return this.#refused(loop, "always-on sampler", 0, error);
668
908
  } finally {
669
- this.#trap_running = false;
670
- this.#recording_since = 0;
671
- this.#release_als();
672
- this.#release_recorder();
909
+ this.#free(token);
673
910
  }
674
- this.#schedule_sample(every_ms);
911
+ again(every_ms);
675
912
  }
676
913
  /** The always-on table: every kept window folded, hot functions by summed self time. */
677
914
  #sampled_summary() {
@@ -704,10 +941,10 @@ var Profiler = class {
704
941
  };
705
942
  }
706
943
  #collect_window(call) {
707
- if (this.#window_net && this.#window_net.length < MAX_WINDOW_NET) this.#window_net.push(call);
944
+ for (const net of this.#window_nets) if (net.length < MAX_WINDOW_NET) net.push(call);
708
945
  }
709
946
  /** An attributed request's context: its own calls (capped), each also offered to the window. */
710
- #request_ctx(entry) {
947
+ #request_ctx(entry, owner) {
711
948
  const collect_window = (call) => this.#collect_window(call);
712
949
  return {
713
950
  entry,
@@ -715,6 +952,7 @@ var Profiler = class {
715
952
  spans: [],
716
953
  route: entry.route,
717
954
  path: entry.path,
955
+ owner,
718
956
  on_net(call) {
719
957
  if (this.net.length < MAX_NET_PER_REQUEST) this.net.push(call);
720
958
  collect_window(call);
@@ -722,7 +960,12 @@ var Profiler = class {
722
960
  };
723
961
  }
724
962
  #init() {
725
- return this.#init_done ??= (async () => {
963
+ return this.#init_done.get();
964
+ }
965
+ /** The mount's one-time setup: the background timers and the network capture (it never fails). */
966
+ #init_done = flights({
967
+ name: "profiler init",
968
+ run: async () => {
726
969
  this.#arm_background();
727
970
  if (!this.want_network) return;
728
971
  try {
@@ -747,29 +990,69 @@ var Profiler = class {
747
990
  });
748
991
  this.#ensure_net = ensure_fetch_patched;
749
992
  } catch {}
750
- })();
751
- }
993
+ }
994
+ });
752
995
  /** A recording is "active" only if one started recently — a run abandoned by a frozen/killed
753
996
  * serverless invocation ages out after the cap instead of wedging the profiler forever. */
754
997
  #recording_active() {
755
- return this.#recording_since > 0 && Date.now() - this.#recording_since < RECORDING_MAX_MS;
998
+ const s = this.#holder.now;
999
+ return s.kind === "recording" && Date.now() - s.since < RECORDING_MAX_MS;
756
1000
  }
757
- /** Take the recorder slot without waiting: true if it was free (now ours), false if busy. Recordings
1001
+ /** Take the recorder without waiting: its token if it was free (now ours), null if busy. Recordings
758
1002
  * serialize PER WORKER — one V8 inspector CPU/heap sampler + one attribution context per process — so
759
1003
  * a second recording on the same process is refused, NOT queued in memory: on serverless the process
760
1004
  * can be recycled at any moment (Amplify freezes a worker after ~30 s), so a waiter parked in memory
761
1005
  * is unreliable, and it isn't needed — a retry lands on a different, free worker. The "queue" is the
762
- * CLIENT polling `/page` until a worker says yes (see RunView). */
763
- #try_acquire_recorder() {
764
- if (this.#recorder_busy) return false;
765
- this.#recorder_busy = true;
766
- return true;
1006
+ * CLIENT polling `/page` until a worker says yes (see RunView). A recording that runs past the hard
1007
+ * cap is cut (its own time, `recorder_machine`): the site stops paying for its context. */
1008
+ #take(kind) {
1009
+ const token = this.#recorder.take();
1010
+ if (token === null) return null;
1011
+ this.#holder.go(kind, {
1012
+ token,
1013
+ since: Date.now()
1014
+ });
1015
+ this.#holder.scope.defer(() => this.#release_als());
1016
+ return token;
1017
+ }
1018
+ /**
1019
+ * The answer to a recording asked for while the recorder is someone else's: a 409 the client
1020
+ * retries, which says who holds it, since when, and whether that holder is STALE: a page recording
1021
+ * past its cap (`cut`, or past RECORDING_MAX_MS by its start), whose request died before its
1022
+ * cleanup ran. Only a stale holder is a reset's to take: a live one (a window someone started, a
1023
+ * page profile still running, the trap) is someone's recording (#290).
1024
+ */
1025
+ #busy(message) {
1026
+ const s = this.#holder.now;
1027
+ const holder = s.kind === "free" ? null : {
1028
+ kind: this.#win.now.kind !== "idle" ? "window" : s.kind,
1029
+ age_ms: Date.now() - s.since,
1030
+ stale: s.kind === "cut" || s.kind === "recording" && !this.#recording_active()
1031
+ };
1032
+ return new Response(JSON.stringify({
1033
+ busy: true,
1034
+ message,
1035
+ holder
1036
+ }), {
1037
+ status: 409,
1038
+ headers: {
1039
+ "content-type": "application/json; charset=utf-8",
1040
+ "retry-after": "2",
1041
+ "cache-control": "no-store"
1042
+ }
1043
+ });
767
1044
  }
768
- /** Free the recorder slot. Called only when the recording is truly finished (its inspector session
769
- * closed) — never from the site-unblock watchdog, which may fire while an orphaned run still holds
770
- * the inspector. */
771
- #release_recorder() {
772
- this.#recorder_busy = false;
1045
+ /** `token` still owns the recorder (a reset, or the next owner, took it otherwise). */
1046
+ #holds(token) {
1047
+ const s = this.#holder.now;
1048
+ return s.kind !== "free" && s.token === token;
1049
+ }
1050
+ /** Free the recorder, only for the token that owns it: false for one a reset took it from. Called
1051
+ * only when the recording is truly finished (its inspector session closed). */
1052
+ #free(token) {
1053
+ if (!this.#recorder.free(token)) return false;
1054
+ this.#holder.go("free");
1055
+ return true;
773
1056
  }
774
1057
  /** Production: detach the profiler's AsyncLocalStorage once a recording ends. An ALS that has
775
1058
  * run once stays enabled — every async hop in the process keeps paying a store copy for it — until
@@ -821,10 +1104,9 @@ var Profiler = class {
821
1104
  });
822
1105
  };
823
1106
  sample_mem();
824
- const mem_timer = setInterval(sample_mem, 250);
825
- mem_timer.unref?.();
1107
+ const sampling = scope();
1108
+ sampling.every(sample_mem, 250);
826
1109
  const heap_series = [];
827
- let heap_timer;
828
1110
  if (!opts.light) try {
829
1111
  const v8 = await import("node:v8");
830
1112
  const sample_heap = () => {
@@ -834,62 +1116,22 @@ var Profiler = class {
834
1116
  mb: v8.getHeapStatistics().used_heap_size / 1048576
835
1117
  });
836
1118
  };
837
- heap_timer = setInterval(sample_heap, HEAP_SERIES_PERIOD_MS);
838
- heap_timer.unref?.();
1119
+ sampling.every(sample_heap, HEAP_SERIES_PERIOD_MS);
839
1120
  } catch {}
840
- this.#window_net = [];
1121
+ const owner = {};
1122
+ const window_net = [];
841
1123
  const netmod = await import("./net.js").catch(() => null);
842
- if (!opts.light) netmod?.set_stack_capture(true);
843
- const spans = [];
844
- let span_seq = 0;
845
- const als = this.#als;
846
1124
  const { AsyncLocalStorage: SpanALS } = await import("node:async_hooks");
847
1125
  const span_als = new SpanALS();
848
- set_span_recorder({
849
- begin: (name, attrs) => {
850
- const ctx = als?.getStore();
851
- const parent = ctx?.span ?? span_als.getStore();
852
- const rec = {
853
- id: ++span_seq,
854
- name: String(name).slice(0, 120),
855
- start: perf_hooks.performance.now(),
856
- ms: -1,
857
- ...attrs ? { attrs: { ...attrs } } : {},
858
- ...parent !== void 0 ? { parent } : {},
859
- caller_site: netmod?.nearest_app_site(),
860
- route: ctx?.route ?? null,
861
- path: ctx?.path ?? null
862
- };
863
- if (spans.length < MAX_WINDOW_SPANS) spans.push(rec);
864
- if (ctx && ctx.spans.length < MAX_SPANS_PER_REQUEST) ctx.spans.push(rec);
865
- return rec;
866
- },
867
- within: (s, fn) => {
868
- const ctx = als?.getStore();
869
- return ctx && als ? als.run({
870
- ...ctx,
871
- span: s.id
872
- }, fn) : span_als.run(s.id, fn);
873
- },
874
- end: (s, attrs, error) => {
875
- s.ms = round2(perf_hooks.performance.now() - s.start);
876
- if (attrs) s.attrs = {
877
- ...s.attrs,
878
- ...attrs
879
- };
880
- if (error !== void 0) s.error = error instanceof Error ? error.message : String(error);
881
- },
882
- tag: (key, value) => {
883
- const ctx = als?.getStore();
884
- if (!ctx) return;
885
- const tags = ctx.entry.tags ??= {};
886
- if (key in tags || Object.keys(tags).length < MAX_TAGS) tags[key] = value;
887
- }
888
- });
1126
+ const window_spans = new WindowSpans(this.#als, span_als, netmod);
1127
+ const { spans } = window_spans;
889
1128
  const iomod = opts.light ? null : await import("./async-io.js").catch(() => null);
890
- let io_rec = iomod ? await iomod.record_async_io() : null;
1129
+ let io_rec = iomod ? await iomod.IoRecorder.start() : null;
891
1130
  let heap_head = null;
892
- set_request_stats_detail(true);
1131
+ const release_detail = hold_request_stats_detail();
1132
+ this.#window_nets.add(window_net);
1133
+ if (!opts.light) netmod?.set_stack_capture(owner, true);
1134
+ own_spans(window_spans);
893
1135
  try {
894
1136
  await session.post("Profiler.enable");
895
1137
  await session.post("Profiler.setSamplingInterval", { interval: interval_us });
@@ -911,7 +1153,7 @@ var Profiler = class {
911
1153
  });
912
1154
  heap_on = true;
913
1155
  } catch (e) {
914
- if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] allocation sampling unavailable, GC attribution off:", e);
1156
+ if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_alloc_sampling_unavailable"), e);
915
1157
  await session.post("HeapProfiler.startSampling", { samplingInterval: 16384 });
916
1158
  }
917
1159
  }
@@ -948,7 +1190,7 @@ var Profiler = class {
948
1190
  const cpu = process.cpuUsage(cpu0);
949
1191
  const elu = perf_hooks.performance.eventLoopUtilization(elu0);
950
1192
  const duration_ms = t1 - t0;
951
- const net = this.#window_net ?? [];
1193
+ const net = window_net;
952
1194
  for (const s of spans) if (s.ms < 0) s.open = true;
953
1195
  return {
954
1196
  profile,
@@ -986,18 +1228,17 @@ var Profiler = class {
986
1228
  })) } : {}
987
1229
  };
988
1230
  } finally {
989
- clearInterval(mem_timer);
990
- if (heap_timer) clearInterval(heap_timer);
1231
+ sampling.close();
991
1232
  histogram.disable();
992
- set_span_recorder(null);
1233
+ disown_spans(window_spans);
993
1234
  span_als.disable();
994
- set_request_stats_detail(false);
995
- netmod?.set_stack_capture(false);
1235
+ release_detail();
1236
+ netmod?.set_stack_capture(owner, false);
996
1237
  io_rec?.stop();
997
1238
  try {
998
1239
  observer?.disconnect();
999
1240
  } catch {}
1000
- this.#window_net = null;
1241
+ this.#window_nets.delete(window_net);
1001
1242
  try {
1002
1243
  session.disconnect();
1003
1244
  } catch {}
@@ -1326,7 +1567,7 @@ var Profiler = class {
1326
1567
  });
1327
1568
  gc_attr.window_offset_ms = window_offset_ms;
1328
1569
  } catch (e) {
1329
- if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] GC attribution failed:", e);
1570
+ if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_gc_attribution_failed"), e);
1330
1571
  gc_attr = void 0;
1331
1572
  }
1332
1573
  {
@@ -1587,7 +1828,11 @@ var Profiler = class {
1587
1828
  if (this.ui_enabled && this.secret && !this.dev) {
1588
1829
  const after_login = ctx.url.searchParams.has(AFTER_LOGIN_PARAM);
1589
1830
  const diag = this.#cookie_diagnosis(ctx.event);
1590
- if (after_login || diag.pairs > 0) console.warn(`[ogygia profiler] session not accepted on ${ctx.url.pathname}: ${JSON.stringify(diag)} — ${describe_cookie_diagnosis(diag)}`);
1831
+ if (after_login || diag.pairs > 0) warn("devtools_profiler_session_rejected", {
1832
+ path: ctx.url.pathname,
1833
+ diagnosis: JSON.stringify(diag),
1834
+ reason: describe_cookie_diagnosis(diag)
1835
+ });
1591
1836
  const target = new URL(ctx.url);
1592
1837
  target.searchParams.delete(AFTER_LOGIN_PARAM);
1593
1838
  const next = encodeURIComponent(target.pathname + target.search);
@@ -1610,7 +1855,7 @@ var Profiler = class {
1610
1855
  window_stop: (c) => this.#window_stop(c),
1611
1856
  reset: (c) => this.#reset(c),
1612
1857
  status: () => ({
1613
- recording: this.#recorder_busy || this.#recording_active(),
1858
+ recording: this.#recorder.held,
1614
1859
  inflight: this.#inflight,
1615
1860
  rss_mb: Math.round(process.memoryUsage().rss / 1048576),
1616
1861
  reports: this.#reports.size
@@ -2486,10 +2731,9 @@ var Profiler = class {
2486
2731
  * profile saw with a `.svelte` source on this machine, and the islands (their keys from the
2487
2732
  * build), each with the page.data keys it reads — joined to the loads' keys and the seed. */
2488
2733
  /** every `.svelte` under the app's `src/`, relative to it, listed once (null: no src here) */
2489
- #svelte_files;
2490
- /** The `src/`-relative paths of `<name>.svelte` files (a component is named after its file). */
2491
- async #component_files(name) {
2492
- this.#svelte_files ??= (async () => {
2734
+ #svelte_files = flights({
2735
+ name: "profiler svelte files",
2736
+ run: async () => {
2493
2737
  try {
2494
2738
  const fs = await import("node:fs");
2495
2739
  const path = await import("node:path");
@@ -2508,8 +2752,12 @@ var Profiler = class {
2508
2752
  const carried = embedded_files().filter((f) => f.endsWith(".svelte"));
2509
2753
  return carried.length ? carried : null;
2510
2754
  }
2511
- })();
2512
- const all = await this.#svelte_files;
2755
+ }
2756
+ });
2757
+ /** The `src/`-relative paths of `<name>.svelte` files (a component is named after its file). */
2758
+ async #component_files(name) {
2759
+ const all = await this.#svelte_files.get();
2760
+ if (all === STALE) return [];
2513
2761
  const leaf = "/" + name + ".svelte";
2514
2762
  return all ? all.filter((f) => f === name + ".svelte" || f.endsWith(leaf)) : [];
2515
2763
  }
@@ -2918,7 +3166,7 @@ var Profiler = class {
2918
3166
  })),
2919
3167
  site_fixes: this.#site_fixes(),
2920
3168
  holes: hole_slots(this.#ring),
2921
- recording: this.#recorder_busy || this.#recording_active(),
3169
+ recording: this.#recorder.held,
2922
3170
  dev: this.dev,
2923
3171
  rss_mb: Math.round(process.memoryUsage().rss / 1048576),
2924
3172
  inflight: this.#inflight,
@@ -2934,12 +3182,12 @@ var Profiler = class {
2934
3182
  trap: this.#trap ? {
2935
3183
  over: this.#trap.over,
2936
3184
  window_ms: this.#trap.window ?? 5e3,
2937
- caught: this.#trap_caught,
3185
+ caught: caught_of(this.#trap_loop),
2938
3186
  keep: this.#trap.keep ?? 3,
2939
- armed: !!this.#trap_timer || this.#trap_running
3187
+ state: trap_state(this.#trap_loop)
2940
3188
  } : null,
2941
3189
  sampled: this.#sampled_summary(),
2942
- background_note: this.#background_note
3190
+ background_note: this.#background_notes()
2943
3191
  };
2944
3192
  }
2945
3193
  #run_page(ctx) {
@@ -2967,7 +3215,6 @@ var Profiler = class {
2967
3215
  ...against && against.length <= 32 ? { against } : {}
2968
3216
  };
2969
3217
  }
2970
- #window = null;
2971
3218
  async #window_sign(nonce) {
2972
3219
  const { createHmac } = await import("node:crypto");
2973
3220
  return createHmac("sha256", this.secret || "ogygia-dev-window").update("window:" + nonce).digest("hex").slice(0, 20);
@@ -2998,8 +3245,10 @@ var Profiler = class {
2998
3245
  at: Date.now(),
2999
3246
  instance: this.#instance_id
3000
3247
  };
3248
+ const store = await this.#store_ready();
3249
+ if (!store?.putVisit) return;
3001
3250
  try {
3002
- await this.#store.putVisit({
3251
+ await store.putVisit({
3003
3252
  key: `${WINDOW_PAGE_PREFIX}${id}\0${rec.at}\0${Math.random().toString(36).slice(2, 8)}`,
3004
3253
  page: WINDOW_PAGE_PREFIX + id,
3005
3254
  at: rec.at,
@@ -3009,9 +3258,10 @@ var Profiler = class {
3009
3258
  }
3010
3259
  /** The requests every instance wrote for a window. */
3011
3260
  async #window_requests(id) {
3012
- if (!this.#store?.listVisits) return [];
3261
+ const store = await this.#store_ready();
3262
+ if (!store?.listVisits) return [];
3013
3263
  try {
3014
- return (await this.#store.listVisits(WINDOW_PAGE_PREFIX + id, MAX_VISITS_PER_KEY)).map((r) => JSON.parse(r.visit)).sort((a, b) => a.at - b.at);
3264
+ return (await store.listVisits(WINDOW_PAGE_PREFIX + id, MAX_VISITS_PER_KEY)).map((r) => JSON.parse(r.visit)).sort((a, b) => a.at - b.at);
3015
3265
  } catch {
3016
3266
  return [];
3017
3267
  }
@@ -3019,50 +3269,30 @@ var Profiler = class {
3019
3269
  /** This instance, told apart from the others in a window's requests. */
3020
3270
  #instance_id = Math.random().toString(36).slice(2, 10);
3021
3271
  async #window_start(ctx) {
3022
- const busy = (message) => new Response(JSON.stringify({
3023
- busy: true,
3024
- message
3025
- }), {
3026
- status: 409,
3027
- headers: {
3028
- "content-type": "application/json; charset=utf-8",
3029
- "retry-after": "2",
3030
- "cache-control": "no-store"
3031
- }
3032
- });
3033
- if (this.#window) return busy("A window is already recording: stop it first.");
3034
- if (this.#recorder_busy && this.#trap_running) {
3035
- this.#page_waiting++;
3036
- try {
3037
- const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
3038
- while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
3039
- } finally {
3040
- this.#page_waiting--;
3041
- }
3042
- }
3043
- if (!this.#try_acquire_recorder()) return busy("Another recording holds the recorder. Try again in a moment.");
3044
- this.#recording_since = Date.now();
3045
- this.#trap_running = true;
3272
+ const busy = (message) => this.#busy(message);
3273
+ if (this.#win.now.kind !== "idle") return busy("A window is already recording: stop it first.");
3274
+ await this.#wait_for_background();
3275
+ const nonce = Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
3276
+ const id = `${nonce}.${await this.#window_sign(nonce)}`;
3277
+ const token = this.#take("background");
3278
+ if (token === null) return busy("Another recording holds the recorder. Try again in a moment.");
3046
3279
  this.#ensure_net?.();
3047
3280
  let stop;
3048
3281
  const stopped = new Promise((r) => stop = r);
3049
- const nonce = Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
3050
- const id = `${nonce}.${await this.#window_sign(nonce)}`;
3051
- const timer = setTimeout(() => {
3052
- stop();
3053
- done.finally(() => {
3054
- if (this.#window?.id === id) this.#window_release();
3055
- });
3056
- }, WINDOW_MAX_MS);
3057
- timer.unref?.();
3058
3282
  const done = this.#capture_window(Math.min(this.sample_interval, 1e3), () => stopped, { light: true });
3059
- done.catch(() => this.#window_release());
3060
- this.#window = {
3283
+ this.#win.go("recording", {
3061
3284
  id,
3062
- stop,
3063
- done,
3064
- timer
3285
+ token,
3286
+ done
3287
+ });
3288
+ const mine = this.#win.now;
3289
+ this.#win.scope.defer(stop);
3290
+ const settled = () => {
3291
+ if (this.#win.now === mine) this.#win.go("idle");
3292
+ const now = this.#win.now;
3293
+ if (now.kind !== "stopping" || now.token !== token) this.#free(token);
3065
3294
  };
3295
+ done.then(settled, settled);
3066
3296
  return ctx.json({
3067
3297
  ok: true,
3068
3298
  id,
@@ -3071,18 +3301,31 @@ var Profiler = class {
3071
3301
  max_ms: WINDOW_MAX_MS
3072
3302
  });
3073
3303
  }
3074
- #window_release() {
3075
- if (this.#window) clearTimeout(this.#window.timer);
3076
- this.#window = null;
3077
- this.#trap_running = false;
3078
- this.#recording_since = 0;
3079
- this.#release_als();
3080
- this.#release_recorder();
3304
+ /** A page profile or a window waits (a few seconds at most) for a background window (the trap,
3305
+ * the sampler, a started window) to give the recorder back. The trap and the sampler do not
3306
+ * start again while one waits. The wait is its request's: once the request ends, it waits no
3307
+ * more. */
3308
+ async #wait_for_background() {
3309
+ if (this.#holder.now.kind !== "background") return;
3310
+ this.#page_waiting++;
3311
+ const wait = scope(current_request_scope() ?? void 0);
3312
+ try {
3313
+ wait.timeout(() => wait.close(), PAGE_WAITS_FOR_BACKGROUND_MS);
3314
+ await wait.task(() => this.#holder.wait("free"));
3315
+ } finally {
3316
+ wait.close();
3317
+ this.#page_waiting--;
3318
+ }
3081
3319
  }
3082
3320
  async #window_stop(ctx) {
3083
3321
  const asked = ctx.event ? await this.#window_id_of(ctx.event.request) : null;
3084
- const w = this.#window && (!asked || this.#window.id === asked) ? this.#window : null;
3085
- if (!w) {
3322
+ const w = this.#win.now;
3323
+ const ours = w.kind !== "idle" && (!asked || w.id === asked);
3324
+ if (ours && w.kind === "stopping") {
3325
+ const a = await w.answer;
3326
+ return ctx.json(a.body, { status: a.status });
3327
+ }
3328
+ if (!ours || w.kind !== "recording") {
3086
3329
  if (asked && this.#store?.listVisits) {
3087
3330
  const reqs = await this.#window_requests(asked);
3088
3331
  const busy = round2(reqs.reduce((a, r) => a + (r.cpu_ms || 0), 0));
@@ -3112,16 +3355,33 @@ var Profiler = class {
3112
3355
  }
3113
3356
  return ctx.json({ error: "No window is recording (start one with POST /window/start)." }, { status: 400 });
3114
3357
  }
3115
- w.stop();
3116
- let cap;
3358
+ const answer = this.#window_answer(w);
3359
+ this.#win.move("recording", "stopping", {
3360
+ id: w.id,
3361
+ token: w.token,
3362
+ answer
3363
+ });
3364
+ const a = await answer;
3365
+ return ctx.json(a.body, { status: a.status });
3366
+ }
3367
+ /** The stopped window's report, made once; the window holds the recorder until it is made. */
3368
+ async #window_answer(w) {
3117
3369
  try {
3118
- cap = await w.done;
3370
+ return await this.#window_report(w.id, await w.done);
3119
3371
  } catch (e) {
3120
- this.#window_release();
3121
- return ctx.json({ error: `The window failed: ${e instanceof Error ? e.message : String(e)}` }, { status: 500 });
3372
+ return {
3373
+ status: 500,
3374
+ body: { error: `The window failed: ${e instanceof Error ? e.message : String(e)}` }
3375
+ };
3376
+ } finally {
3377
+ const now = this.#win.now;
3378
+ if (now.kind === "stopping" && now.token === w.token) this.#win.go("idle");
3379
+ this.#free(w.token);
3122
3380
  }
3381
+ }
3382
+ async #window_report(window_id, cap) {
3123
3383
  const own = `${this.base}/`;
3124
- const shared = await this.#window_requests(w.id);
3384
+ const shared = await this.#window_requests(window_id);
3125
3385
  const others = shared.filter((e) => e.instance !== this.#instance_id && !e.path.startsWith(own) && e.path !== this.base);
3126
3386
  const requests = [...this.#ring.filter((e) => !e.internal && e.ts >= cap.t0 && e.ts <= cap.t1 && !e.path.startsWith(own) && e.path !== this.base).map((e) => ({
3127
3387
  method: e.method,
@@ -3132,64 +3392,44 @@ var Profiler = class {
3132
3392
  cpu_ms: e.cpu_ms,
3133
3393
  instance: this.#instance_id
3134
3394
  })), ...others];
3135
- try {
3136
- const id = await this.#finish_report(cap, {
3137
- trigger: "window",
3138
- ...requests[0] ? { request: {
3139
- method: requests[0].method,
3140
- path: requests[0].path,
3141
- route: requests[0].route,
3142
- ms: requests[0].ms
3143
- } } : {}
3144
- });
3145
- const s = this.#reports.get(id);
3146
- return ctx.json({
3395
+ const id = await this.#finish_report(cap, {
3396
+ trigger: "window",
3397
+ ...requests[0] ? { request: {
3398
+ method: requests[0].method,
3399
+ path: requests[0].path,
3400
+ route: requests[0].route,
3401
+ ms: requests[0].ms
3402
+ } } : {}
3403
+ });
3404
+ const s = this.#reports.get(id);
3405
+ return {
3406
+ status: 200,
3407
+ body: {
3147
3408
  ...report_json(s.analysis, s.meta, this.base, this.#report_extras(s)),
3148
3409
  window: {
3149
3410
  ms: round2(cap.t1 - cap.t0),
3150
3411
  requests,
3151
3412
  ...shared.length ? { instances: new Set(requests.map((r) => r.instance)).size } : {}
3152
3413
  }
3153
- });
3154
- } finally {
3155
- this.#window_release();
3156
- }
3414
+ }
3415
+ };
3157
3416
  }
3158
3417
  #reset(ctx) {
3159
- this.#recording_since = 0;
3418
+ if (this.#holder.now.kind !== "free") {
3419
+ this.#recorder.free(this.#recorder.seize());
3420
+ this.#holder.go("free");
3421
+ }
3422
+ if (this.#win.now.kind !== "idle") this.#win.go("idle");
3160
3423
  this.#release_als();
3161
- this.#recorder_busy = false;
3162
3424
  return ctx.redirect(this.base);
3163
3425
  }
3164
3426
  async #record_page(ctx, replay) {
3165
3427
  const event = ctx.event;
3166
- if (this.#recorder_busy && this.#trap_running) {
3167
- this.#page_waiting++;
3168
- try {
3169
- const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
3170
- while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
3171
- } finally {
3172
- this.#page_waiting--;
3173
- }
3174
- }
3175
- if (!this.#try_acquire_recorder()) return new Response(JSON.stringify({
3176
- busy: true,
3177
- message: "A profile is already running on this worker. Waiting for it to finish (or for a free worker)."
3178
- }), {
3179
- status: 409,
3180
- headers: {
3181
- "content-type": "application/json; charset=utf-8",
3182
- "retry-after": "2",
3183
- "cache-control": "no-store"
3184
- }
3185
- });
3428
+ await this.#wait_for_background();
3429
+ const token = this.#take("recording");
3430
+ if (token === null) return this.#busy("A profile is already running on this worker. Waiting for it to finish (or for a free worker).");
3186
3431
  const q = ctx.url.searchParams;
3187
- this.#recording_since = Date.now();
3188
- const watchdog = setTimeout(() => {
3189
- this.#recording_since = 0;
3190
- this.#release_als();
3191
- }, 32e3);
3192
- watchdog.unref?.();
3432
+ const kept_owner = {};
3193
3433
  this.#ensure_net?.();
3194
3434
  try {
3195
3435
  const path = replay?.path ?? q.get("p") ?? "";
@@ -3209,6 +3449,7 @@ var Profiler = class {
3209
3449
  const request_deadline = Date.now() + detect_request_budget_ms();
3210
3450
  const deadline = Math.min(budget_deadline, Date.now() + RECORDING_HARD_CAP_MS);
3211
3451
  const fetch_render = (p, extra) => {
3452
+ if (!this.#holds(token)) return Promise.reject(/* @__PURE__ */ new Error("the recording was reset before it finished"));
3212
3453
  const left = deadline - Date.now();
3213
3454
  const ms = Number.isFinite(left) ? Math.max(1, Math.min(PER_RENDER_TIMEOUT_MS, left)) : PER_RENDER_TIMEOUT_MS;
3214
3455
  return with_timeout(event.fetch(p, { headers: {
@@ -3302,7 +3543,7 @@ var Profiler = class {
3302
3543
  skipped: heap_skipped
3303
3544
  } : void 0;
3304
3545
  };
3305
- this.#kept?.keep(true);
3546
+ this.#kept?.keep(kept_owner, true);
3306
3547
  mem_mark("before the timed renders");
3307
3548
  const cap = await this.#capture_window(interval, async () => {
3308
3549
  const path_only = target.split("?")[0];
@@ -3373,7 +3614,7 @@ var Profiler = class {
3373
3614
  cap.set_aside = aside;
3374
3615
  }
3375
3616
  const stable = stable_answers(cap.net, run_windows);
3376
- this.#kept?.keep(false);
3617
+ this.#kept?.keep(kept_owner, false);
3377
3618
  const almost = /* @__PURE__ */ new Map();
3378
3619
  if (this.#kept) for (const key of varied_answers(cap.net, run_windows)) {
3379
3620
  const d = this.#kept.diff(key);
@@ -3404,8 +3645,8 @@ var Profiler = class {
3404
3645
  } catch {
3405
3646
  answers = void 0;
3406
3647
  } finally {
3407
- this.#answers = null;
3408
- this.#kept.drop();
3648
+ if (this.#answers === state) this.#answers = null;
3649
+ this.#kept.drop(kept_owner);
3409
3650
  }
3410
3651
  }
3411
3652
  const wait_runs = {};
@@ -3490,12 +3731,18 @@ var Profiler = class {
3490
3731
  }, true) : (heap_skipped.push("the call-count render (the ×N counts)"), { counts: {} });
3491
3732
  cap.call_counts = counted.counts;
3492
3733
  mem_mark("after the coverage render");
3493
- const app_fetch = async (u) => {
3734
+ const app_fetch = async (u, signal) => {
3494
3735
  try {
3495
- const r = await event.fetch(u, { headers: { "x-og-profiler-internal": "1" } });
3736
+ const r = await event.fetch(u, {
3737
+ headers: { "x-og-profiler-internal": "1" },
3738
+ signal
3739
+ });
3496
3740
  if (r.ok) return r;
3497
3741
  } catch {}
3498
- return fetch(new URL(u, event.url.origin), { headers: { "x-og-profiler-internal": "1" } });
3742
+ return fetch(new URL(u, event.url.origin), {
3743
+ headers: { "x-og-profiler-internal": "1" },
3744
+ signal
3745
+ });
3499
3746
  };
3500
3747
  const id = await this.#finish_report(cap, {
3501
3748
  trigger: "page",
@@ -3604,7 +3851,8 @@ var Profiler = class {
3604
3851
  await this.#lines_for(s, counted.profile, cap.window?.start, stable, wait_runs, almost);
3605
3852
  } catch {}
3606
3853
  const persist_wait = Math.max(200, Math.min(PERSIST_WAIT_MS, request_deadline - Date.now() - 2e3));
3607
- await Promise.race([this.#persist(s), new Promise((r) => setTimeout(r, persist_wait))]);
3854
+ const bound = scope(current_request_scope() ?? void 0);
3855
+ await Promise.race([this.#persist(s), pause(bound, persist_wait)]).finally(() => bound.close());
3608
3856
  if (q.get("format") === "keep") return ctx.json({
3609
3857
  id,
3610
3858
  url: ctx.href("/report/[id]", { id }),
@@ -3626,13 +3874,8 @@ var Profiler = class {
3626
3874
  if (isHttpError(e) || isRedirect(e)) throw e;
3627
3875
  return error(500, e instanceof Error && INSPECTOR_ERR.test(e.message) ? "CPU profiling needs a Node.js server (adapter-node or dev). This platform does not expose the V8 inspector." : `Profiling failed: ${e instanceof Error ? e.message : String(e)}`);
3628
3876
  } finally {
3629
- clearTimeout(watchdog);
3630
- this.#recording_since = 0;
3631
- this.#kept?.keep(false);
3632
- this.#kept?.drop();
3633
- this.#answers = null;
3634
- this.#release_als();
3635
- this.#release_recorder();
3877
+ this.#kept?.drop(kept_owner);
3878
+ this.#free(token);
3636
3879
  }
3637
3880
  }
3638
3881
  async #upload(ctx) {
@@ -3931,6 +4174,18 @@ var Profiler = class {
3931
4174
  * fails the recording). The dump is the same portable JSON the `.ogp` export uses. The returned
3932
4175
  * promise settles when the write did: a serverless host freezes the process once the response
3933
4176
  * is sent, so a write left running after it may never land — the page recording awaits it. */
4177
+ /** The durable store, once its `init` has succeeded; null when none is configured or its init
4178
+ * failed this time (said, and tried again by the next use). */
4179
+ async #store_ready() {
4180
+ const store = this.#store;
4181
+ if (!store || !this.#store_init) return store;
4182
+ try {
4183
+ return await this.#store_init.get() === STALE ? null : store;
4184
+ } catch (e) {
4185
+ console.error(new OgygiaError("devtools_profiler_store_init_failed"), e);
4186
+ return null;
4187
+ }
4188
+ }
3934
4189
  #persist(stored) {
3935
4190
  if (!this.#store) return Promise.resolve();
3936
4191
  const meta = stored.meta;
@@ -3945,10 +4200,12 @@ var Profiler = class {
3945
4200
  dump: JSON.stringify(report_dump(stored.analysis, meta, this.#report_extras(stored)))
3946
4201
  };
3947
4202
  return Promise.resolve().then(async () => {
3948
- await this.#store.putReport(rec);
3949
- await this.#store.prune?.(Math.max(this.max_reports * 8, 200));
4203
+ const store = await this.#store_ready();
4204
+ if (!store) return;
4205
+ await store.putReport(rec);
4206
+ await store.prune?.(Math.max(this.max_reports * 8, 200));
3950
4207
  }).catch((err) => {
3951
- if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] store.putReport failed:", err);
4208
+ if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_put_report_failed"), err);
3952
4209
  });
3953
4210
  }
3954
4211
  /** Memory first, then the durable store: read the dump back, reconstruct, cache in memory. */
@@ -3956,9 +4213,10 @@ var Profiler = class {
3956
4213
  if (!id) return void 0;
3957
4214
  const hit = this.#reports.get(id);
3958
4215
  if (hit) return hit;
3959
- if (!this.#store) return void 0;
4216
+ const store = await this.#store_ready();
4217
+ if (!store) return void 0;
3960
4218
  try {
3961
- const json = await this.#store.getReport(id);
4219
+ const json = await store.getReport(id);
3962
4220
  if (!json) return void 0;
3963
4221
  const dump = JSON.parse(json);
3964
4222
  if (!dump?.meta || !dump.analysis) return void 0;
@@ -3972,15 +4230,16 @@ var Profiler = class {
3972
4230
  this.#reports.set(id, stored);
3973
4231
  return stored;
3974
4232
  } catch (err) {
3975
- if (process.env.OGYGIA_PROFILER_DEBUG) console.error("[ogygia/profiler] store.getReport failed:", err);
4233
+ if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_get_report_failed"), err);
3976
4234
  return;
3977
4235
  }
3978
4236
  }
3979
4237
  /** The reports the durable store holds (summaries), newest first — the sidebar's shared list. */
3980
4238
  async #list_stored(limit = 20) {
3981
- if (!this.#store) return [];
4239
+ const store = await this.#store_ready();
4240
+ if (!store) return [];
3982
4241
  try {
3983
- return (await this.#store.listReports(limit)).map((r) => ({
4242
+ return (await store.listReports(limit)).map((r) => ({
3984
4243
  id: r.id,
3985
4244
  label: r.label,
3986
4245
  page: r.page ?? void 0,
@@ -4501,8 +4760,9 @@ var Profiler = class {
4501
4760
  if (Object.keys(kept).length) entry.replay_headers = kept;
4502
4761
  }
4503
4762
  const profile_header = event.request.headers.get("x-profile");
4504
- const header_profile = !!profile_header && this.ui_enabled && await this.#key_matches(profile_header) && this.#try_acquire_recorder();
4505
- const ctx = !!this.#als && (this.dev || this.#recording_active() && !this.#trap_running || header_profile) ? this.#request_ctx(entry) : null;
4763
+ const header_token = !!profile_header && this.ui_enabled && await this.#key_matches(profile_header) ? this.#take("recording") : null;
4764
+ const header_profile = header_token !== null;
4765
+ const ctx = !!this.#als && (this.dev || this.#recording_active() || header_profile) ? this.#request_ctx(entry, request_scope(event.request)) : null;
4506
4766
  const answers = this.#answers;
4507
4767
  if (ctx && answers && this.#kept && event.request.headers.get(ANSWERS_HEADER) === answers.token) {
4508
4768
  const kept = this.#kept;
@@ -4570,47 +4830,36 @@ var Profiler = class {
4570
4830
  } catch {}
4571
4831
  return res;
4572
4832
  };
4573
- if (header_profile) {
4574
- this.#recording_since = Date.now();
4575
- const watchdog = setTimeout(() => {
4576
- this.#recording_since = 0;
4577
- this.#release_als();
4578
- }, 32e3);
4579
- watchdog.unref?.();
4833
+ if (header_token !== null) try {
4834
+ let res;
4835
+ const cap = await this.#capture_window(100, async () => {
4836
+ res = await run();
4837
+ });
4838
+ this.#finalize(entry, start, cpu0, ctx, event.request);
4839
+ cap.window = {
4840
+ start,
4841
+ end: start + entry.ms
4842
+ };
4843
+ const id = await this.#finish_report(cap, {
4844
+ trigger: "request",
4845
+ request: {
4846
+ method: entry.method,
4847
+ path: entry.path,
4848
+ route: entry.route,
4849
+ ms: entry.ms
4850
+ }
4851
+ });
4852
+ const stored = this.#reports.get(id);
4853
+ if (stored) stored.replay = {
4854
+ path: entry.path + (entry.search ?? ""),
4855
+ headers: entry.replay_headers ?? {}
4856
+ };
4580
4857
  try {
4581
- let res;
4582
- const cap = await this.#capture_window(100, async () => {
4583
- res = await run();
4584
- });
4585
- this.#finalize(entry, start, cpu0, ctx, event.request);
4586
- cap.window = {
4587
- start,
4588
- end: start + entry.ms
4589
- };
4590
- const id = await this.#finish_report(cap, {
4591
- trigger: "request",
4592
- request: {
4593
- method: entry.method,
4594
- path: entry.path,
4595
- route: entry.route,
4596
- ms: entry.ms
4597
- }
4598
- });
4599
- const stored = this.#reports.get(id);
4600
- if (stored) stored.replay = {
4601
- path: entry.path + (entry.search ?? ""),
4602
- headers: entry.replay_headers ?? {}
4603
- };
4604
- try {
4605
- res?.headers.append("x-profile-report", `${this.base}/report/${id}`);
4606
- } catch {}
4607
- return res;
4608
- } finally {
4609
- clearTimeout(watchdog);
4610
- this.#recording_since = 0;
4611
- this.#release_als();
4612
- this.#release_recorder();
4613
- }
4858
+ res?.headers.append("x-profile-report", `${this.base}/report/${id}`);
4859
+ } catch {}
4860
+ return res;
4861
+ } finally {
4862
+ this.#free(header_token);
4614
4863
  }
4615
4864
  try {
4616
4865
  return await run();
@@ -4661,47 +4910,72 @@ var Profiler = class {
4661
4910
  if (Number.isFinite(serverless_work_budget_ms())) this.#sink_flush();
4662
4911
  }
4663
4912
  }
4664
- /** Post the buffered rows as NDJSON to the sink. One post in flight at a time; a failure keeps
4665
- * the rows for the next try (the buffer drops its oldest request rows when full). */
4666
- async #sink_flush() {
4913
+ /** Hand the buffered rows to the sink's outbox as one post, now. While a post is out, the rows
4914
+ * wait in the buffer (capped: it drops the oldest when full) and go in one post once it ends,
4915
+ * never a post per tick queued behind a sink that is down (#376). Settles once the rows asked
4916
+ * for were taken or given up; never rejects. */
4917
+ #sink_flush() {
4667
4918
  if (this.#sink_timer && this.#superseded()) {
4668
- clearInterval(this.#sink_timer);
4919
+ this.#sink_timer();
4669
4920
  this.#sink_timer = null;
4670
4921
  }
4671
- if (!this.#sink_url || this.#sink_inflight || !this.#sink.size) return;
4672
- this.#sink_inflight = true;
4922
+ const out = this.#sink_out;
4923
+ if (out !== null) {
4924
+ out.again = true;
4925
+ return out.done;
4926
+ }
4927
+ if (!this.#sink_url || !this.#sink.size) return Promise.resolve();
4673
4928
  const rows = this.#sink.size;
4674
- const body = this.#sink.drain();
4929
+ const post = {
4930
+ id: `${this.#sink_run}-${++this.#sink_count}`,
4931
+ body: this.#sink.drain(),
4932
+ rows
4933
+ };
4934
+ const posting = {
4935
+ again: false,
4936
+ done: Promise.resolve()
4937
+ };
4938
+ this.#sink_out = posting;
4939
+ posting.done = this.#sink_posts.put(post).then(() => {
4940
+ this.#sink_out = null;
4941
+ if (posting.again) return this.#sink_flush();
4942
+ });
4943
+ return posting.done;
4944
+ }
4945
+ /** The post out now, and whether a flush asked for the rows read meanwhile. */
4946
+ #sink_out = null;
4947
+ /** Each post's id: this profiler's run, and its count. */
4948
+ #sink_run = Math.random().toString(36).slice(2, 10);
4949
+ #sink_count = 0;
4950
+ /** One try at a post: done once the sink answered ok. Its id rides `idempotency-key`, so a sink
4951
+ * that saw it already (an answer lost on the way back) can drop the copy. */
4952
+ async #sink_post(post, signal) {
4675
4953
  try {
4676
4954
  const res = await fetch(this.#sink_url, {
4677
4955
  method: "POST",
4678
- body,
4956
+ body: post.body,
4679
4957
  headers: {
4680
4958
  "content-type": "application/x-ndjson",
4959
+ "idempotency-key": post.id,
4681
4960
  "x-og-profiler-internal": "1",
4682
4961
  ...this.#sink_key ? { authorization: `Bearer ${this.#sink_key}` } : {}
4683
4962
  },
4684
- signal: AbortSignal.timeout(5e3)
4963
+ signal: AbortSignal.any([signal, AbortSignal.timeout(SINK_TRY_MS)])
4685
4964
  });
4965
+ if (!res.ok) throw new Error(`the sink answered ${res.status}`);
4686
4966
  this.#sink_last = {
4687
4967
  at: Date.now(),
4688
- ok: res.ok,
4689
- rows,
4690
- ...res.ok ? {} : { error: `the sink answered ${res.status}` }
4968
+ ok: true,
4969
+ rows: post.rows
4691
4970
  };
4692
- if (!res.ok) {
4693
- for (const line of body.split("\n")) if (line) this.#sink.push(JSON.parse(line));
4694
- }
4695
- } catch (e) {
4971
+ } catch (error) {
4696
4972
  this.#sink_last = {
4697
4973
  at: Date.now(),
4698
4974
  ok: false,
4699
- rows,
4700
- error: e instanceof Error ? e.message : String(e)
4975
+ rows: post.rows,
4976
+ error: error_text(error)
4701
4977
  };
4702
- for (const line of body.split("\n")) if (line) this.#sink.push(JSON.parse(line));
4703
- } finally {
4704
- this.#sink_inflight = false;
4978
+ throw error;
4705
4979
  }
4706
4980
  }
4707
4981
  /** The site view's rows from THIS instance: the request ring and the sampler's windows — what a
@@ -4806,12 +5080,13 @@ function clamp(n, lo, hi) {
4806
5080
  * is left to settle on its own — the caller has already moved on. */
4807
5081
  function with_timeout(p, ms, label) {
4808
5082
  if (!Number.isFinite(ms) || ms <= 0) return p;
4809
- let timer;
4810
- const timeout = new Promise((_, reject) => {
4811
- timer = setTimeout(() => reject(new Error(label)), ms);
4812
- timer.unref?.();
4813
- });
4814
- return Promise.race([p.finally(() => clearTimeout(timer)), timeout]);
5083
+ const race = scope();
5084
+ const timeout = new Promise((_, reject) => race.timeout(() => reject(new Error(label)), Math.min(ms, 2147483647)));
5085
+ return Promise.race([p.finally(() => race.close()), timeout]);
5086
+ }
5087
+ /** Settles after `ms`, or at once when `owner` ends (its timer goes with it). */
5088
+ function pause(owner, ms) {
5089
+ return owner.task(() => new Promise((r) => owner.timeout(r, ms)));
4815
5090
  }
4816
5091
  /** OGYGIA_PROFILER_DEBUG: the process's memory at a step of a recording (what the profiler itself
4817
5092
  * holds on a small serverless instance) */
@@ -4918,4 +5193,4 @@ function imported_from(src, name, file) {
4918
5193
  return;
4919
5194
  }
4920
5195
  }
4921
- export { pick_fields, profiler, route_prerendered, self_profile_to_cpuprofile };
5196
+ export { pick_fields, profiler, route_prerendered, self_profile_to_cpuprofile, url_weights, weigh_urls };