@ai-ecoverse/slicc-shared-web 1.10.2 → 1.11.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.
package/README.md CHANGED
@@ -31,7 +31,9 @@ The hooks expect two scripts in each repo: `test:unit`, which writes `coverage/u
31
31
 
32
32
  ## Integration-test harness
33
33
 
34
- `harness/` drives playwright-core's Chromium over raw CDP on `--remote-debugging-pipe`, as its only client, from `node --test`. Every page, service worker, shared worker and dedicated worker, nested ones included, starts paused, gets coverage and the CPU profiler switched on in the same tick, and only then runs. Each test gets its own browser context and leaves `artifacts/<suite>/<test>/` behind: one CPU profile per target and snapshot, a screenshot per tab and a `console.log` of every target. Lines logged before the first test, such as an extension service worker's at startup, go into the first test's `console.log`. The global teardown merges the raw coverage into `coverage/` (console table, lcov, V8 HTML) and writes `artifacts/hotspots.md`, the 15 frames with the most self time in the repo's own scripts.
34
+ `harness/` drives playwright-core's Chromium over raw CDP on `--remote-debugging-pipe`, as its only client, from `node --test`. Every page, service worker, shared worker and dedicated worker, nested ones included, starts paused, gets coverage (and the CPU profiler, when profiling is on) switched on in the same tick, and only then runs. Each test gets its own browser context and leaves `artifacts/<suite>/<test>/` behind: a screenshot per tab and a `console.log` of every target. Lines logged before the first test, such as an extension service worker's at startup, go into the first test's `console.log`. The global teardown merges the raw coverage into `coverage/` (console table, lcov, V8 HTML) and writes `artifacts/hotspots.md`.
35
+
36
+ CPU profiling is off by default. V8's sampler interrupts each page and worker thread every 100 µs with a signal, and the renderer SIGSEGVs in slicc-bios#114 all fault inside a signal handler walking the interrupted thread's frames, which points at it. Turn it on with `profile: true` or `SLICC_PROFILE=1` to get one CPU profile per target and snapshot in the test's artifact directory, and the 15 frames with the most self time in the repo's own scripts in `hotspots.md`. Without it, `hotspots.md` says that profiling is off.
35
37
 
36
38
  `playwright-core` and `monocart-coverage-reports` are peer dependencies; pin both in the repo's `devDependencies`. A repo describes what to serve and launches the browser once per test file:
37
39
 
@@ -64,6 +66,7 @@ test('boots', async (t) => {
64
66
  | `args` | Extra Chromium flags, such as `--host-resolver-rules` or `--ignore-certificate-errors` | none |
65
67
  | `timeout` | How long `page.until` polls and `page.evaluate` waits, in ms | `30000` |
66
68
  | `stallAfter` | How long one evaluation may run before the harness probes the test's targets, in ms | `30000` |
69
+ | `profile` | Record a CPU profile of every target, sampled every 100 µs | `true` if `SLICC_PROFILE=1`, else `false` |
67
70
 
68
71
  `chrome.page(t)` opens a tab and returns the page API: `goto(path)`, `reload()`, `evaluate(fn, ...args)`, `until(fn, ...args)`, `press(key, ...modifiers)` (a key name like `Enter` or `F5`, or a single character typed with its US-layout key code), `type(text)`, `insert(text)`, `enter()`, `init(fn)` (runs in every new document), `expose(name, handler)` (a binding the page calls with a JSON string), `screenshot(file)`, `tab()` (another tab in the same context), `close()`, `send(method, params, ms)` (a raw CDP call in the tab's session), `errors`, `responses` and `dir` (the test's artifact directory). `chrome.pid` is the browser's process id and `chrome.profile` its `slicc-harness-*` profile directory in `$TMPDIR`. `goto` and `reload` wait until the new document is complete and no longer `about:blank`, and retry the navigation once if that never happens. `until` polls until the function returns a truthy value (not only strict `true`) and resolves with that value; the timeout error names the last result. `within(ms, fn, ...args)` is `until` with its own deadline, for a stage that should take a known time. Both stop at the deadline even if one evaluation never answers. Every CDP call has a deadline: `connect(url).send(method, params, sessionId, ms)` rejects with `<method>: no answer in <ms> ms` (60 s unless given), so a stuck page fails its test instead of hanging the run. A crash fails it at once instead:
69
72
  - when the browser dies, every pending and later call rejects with `<method>: browser exited with <signal>` (or `with code <n>`);
@@ -75,7 +78,7 @@ test('boots', async (t) => {
75
78
  When one evaluation runs past `stallAfter`, the harness probes once, without interrupting the wait:
76
79
  - it adds a `pending` line to `console.log` for every navigation and request of the page that has no answer yet, with its age. A worker's script loads in the worker's own session, so its request is left out. Chrome holds CDP messages to a page while a navigation is waiting for its response, so an evaluation that hangs while the page is idle usually means one of these;
77
80
  - it saves `stall-<n>.png` (a screenshot of the page, if the renderer still answers) in the test's artifact directory;
78
- - it saves a CPU profile sample of every page and worker in the test, including workers started by other workers, as `stall-<n>-<target>-<session>.cpuprofile`;
81
+ - when profiling is on, it saves a CPU profile sample of every page and worker in the test, including workers started by other workers, as `stall-<n>-<target>-<session>.cpuprofile`;
79
82
  - it adds a line per target to `console.log`, saying whether that target is running or paused;
80
83
  - on the first stall, it records a 10 s browser trace as `stall-1-trace.json` (open it in DevTools' Performance panel or Perfetto). The browser collects it with V8 CPU samples per thread, task, IPC, Blink and loading events, so it shows what a page's main thread is doing even when that thread no longer answers CDP. It logs each process's CPU time over the window, and for each renderer its main thread's task count, the call chain of its longest or still open task (function, file, line and URL of a request), and where its CPU samples fall. The targets are sampled after the trace starts, so their new profiles carry their call frames into it. A call that started before the trace shows only as a node id, since its frames were named before; a thread that is blocked keeps `Tracing.start` waiting, which the harness logs and traces through;
81
84
  - it adds a line for every debugger pause still held in the browser, in any target, with its id, URL, start time, reason and top call frames.
@@ -99,8 +102,8 @@ Why it works the way it does:
99
102
  ## Cleanup
100
103
 
101
104
  `close()` stops Chrome and removes its profile. It asks Chrome to close, waits up to 10 s for the exit, sends `SIGKILL`, waits up to 5 s more and then moves on with a warning, so a browser that ignores `Browser.close` or a missed exit never hangs teardown. A test process that ends without `close()` still leaves nothing running:
102
- - on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
103
- - when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` that reads a pipe from the test process, then gets end of file and kills Chrome with `SIGKILL` at once. Chrome also shuts itself down when the CDP pipe closes, but that can take seconds. Only the profile stays behind;
105
+ - on `exit` and on `SIGINT`, `SIGTERM` or `SIGHUP`, the harness kills every browser it started in that process, with all its helper processes (Chrome runs in its own process group), with `SIGKILL` and removes its profile and crash dump directory, then re-raises the signal so the process ends the way it would have;
106
+ - when the test process dies of `SIGKILL`, no handler runs. A watchdog, a `sh` in its own process group that reads a pipe from the test process, then gets end of file and kills Chrome's process group with `SIGKILL` at once. Chrome also shuts itself down when the CDP pipe closes, but that can take seconds. Only the profile stays behind;
104
107
  - the first `launch()` in a process removes every `slicc-harness-*` directory in `$TMPDIR` that is older than a day, or older than a minute and no running process uses as its `--user-data-dir`. It never kills a process.
105
108
 
106
109
  ## Crash dumps
@@ -74,6 +74,7 @@ export async function start(
74
74
  ) {
75
75
  const child = spawn(executable, args, {
76
76
  env,
77
+ detached: true,
77
78
  stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
78
79
  });
79
80
  const stderr = lines(child.stderr);
@@ -111,7 +112,7 @@ export async function stop(cdp, child, waits = WAITS) {
111
112
  await bounded(cdp.send('Browser.close').catch(ignore), 'Browser.close', waits.close);
112
113
  cdp.close();
113
114
  if (gone() || (await bounded(exited, 'exit', waits.exit)) !== TIMED_OUT || gone()) return;
114
- child.kill('SIGKILL');
115
+ kill(child);
115
116
  if (gone() || (await bounded(exited, 'exit after SIGKILL', waits.kill)) !== TIMED_OUT) return;
116
117
  if (!gone()) console.warn(`chrome: pid ${child.pid} still runs after SIGKILL, moving on`);
117
118
  }
@@ -123,15 +124,27 @@ export const GRACE = 60 * 1000;
123
124
  const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
124
125
  const running = new Set();
125
126
 
126
- function kill(child) {
127
+ export function kill(child) {
128
+ if (child.pid > 0 && child.spawnargs?.length) {
129
+ try {
130
+ process.kill(-child.pid, 'SIGKILL');
131
+ } catch {}
132
+ }
127
133
  if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
128
134
  }
129
135
 
130
- function reap() {
131
- for (const { children, paths } of running) {
132
- for (const child of children) kill(child);
136
+ function remove(entries) {
137
+ for (const { paths } of entries) {
133
138
  for (const path of paths) rmSync(path, { recursive: true, force: true });
134
139
  }
140
+ }
141
+
142
+ function reap() {
143
+ const entries = [...running];
144
+ for (const { children } of entries) children.forEach(kill);
145
+ remove(entries);
146
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
147
+ remove(entries);
135
148
  running.clear();
136
149
  unhook();
137
150
  }
@@ -152,7 +165,8 @@ function unhook() {
152
165
  }
153
166
 
154
167
  export function watchdog(pid) {
155
- const dog = spawn('sh', ['-c', 'read -r _; kill -9 "$0" 2>/dev/null', String(pid)], {
168
+ const dog = spawn('sh', ['-c', 'read -r _; kill -9 -"$0" "$0" 2>/dev/null', String(pid)], {
169
+ detached: true,
156
170
  stdio: ['pipe', 'ignore', 'ignore'],
157
171
  });
158
172
  dog.on('error', ignore);
@@ -210,6 +224,7 @@ export async function launch({
210
224
  args = [],
211
225
  timeout,
212
226
  stallAfter,
227
+ profile = inherited.SLICC_PROFILE === '1',
213
228
  } = {}) {
214
229
  pruning ??= prune();
215
230
  await pruning;
@@ -218,16 +233,16 @@ export async function launch({
218
233
  const fromExtension = extensionSource(found);
219
234
  const files = { ...server, source: (href) => server.source(href) ?? fromExtension(href) };
220
235
  const shared = extensions.length > 0;
221
- const profile = await mkdtemp(join(tmpdir(), PREFIX));
236
+ const userData = await mkdtemp(join(tmpdir(), PREFIX));
222
237
  const loaded = [...found.values()].map((root) => root.slice(0, -1));
223
238
  const dumps = await crashpad();
224
239
  const { child, exited, stderr } = await start(
225
- profile,
226
- commandLine(profile, loaded, args),
240
+ userData,
241
+ commandLine(userData, loaded, args),
227
242
  chromium.executablePath(),
228
243
  dumps.env
229
244
  );
230
- const untrack = track(child, [profile, dumps.dir]);
245
+ const untrack = track(child, [userData, dumps.dir]);
231
246
  const cdp = await connect(pipe(child.stdio[3], child.stdio[4]), {
232
247
  exited,
233
248
  tail: () => stderr.tail().map((line) => `stderr: ${line}`),
@@ -238,7 +253,11 @@ export async function launch({
238
253
  cdp.on(({ method }) => {
239
254
  if (method === CRASHED || method === 'Target.targetCrashed') crashes += 1;
240
255
  });
241
- const record = recorder(cdp, files, { coverage, exits: await breakpoints(server, exits) });
256
+ const record = recorder(cdp, files, {
257
+ coverage,
258
+ exits: await breakpoints(server, exits),
259
+ profiling: profile,
260
+ });
242
261
  const remote = await cdn(cdp, intercept);
243
262
  await cdp.send('Target.setDiscoverTargets', { discover: true });
244
263
  await cdp.send('Target.setAutoAttach', {
@@ -282,7 +301,7 @@ export async function launch({
282
301
  return {
283
302
  url: server.url,
284
303
  pid: child.pid,
285
- profile,
304
+ profile: userData,
286
305
  cdn: remote.state,
287
306
  requests: server.requests,
288
307
  overrides: server.overrides,
@@ -316,7 +335,7 @@ export async function launch({
316
335
  },
317
336
  async close() {
318
337
  await stop(cdp, child);
319
- await rm(profile, { recursive: true, force: true });
338
+ await rm(userData, { recursive: true, force: true });
320
339
  untrack();
321
340
  closed = true;
322
341
  if (open === 0) await dumps.remove();
@@ -41,6 +41,9 @@ export function table(runs) {
41
41
  self.set(frame, (self.get(frame) ?? 0) + micros);
42
42
  }
43
43
  }
44
+ if (profiles === 0) {
45
+ return '### Hotspots: CPU profiling is off. Set SLICC_PROFILE=1 or pass `profile: true` to `launch()` to record profiles.\n';
46
+ }
44
47
  const top = [...self].sort((a, b) => b[1] - a[1]).slice(0, 15);
45
48
  const rows = top.map(([frame, micros]) => `| ${(micros / 1000).toFixed(2)} ms | \`${frame}\` |`);
46
49
  return [
@@ -8,6 +8,7 @@ import { bounded, ignore, raw, samples, sleep, TIMED_OUT } from './util.mjs';
8
8
 
9
9
  const profiled = new Set(['page', 'worker', 'service_worker', 'shared_worker']);
10
10
  const nested = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true };
11
+ const sampling = [['Profiler.setSamplingInterval', { interval: 100 }], ['Profiler.start']];
11
12
  const unload = "addEventListener('beforeunload', () => { debugger; })";
12
13
 
13
14
  function pathOf(url) {
@@ -77,18 +78,28 @@ function frames(params) {
77
78
  .join(' < ');
78
79
  }
79
80
 
80
- async function sample(cdp, pauses, [sessionId, target], dir, tag) {
81
+ async function sample(cdp, pauses, [sessionId, target], dir, tag, profiling) {
81
82
  const send = (method) => cdp.send(method, {}, sessionId, 8000);
82
- const stopped = await send('Profiler.stop').catch((error) => ({ error }));
83
- send('Profiler.start').catch(ignore);
84
83
  const pause = pauses.get(sessionId);
85
84
  const state = pause ? `paused (${pause.reason})` : 'running';
85
+ if (!profiling) return `${label(target)}: ${state}`;
86
+ const stopped = await send('Profiler.stop').catch((error) => ({ error }));
87
+ send('Profiler.start').catch(ignore);
86
88
  if (!stopped.profile) return `${label(target)}: ${state}, no profile (${stopped.error?.message})`;
87
89
  const name = `${tag}-${label(target)}-${sessionId.slice(0, 6)}.cpuprofile`;
88
90
  await writeFile(new URL(name, dir), JSON.stringify(stopped.profile)).catch(ignore);
89
91
  return `${label(target)}: ${state}, profile ${name}`;
90
92
  }
91
93
 
94
+ function collect(send, profiling) {
95
+ const stop = profiling ? send('Profiler.stop') : {};
96
+ const taken = Promise.all([send('Profiler.takePreciseCoverage'), stop]);
97
+ return Promise.race([taken, sleep(3000)]).then(
98
+ (answer) => answer ?? [{}, {}],
99
+ () => [{}, {}]
100
+ );
101
+ }
102
+
92
103
  function held(pauses) {
93
104
  return [...pauses.values()].map(
94
105
  (pause) =>
@@ -120,7 +131,7 @@ function contextOf(sessions, parents, sessionId) {
120
131
  return undefined;
121
132
  }
122
133
 
123
- export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } = {}) {
134
+ export function recorder(cdp, server, { coverage = ['/'], exits = new Map(), profiling } = {}) {
124
135
  const sessions = new Map();
125
136
  const pauses = new Map();
126
137
  const parents = new Map();
@@ -132,14 +143,11 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
132
143
  async function dump(sessionId, target, restart) {
133
144
  const current = run;
134
145
  const send = (method) => cdp.send(method, {}, sessionId);
135
- const taken = Promise.all([send('Profiler.takePreciseCoverage'), send('Profiler.stop')]);
136
- const [{ result }, { profile }] = await Promise.race([taken, sleep(3000)]).then(
137
- (answer) => answer ?? [{}, {}],
138
- () => [{}, {}]
139
- );
146
+ const [{ result }, { profile }] = await collect(send, profiling);
140
147
  if (!(result && current)) return;
141
- if (restart) send('Profiler.start').catch(ignore);
142
148
  current.scripts.push(...result.filter(ours));
149
+ if (!profile) return;
150
+ if (restart) send('Profiler.start').catch(ignore);
143
151
  current.profiles += 1;
144
152
  selfTimes(profile, server, current.self);
145
153
  const name = `${String(current.profiles).padStart(3, '0')}-${label(target)}.cpuprofile`;
@@ -168,11 +176,10 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
168
176
  list.push(
169
177
  ['Runtime.enable'],
170
178
  ['Profiler.enable'],
171
- ['Profiler.setSamplingInterval', { interval: 100 }],
172
179
  ['Profiler.startPreciseCoverage', { callCount: true, detailed: true }],
173
- ['Profiler.start'],
174
180
  ['Target.setAutoAttach', nested]
175
181
  );
182
+ if (profiling) list.push(...sampling);
176
183
  }
177
184
  const lines = type === 'worker' ? exits.get(pathOf(url)) : undefined;
178
185
  if (lines) {
@@ -230,7 +237,8 @@ export function recorder(cdp, server, { coverage = ['/'], exits = new Map() } =
230
237
  const current = [...sessions].filter(
231
238
  ([sessionId]) => contextOf(sessions, parents, sessionId) === run?.context
232
239
  );
233
- const sampled = () => Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag)));
240
+ const sampled = () =>
241
+ Promise.all(current.map((entry) => sample(cdp, pauses, entry, dir, tag, profiling)));
234
242
  const [lines, traced] = first ? await trace(cdp, dir, tag, sampled) : [await sampled(), []];
235
243
  const all = [...pending, ...lines, ...traced, ...held(pauses)];
236
244
  for (const line of all) run?.console.push(`${tag}: ${line}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-shared-web",
3
- "version": "1.10.2",
3
+ "version": "1.11.0",
4
4
  "description": "Shared Renovate, Biome, TypeScript, lefthook and GitHub Actions configuration, plus the CDP integration-test harness, for SLICC's web repos",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -52,7 +52,7 @@
52
52
  "scripts": {
53
53
  "lint": "bin/slicc-lint.sh && shellcheck bin/*.sh",
54
54
  "pretest": "playwright-core install --no-shell chromium",
55
- "test": "node --test --test-global-setup=harness/global.mjs test/integration/*.test.mjs && node test/integration/verify.mjs",
55
+ "test": "SLICC_PROFILE=1 node --test --test-global-setup=harness/global.mjs test/integration/*.test.mjs && node test/integration/verify.mjs",
56
56
  "test:unit": "mcr -c test/unit/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
57
57
  "trust": "npx fledgling sync -y --skip-publish",
58
58
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",