@driftengine/core 3.61.1 → 3.61.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "3.61.1",
3
+ "version": "3.61.3",
4
4
  "description": "DriftEngine reusable browser 3D runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "!src/**/*.test.ts",
22
22
  "!src/**/*.test.mjs",
23
23
  "!src/**/__snapshots__",
24
+ "scripts",
24
25
  "CHANGELOG.json",
25
26
  "README.md",
26
27
  "LICENSE",
@@ -28,8 +29,8 @@
28
29
  ],
29
30
  "sideEffects": false,
30
31
  "dependencies": {
31
- "@driftengine/drft": "3.61.1",
32
- "@driftengine/physics": "3.61.1",
32
+ "@driftengine/drft": "3.61.3",
33
+ "@driftengine/physics": "3.61.3",
33
34
  "gl-matrix": "^3.4.3"
34
35
  },
35
36
  "author": "Drift Technologies",
@@ -0,0 +1,17 @@
1
+ import type { CdpClient } from './cdp.d.mts';
2
+
3
+ /** A running browser's debugging port, and how to stop it. */
4
+ export interface LaunchedBrowser {
5
+ readonly port: number;
6
+ readonly binary: string;
7
+ close(): Promise<void>;
8
+ }
9
+
10
+ export function chromePath(): string;
11
+ export function launch(options?: {
12
+ flags?: readonly string[];
13
+ headless?: boolean;
14
+ timeoutMs?: number;
15
+ }): Promise<LaunchedBrowser>;
16
+ export function rendererName(client: CdpClient): Promise<string>;
17
+ export function requireHardwareGpu(client: CdpClient): Promise<string>;
@@ -0,0 +1,353 @@
1
+ /**
2
+ * A browser that is really using the GPU, or an error saying it is not.
3
+ *
4
+ * **This module exists because the default is wrong and the default is silent.** Plain
5
+ * `--headless=new` on this machine selects SwiftShader, measured: the renderer string comes back
6
+ * `ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)` and every
7
+ * frame it produces is a lie about what a player sees. It also saturates and overheats the CPU,
8
+ * which is why `AGENTS.md` forbids it outright. Nothing warns. The pictures look plausible.
9
+ *
10
+ * So the flags below are not tuning, and neither is the check after them. A visual comparison
11
+ * taken on a software renderer is worse than no comparison, because it reads exactly like
12
+ * evidence.
13
+ *
14
+ * **This module also cannot be allowed to leak a browser, and it takes three mechanisms to keep
15
+ * that promise, not one.** Measured on this machine: 132 live headless Chrome processes across 12
16
+ * orphaned profiles, plus 59 stale directories, after a few hours of sessions that each thought
17
+ * their `finally` block would cover them. It didn't, for three separate reasons, and each needs
18
+ * its own fix. First, `spawn` was not detached: Chrome is a tree, not a process — it forks a
19
+ * zygote, a GPU process and one renderer per page — and killing the top of the tree alone leaves
20
+ * the rest running with the port closed and the profile gone, which is exactly the state those
21
+ * 132 were found in. Second, `close()` only runs when the process gets far enough to call it, and
22
+ * SIGINT, a shell timeout, an uncaught exception outside the `try`, and SIGKILL all skip it; a
23
+ * handler can catch the first three, but nothing can run during the fourth. Third, and this is the
24
+ * one that actually bounds the count, nothing was reaping a previous run's corpse, so every leak
25
+ * was permanent and the total only ever grew. A sweep at the start of every launch is the only
26
+ * mechanism that can cover the SIGKILL case, because it runs *after* the crash instead of during
27
+ * it, and it is what turns "leaks accumulate forever" into "at most one leak per still-running
28
+ * process."
29
+ */
30
+ import { spawn } from 'node:child_process';
31
+ import { once } from 'node:events';
32
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import path from 'node:path';
35
+ import { sleep } from './cdp.mjs';
36
+
37
+ /** Where a Chrome or Chromium lives, in the order worth trying. `CHROME_PATH` wins. */
38
+ const CANDIDATES = [
39
+ '/opt/google/chrome/chrome',
40
+ '/usr/bin/google-chrome',
41
+ '/usr/bin/google-chrome-stable',
42
+ '/usr/bin/chromium',
43
+ '/usr/bin/chromium-browser',
44
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
45
+ ];
46
+
47
+ /**
48
+ * What it takes to reach the real card from a headless process.
49
+ *
50
+ * `--use-angle=vulkan` with the feature flag beside it is what moved this machine from
51
+ * SwiftShader to an RX 9070 XT through radv. A caller on other hardware overrides `flags`
52
+ * entirely; the guard below is what tells them whether they need to.
53
+ *
54
+ * **WebGPU needs no third flag, and it does need a secure context.** `navigator.gpu` is present
55
+ * on `http://localhost` with the two below and absent on `about:blank`, which is not one — so a
56
+ * probe that opens a blank page to ask what the adapter is gets told there is none on a machine
57
+ * that has one, and `--enable-unsafe-webgpu` alongside changes neither answer. Both measured, in
58
+ * both directions, because the first reading of that absence was that the card had no adapter.
59
+ * What it reports here is `vendor amd`, `architecture rdna-4`.
60
+ */
61
+ const GPU_FLAGS = ['--use-angle=vulkan', '--enable-features=Vulkan'];
62
+
63
+ const BASE_FLAGS = [
64
+ '--headless=new',
65
+ '--no-first-run',
66
+ '--no-default-browser-check',
67
+ '--password-store=basic',
68
+ '--hide-scrollbars',
69
+ /* The port is asked for as 0 and read back out of the profile, so two runs at once cannot
70
+ collide on a number somebody picked. */
71
+ '--remote-debugging-port=0',
72
+ ];
73
+
74
+ /** Names a software rasteriser goes by. Any of them means the measurement is worthless. */
75
+ const SOFTWARE = /swiftshader|llvmpipe|softpipe|software|microsoft basic/i;
76
+
77
+ /** Every throwaway profile this module makes, so a sweep can recognise its own litter. */
78
+ const PROFILE_PREFIX = 'driftengine-shots-';
79
+ /** Written into each profile so a later run can tell whether anybody still owns it. */
80
+ const OWNER_FILE = 'driftengine-owner.json';
81
+
82
+ /**
83
+ * Signal an entire process group, escalating if it does not go.
84
+ *
85
+ * **Chrome is a tree, not a process.** It forks a zygote, a GPU process and one renderer per
86
+ * page, and signalling the parent alone leaves the rest running with the port closed and the
87
+ * profile deleted, which is the state 132 of them were found in. `launch` puts the browser in
88
+ * its own group with `detached`, so the negative PID below reaches every descendant in one call.
89
+ *
90
+ * SIGTERM first because a browser asked politely flushes and exits; SIGKILL after a grace period
91
+ * because one that is wedged never will, and a wedged browser is exactly the case this exists for.
92
+ */
93
+ function killTree(pid, graceMs = 2000) {
94
+ if (!Number.isInteger(pid) || pid <= 0) return;
95
+ try {
96
+ process.kill(-pid, 'SIGTERM');
97
+ } catch {
98
+ /* Already gone, or never in its own group. Either way there is nothing to signal. */
99
+ return;
100
+ }
101
+ const deadline = Date.now() + graceMs;
102
+ const timer = setTimeout(
103
+ () => {
104
+ try {
105
+ process.kill(-pid, 'SIGKILL');
106
+ } catch {
107
+ /* it went during the grace period, which is the outcome we wanted */
108
+ }
109
+ },
110
+ Math.max(0, deadline - Date.now()),
111
+ );
112
+ /* Do not hold the event loop open on a browser that has already been told to go. */
113
+ timer.unref?.();
114
+ }
115
+
116
+ /**
117
+ * How long `close()` waits for Chrome to actually exit before touching its directory.
118
+ *
119
+ * Measured on this machine: `killTree` followed immediately by `rmSync`, with no wait between
120
+ * them, raced Chrome's own multi-process shutdown and lost on nearly every ordinary run, leaving
121
+ * a directory behind after almost every `close()` call rather than only the SIGKILL case this
122
+ * module exists for. `killTree`'s own escalation to SIGKILL fires at the 2s mark, so this only
123
+ * needs to clear that by a margin wide enough to let a well-behaved shutdown actually finish
124
+ * writing and release its files; a browser that is still standing after that is wedged rather
125
+ * than slow, and gets left for the next launch's sweep, same as any other case this can't cover.
126
+ */
127
+ const CLOSE_WAIT_MS = 3500;
128
+
129
+ /**
130
+ * Remove every throwaway profile whose owning Node process is gone, killing whatever still runs
131
+ * in it.
132
+ *
133
+ * **This is the part that makes the guarantee absolute, and the reason is worth stating.** Exit
134
+ * handlers cover a crash; they cannot cover `SIGKILL`, a power cut, or an OOM kill, because no
135
+ * code of ours runs in those. Something has to clean up *afterwards*, and the only moment that is
136
+ * guaranteed to come is the next launch. So a leak can survive its own run and cannot survive the
137
+ * next one, which is what stops the count from growing.
138
+ *
139
+ * Every capability is a parameter because the alternative is a test that spawns browsers to check
140
+ * that browsers get cleaned up.
141
+ */
142
+ export function reapOrphanProfiles({
143
+ root = tmpdir(),
144
+ listProfiles = () => defaultListProfiles(root),
145
+ readOwner = defaultReadOwner,
146
+ isAlive = defaultIsAlive,
147
+ killTree: kill = killTree,
148
+ remove = (dir) => rmSync(dir, { recursive: true, force: true }),
149
+ } = {}) {
150
+ let reaped = 0;
151
+ for (const dir of listProfiles()) {
152
+ try {
153
+ const owner = readOwner(dir);
154
+ /* A live owner is a run in progress, possibly in another terminal. Leave it alone. */
155
+ if (owner !== null && isAlive(owner.ownerPid)) continue;
156
+ if (owner !== null) kill(owner.chromePid);
157
+ remove(dir);
158
+ reaped++;
159
+ } catch {
160
+ /* One unreadable or busy directory must not abandon the rest: a sweep that stops at the
161
+ first problem is a sweep that stops working the first time it is needed. */
162
+ }
163
+ }
164
+ return reaped;
165
+ }
166
+
167
+ function defaultListProfiles(root) {
168
+ return readdirSync(root)
169
+ .filter((name) => name.startsWith(PROFILE_PREFIX))
170
+ .map((name) => path.join(root, name));
171
+ }
172
+
173
+ function defaultReadOwner(dir) {
174
+ try {
175
+ const parsed = JSON.parse(readFileSync(path.join(dir, OWNER_FILE), 'utf8'));
176
+ return typeof parsed?.ownerPid === 'number' && typeof parsed?.chromePid === 'number'
177
+ ? parsed
178
+ : null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /** Signal 0 asks the kernel whether a PID exists without touching it. */
185
+ function defaultIsAlive(pid) {
186
+ try {
187
+ process.kill(pid, 0);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ export function chromePath() {
195
+ const asked = process.env['CHROME_PATH'];
196
+ if (asked !== undefined && asked !== '') return asked;
197
+ const found = CANDIDATES.find((candidate) => existsSync(candidate));
198
+ if (found === undefined) {
199
+ throw new Error(
200
+ `no Chrome or Chromium found. Tried:\n ${CANDIDATES.join('\n ')}\nSet CHROME_PATH to one.`,
201
+ );
202
+ }
203
+ return found;
204
+ }
205
+
206
+ /**
207
+ * Start a browser and wait for its debugging port.
208
+ *
209
+ * Returns `{ port, close }`. `close` kills the process and removes the throwaway profile, and a
210
+ * caller that forgets leaves a browser running for the length of the session, which is the other
211
+ * half of the rule about stopping temporary sessions after a check.
212
+ */
213
+ export async function launch({ flags = GPU_FLAGS, headless = true, timeoutMs = 20_000 } = {}) {
214
+ /* A launch that begins by clearing the last run's corpse is what bounds the leaked count at
215
+ one per still-running process instead of letting it grow across every session that forgot. */
216
+ reapOrphanProfiles();
217
+
218
+ const binary = chromePath();
219
+ const profile = mkdtempSync(path.join(tmpdir(), PROFILE_PREFIX));
220
+ const args = [
221
+ ...BASE_FLAGS.filter((flag) => headless || flag !== '--headless=new'),
222
+ `--user-data-dir=${profile}`,
223
+ ...flags,
224
+ 'about:blank',
225
+ ];
226
+ /* `detached` puts Chrome in its own process group so `killTree` can reach the zygote, the GPU
227
+ process and every renderer with one signal to the negative PID. `unref()` is deliberately
228
+ not called: the parent must stay attached, or its own exit and signal handlers below never
229
+ fire, and those are half of what closes the other three holes. */
230
+ const child = spawn(binary, args, { stdio: 'ignore', detached: true });
231
+ writeFileSync(
232
+ path.join(profile, OWNER_FILE),
233
+ JSON.stringify({ ownerPid: process.pid, chromePid: child.pid }),
234
+ );
235
+
236
+ /*
237
+ * The paths `close()` never gets to run on. `exit` must stay synchronous and best-effort by
238
+ * necessity, not by choice: a `process.once('exit', ...)` handler cannot await anything, full
239
+ * stop, so there is no version of this that waits for Chrome to actually leave before touching
240
+ * its directory. Do not "fix" the race here by making this async — it will not run. The next
241
+ * launch's sweep is the backstop for exactly this case, the same as it is for a SIGKILL this
242
+ * process never even got a handler for. The signal handlers re-raise after cleaning up so the
243
+ * exit code still means what it meant, which matters to a CI character reading it, and they go
244
+ * through this same synchronous path for the same reason: by the time one fires, the process is
245
+ * on its way out either way.
246
+ */
247
+ let cleaned = false;
248
+ const cleanup = () => {
249
+ if (cleaned) return;
250
+ cleaned = true;
251
+ killTree(child.pid);
252
+ try {
253
+ rmSync(profile, { recursive: true, force: true });
254
+ } catch {
255
+ /* the browser may still hold a handle; the next launch's sweep will collect it */
256
+ }
257
+ };
258
+ const onSignal = (signal) => {
259
+ cleanup();
260
+ process.removeListener(signal, onSignal);
261
+ process.kill(process.pid, signal);
262
+ };
263
+ process.once('exit', cleanup);
264
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, onSignal);
265
+
266
+ const portFile = path.join(profile, 'DevToolsActivePort');
267
+ const started = Date.now();
268
+ let port = 0;
269
+ while (port === 0) {
270
+ if (existsSync(portFile)) {
271
+ const first = readFileSync(portFile, 'utf8').split('\n')[0];
272
+ if (first !== undefined && first !== '') port = Number(first);
273
+ }
274
+ if (port === 0) {
275
+ if (Date.now() - started > timeoutMs) {
276
+ /* The fourth hole: this used to be `child.kill()`, which killed one process and left the
277
+ profile on disk. `cleanup()` is the same path every other exit takes. */
278
+ cleanup();
279
+ throw new Error(`${binary} did not open a debugging port within ${timeoutMs} ms`);
280
+ }
281
+ await sleep(150);
282
+ }
283
+ }
284
+
285
+ return {
286
+ port,
287
+ binary,
288
+ async close() {
289
+ if (!cleaned) {
290
+ cleaned = true;
291
+ killTree(child.pid);
292
+ /*
293
+ * Unlike `cleanup()` above, this is already inside an async function, so it is not stuck
294
+ * removing the directory in the same tick as the signal. Waiting for the real exit event
295
+ * (or the grace period, whichever comes first) is what turns "usually leaves a directory
296
+ * behind" into "does not," on the one path that runs every single time.
297
+ */
298
+ await Promise.race([once(child, 'exit'), sleep(CLOSE_WAIT_MS)]);
299
+ try {
300
+ rmSync(profile, { recursive: true, force: true });
301
+ } catch {
302
+ /* still busy after waiting: wedged rather than slow. The next launch's sweep collects it. */
303
+ }
304
+ }
305
+ /* Unconditional and idempotent, so a second close() call (or one that follows a signal that
306
+ already ran cleanup()) still detaches every listener rather than leaving the leftover two
307
+ behind. Without this, a long-lived process launching many browsers accumulates one
308
+ listener per signal per launch and eventually trips Node's max-listeners warning. */
309
+ process.removeListener('exit', cleanup);
310
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'])
311
+ process.removeListener(signal, onSignal);
312
+ },
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Which renderer this browser actually chose, asked of a real WebGL2 context.
318
+ *
319
+ * `WEBGL_debug_renderer_info` is the only way to see past the masked string, and its absence is
320
+ * itself worth reporting rather than papering over: a browser that hides it cannot be checked.
321
+ */
322
+ export async function rendererName(client) {
323
+ const page = await client.page('about:blank', 320, 240);
324
+ try {
325
+ return await page.eval(`(() => {
326
+ const gl = document.createElement('canvas').getContext('webgl2');
327
+ if (gl === null) return 'no webgl2';
328
+ const info = gl.getExtension('WEBGL_debug_renderer_info');
329
+ if (info === null) return 'unknown: WEBGL_debug_renderer_info absent';
330
+ return gl.getParameter(info.UNMASKED_RENDERER_WEBGL);
331
+ })()`);
332
+ } finally {
333
+ await page.close();
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Refuse to go on unless a physical GPU is drawing. Returns the renderer string.
339
+ *
340
+ * Called by `shots.mjs` before anything is captured. A consumer driving its own captures should
341
+ * call it too, once per browser, for the same reason.
342
+ */
343
+ export async function requireHardwareGpu(client) {
344
+ const name = await rendererName(client);
345
+ if (SOFTWARE.test(name) || name.startsWith('no webgl2')) {
346
+ throw new Error(
347
+ `this browser is drawing with ${name}, which cannot be measured or looked at.\n` +
348
+ `Try launch({ flags: ['--use-angle=vulkan', '--enable-features=Vulkan'] }), or a ` +
349
+ `different backend for this platform, or run with headless: false.`,
350
+ );
351
+ }
352
+ return name;
353
+ }
@@ -0,0 +1,28 @@
1
+ /** A tab opened through `client.page`, sized and ready to be driven. */
2
+ export interface CdpPage {
3
+ call(method: string, params?: Record<string, unknown>): Promise<unknown>;
4
+ readonly logs: string[];
5
+ /** `Runtime.evaluate` with `awaitPromise` and `returnByValue`; throws on a page exception. */
6
+ eval(expression: string, options?: Record<string, unknown>): Promise<unknown>;
7
+ settled(expression?: string, options?: { timeoutMs?: number; settleMs?: number }): Promise<void>;
8
+ frames(count?: number): Promise<void>;
9
+ complaints(): string[];
10
+ screenshot(path: string): Promise<string>;
11
+ close(): Promise<void>;
12
+ }
13
+
14
+ /** A session against a browser already listening on its debugging port. */
15
+ export interface CdpClient {
16
+ send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown>;
17
+ on(listener: (message: unknown) => void): void;
18
+ page(
19
+ url: string,
20
+ width?: number,
21
+ height?: number,
22
+ options?: { beforeLoad?: string },
23
+ ): Promise<CdpPage>;
24
+ close(): void;
25
+ }
26
+
27
+ export function sleep(ms: number): Promise<void>;
28
+ export function connect(port: number, options?: { timeoutMs?: number }): Promise<CdpClient>;