@gjsify/cli 0.51.1 → 0.53.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/dist/cli.gjs.mjs +539 -493
- package/lib/actions/build.d.ts +22 -0
- package/lib/actions/build.js +37 -9
- package/lib/bundler-pick.d.ts +37 -1
- package/lib/bundler-pick.js +108 -17
- package/lib/cli-app.js +7 -2
- package/lib/commands/affected-classify.d.ts +7 -1
- package/lib/commands/affected-classify.js +66 -9
- package/lib/commands/affected-script-refs.d.ts +20 -0
- package/lib/commands/affected-script-refs.js +218 -0
- package/lib/commands/affected.js +24 -2
- package/lib/commands/env.d.ts +31 -0
- package/lib/commands/env.js +114 -0
- package/lib/commands/exec.d.ts +10 -0
- package/lib/commands/exec.js +208 -0
- package/lib/commands/flatpak/diff.js +8 -2
- package/lib/commands/flatpak/init.js +4 -1
- package/lib/commands/flatpak/sources.js +4 -3
- package/lib/commands/flatpak/sync-flathub.d.ts +2 -0
- package/lib/commands/flatpak/sync-flathub.js +149 -7
- package/lib/commands/flatpak/utils.d.ts +10 -0
- package/lib/commands/flatpak/utils.js +10 -0
- package/lib/commands/foreach.js +5 -97
- package/lib/commands/format.js +26 -6
- package/lib/commands/index.d.ts +4 -0
- package/lib/commands/index.js +4 -0
- package/lib/commands/install.d.ts +2 -2
- package/lib/commands/install.js +81 -5
- package/lib/commands/link.d.ts +13 -0
- package/lib/commands/link.js +243 -0
- package/lib/commands/onboard.js +18 -2
- package/lib/commands/publish.d.ts +34 -0
- package/lib/commands/publish.js +153 -8
- package/lib/commands/self-update.js +2 -1
- package/lib/commands/ship.js +19 -1
- package/lib/commands/test.d.ts +18 -9
- package/lib/commands/test.js +56 -11
- package/lib/commands/tsc.js +4 -4
- package/lib/commands/upgrade.d.ts +15 -0
- package/lib/commands/upgrade.js +76 -2
- package/lib/commands/webext/index.d.ts +2 -0
- package/lib/commands/webext/index.js +15 -0
- package/lib/commands/webext/subcommands.d.ts +16 -0
- package/lib/commands/webext/subcommands.js +204 -0
- package/lib/types/config-data.d.ts +2 -2
- package/lib/utils/bin-shim.d.ts +8 -2
- package/lib/utils/bin-shim.js +6 -5
- package/lib/utils/bundle-inputs.d.ts +84 -0
- package/lib/utils/bundle-inputs.js +268 -0
- package/lib/utils/check-system-deps.d.ts +5 -19
- package/lib/utils/check-system-deps.js +31 -94
- package/lib/utils/dev-link.d.ts +225 -0
- package/lib/utils/dev-link.js +684 -0
- package/lib/utils/exec-bin.d.ts +137 -0
- package/lib/utils/exec-bin.js +321 -0
- package/lib/utils/gi-runtime-paths.js +3 -2
- package/lib/utils/gitignore.d.ts +15 -0
- package/lib/utils/gitignore.js +178 -0
- package/lib/utils/gjsify-shim.d.ts +47 -0
- package/lib/utils/gjsify-shim.js +97 -8
- package/lib/utils/hang-watchdog.d.ts +95 -0
- package/lib/utils/hang-watchdog.js +194 -0
- package/lib/utils/install-backend-native.d.ts +33 -1
- package/lib/utils/install-backend-native.js +184 -20
- package/lib/utils/install-extraneous.d.ts +90 -0
- package/lib/utils/install-extraneous.js +120 -0
- package/lib/utils/install-lock.js +4 -25
- package/lib/utils/install-tarball-cache.d.ts +37 -9
- package/lib/utils/install-tarball-cache.js +59 -14
- package/lib/utils/native-skip-scan.d.ts +20 -0
- package/lib/utils/native-skip-scan.js +343 -0
- package/lib/utils/node-script.js +1 -1
- package/lib/utils/normalize-bundler-options.js +11 -0
- package/lib/utils/oxc-resolve.js +42 -3
- package/lib/utils/process-table.d.ts +22 -0
- package/lib/utils/process-table.js +188 -0
- package/lib/utils/prune-prefix.js +1 -1
- package/lib/utils/publish-pins.d.ts +38 -0
- package/lib/utils/publish-pins.js +47 -0
- package/lib/utils/publish-readback.d.ts +103 -17
- package/lib/utils/publish-readback.js +326 -47
- package/lib/utils/run-gjs.d.ts +15 -1
- package/lib/utils/run-gjs.js +98 -13
- package/lib/utils/run-node.d.ts +2 -0
- package/lib/utils/run-node.js +2 -1
- package/lib/utils/runtimes.js +14 -13
- package/lib/utils/ship/gzip.js +1 -1
- package/lib/utils/ship/icns.d.ts +14 -0
- package/lib/utils/ship/icns.js +83 -0
- package/lib/utils/ship/ico.d.ts +25 -0
- package/lib/utils/ship/ico.js +120 -0
- package/lib/utils/ship/icons.d.ts +197 -0
- package/lib/utils/ship/icons.js +399 -0
- package/lib/utils/ship/launcher.js +16 -1
- package/lib/utils/ship/layout.d.ts +32 -0
- package/lib/utils/ship/layout.js +112 -38
- package/lib/utils/ship/msi.d.ts +1 -1
- package/lib/utils/ship/msi.js +30 -5
- package/lib/utils/ship/pe-launcher.d.ts +18 -0
- package/lib/utils/ship/pe-launcher.js +125 -4
- package/lib/utils/ship/plan.d.ts +1 -34
- package/lib/utils/ship/plan.js +7 -56
- package/lib/utils/ship/plist.d.ts +20 -10
- package/lib/utils/ship/plist.js +29 -12
- package/lib/utils/ship/settings.js +3 -2
- package/lib/utils/spawn.d.ts +13 -0
- package/lib/utils/spawn.js +10 -5
- package/lib/utils/watch-loop.d.ts +15 -1
- package/lib/utils/watch-loop.js +9 -5
- package/lib/utils/webext/build.d.ts +42 -0
- package/lib/utils/webext/build.js +120 -0
- package/lib/utils/webext/config.d.ts +72 -0
- package/lib/utils/webext/config.js +234 -0
- package/lib/utils/webext/icons.d.ts +24 -0
- package/lib/utils/webext/icons.js +83 -0
- package/lib/utils/webext/launch.d.ts +32 -0
- package/lib/utils/webext/launch.js +77 -0
- package/lib/utils/webext/manifest.d.ts +53 -0
- package/lib/utils/webext/manifest.js +219 -0
- package/lib/utils/webext/pages.d.ts +26 -0
- package/lib/utils/webext/pages.js +61 -0
- package/lib/utils/webext/targets.d.ts +13 -0
- package/lib/utils/webext/targets.js +36 -0
- package/lib/utils/win32-command.d.ts +17 -0
- package/lib/utils/win32-command.js +21 -6
- package/package.json +20 -20
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
* undoing the repair one level down.
|
|
9
9
|
*/
|
|
10
10
|
export declare function usingSelfShim(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
|
|
13
|
+
*
|
|
14
|
+
* The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
|
|
15
|
+
* runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
|
|
16
|
+
* back, the temp copy of the version it had just replaced, so a successful update
|
|
17
|
+
* inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
|
|
18
|
+
* (v0.51.1)" and exited 1.
|
|
19
|
+
*/
|
|
20
|
+
export declare function pathWithoutSelfShim(pathValue?: string, shimDir?: string | undefined, sep?: string): string;
|
|
11
21
|
/**
|
|
12
22
|
* Does this invocation need its own `gjsify` on PATH, rather than the workspace's
|
|
13
23
|
* npm bin?
|
|
@@ -31,6 +41,43 @@ export declare function needsSelfShim(opts: {
|
|
|
31
41
|
* reuse it instead of creating their own.
|
|
32
42
|
*/
|
|
33
43
|
export declare function ensureGjsifyShimOnPath(): void;
|
|
44
|
+
/**
|
|
45
|
+
* The tree whose `node_modules` holds the running CLI and its prebuilds: the
|
|
46
|
+
* install prefix above the first `node_modules` segment, or the workspace root
|
|
47
|
+
* for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
|
|
48
|
+
*/
|
|
49
|
+
export declare function selfShimScanRoot(selfEntry: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
|
|
52
|
+
* with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
|
|
53
|
+
* the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
|
|
54
|
+
*/
|
|
55
|
+
export declare function selfShimPreamble(selfEntry: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
|
|
58
|
+
*
|
|
59
|
+
* On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
|
|
60
|
+
* `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
|
|
61
|
+
* is one, so the child `gjs` otherwise starts without the loader path the install
|
|
62
|
+
* launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
|
|
63
|
+
* `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
|
|
64
|
+
* the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
|
|
65
|
+
* `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
|
|
66
|
+
* through the tree's own launcher worked.
|
|
67
|
+
*
|
|
68
|
+
* Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
|
|
69
|
+
* shim lives in a temp dir for ONE process tree and carries exactly the environment
|
|
70
|
+
* that tree's root was started with.
|
|
71
|
+
*/
|
|
72
|
+
export declare function buildSelfShimScript(opts: {
|
|
73
|
+
interpreter: string;
|
|
74
|
+
interpreterArgs: readonly string[];
|
|
75
|
+
target: string;
|
|
76
|
+
platform?: string;
|
|
77
|
+
env?: Record<string, string | undefined>;
|
|
78
|
+
}): string;
|
|
79
|
+
/** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
|
|
80
|
+
export declare function dyldCarryPreamble(platform: string, env: Record<string, string | undefined>): string;
|
|
34
81
|
/**
|
|
35
82
|
* Directory carrying a `node` that runs the file through
|
|
36
83
|
* `gjsify run --node-script`, or null when this host needs none.
|
package/lib/utils/gjsify-shim.js
CHANGED
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
// nested builds off the GJS bundle.
|
|
42
42
|
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs';
|
|
43
43
|
import { tmpdir } from 'node:os';
|
|
44
|
-
import { join, delimiter, relative, isAbsolute } from 'node:path';
|
|
44
|
+
import { join, delimiter, relative, isAbsolute, dirname } from 'node:path';
|
|
45
45
|
import { fileURLToPath } from 'node:url';
|
|
46
46
|
import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
|
|
47
|
-
import { buildLauncherShims } from './bin-shim.js';
|
|
47
|
+
import { buildLauncherShims, buildNativeEnvPreamble, buildShLauncher, shQuoteArg } from './bin-shim.js';
|
|
48
|
+
import { detectNativePackages } from './detect-native-packages.js';
|
|
48
49
|
import { resolveBinOnPath } from './install-global.js';
|
|
49
50
|
import { findWorkspaceRoot } from './workspace-root.js';
|
|
50
51
|
let _ensured = false;
|
|
@@ -61,6 +62,23 @@ let _selfShimActive = false;
|
|
|
61
62
|
export function usingSelfShim() {
|
|
62
63
|
return _selfShimActive;
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* `PATH` as the user's shell sees it: `process.env.PATH` without the self-shim dir.
|
|
67
|
+
*
|
|
68
|
+
* The shim is this PROCESS TREE's `gjsify`, never the one the user's next command
|
|
69
|
+
* runs. `self-update` asked `resolveBinOnPath` who wins PATH and got its own shim
|
|
70
|
+
* back, the temp copy of the version it had just replaced, so a successful update
|
|
71
|
+
* inside a workspace reported "`gjsify` on PATH resolves to /tmp/gjsify-shim-…
|
|
72
|
+
* (v0.51.1)" and exited 1.
|
|
73
|
+
*/
|
|
74
|
+
export function pathWithoutSelfShim(pathValue = process.env.PATH ?? '', shimDir = process.env.GJSIFY_SHIM_DIR, sep = delimiter) {
|
|
75
|
+
if (!shimDir)
|
|
76
|
+
return pathValue;
|
|
77
|
+
return pathValue
|
|
78
|
+
.split(sep)
|
|
79
|
+
.filter((entry) => entry !== shimDir)
|
|
80
|
+
.join(sep);
|
|
81
|
+
}
|
|
64
82
|
/**
|
|
65
83
|
* Does this invocation need its own `gjsify` on PATH, rather than the workspace's
|
|
66
84
|
* npm bin?
|
|
@@ -117,23 +135,90 @@ export function ensureGjsifyShimOnPath() {
|
|
|
117
135
|
// `process.execPath`: the batch member cannot quote an interpreter argument
|
|
118
136
|
// (`<interpreter> "<target>" %*`), so `C:\Program Files\nodejs\node.exe` splits.
|
|
119
137
|
const interpreter = gjs ? process.env.GJS_CONSOLE || 'gjs' : 'node';
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
138
|
+
// Under GJS the shim is the SAME launcher `gjsify install` writes, preamble
|
|
139
|
+
// included (`selfShimPreamble`). A bare `exec gjs -m` is not enough on a Mac:
|
|
140
|
+
// it runs behind `/bin/sh`, and SIP strips every `DYLD_*` from a protected
|
|
141
|
+
// binary's environment, so a nested `gjsify` found its typelibs through the
|
|
142
|
+
// surviving GI_TYPELIB_PATH and none of their libraries. Reported by the
|
|
143
|
+
// beifahrer consumer on macOS: `gjsify workspace <pkg> build` died in
|
|
144
|
+
// `get columns` with "Unsupported type void".
|
|
145
|
+
const envPreamble = gjs ? selfShimPreamble(selfEntry) : '';
|
|
146
|
+
writeFileSync(shim, gjs
|
|
147
|
+
? buildShLauncher(selfEntry, { isGjsBundle: true, envPreamble, gjs: interpreter })
|
|
148
|
+
: `#!/bin/sh\nexec "${interpreter}" "${selfEntry}" "$@"\n`, { mode: 0o755 });
|
|
123
149
|
chmodSync(shim, 0o755);
|
|
124
150
|
// cmd.exe and pwsh cannot run the extension-less member: not on PATHEXT, and
|
|
125
151
|
// Windows has no shebang handling. `buildLauncherShims` ports npm's
|
|
126
152
|
// three-sibling answer, including the IF/ELSE ERRORLEVEL handling that
|
|
127
153
|
// `&&`/`||` chaining gets wrong.
|
|
128
154
|
if (!gjs && process.platform === 'win32') {
|
|
129
|
-
const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs, target: selfEntry });
|
|
155
|
+
const { cmd, ps1 } = buildLauncherShims({ interpreter, interpreterArgs: [], target: selfEntry });
|
|
130
156
|
writeFileSync(`${shim}.cmd`, cmd);
|
|
131
157
|
writeFileSync(`${shim}.ps1`, ps1);
|
|
132
158
|
}
|
|
133
159
|
_selfShimActive = true;
|
|
134
160
|
process.env.GJSIFY_SHIM_DIR = dir;
|
|
135
161
|
process.env.PATH = dir + delimiter + (process.env.PATH ?? '');
|
|
136
|
-
writeNodeShim(dir, gjs, selfEntry);
|
|
162
|
+
writeNodeShim(dir, gjs, selfEntry, envPreamble);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The tree whose `node_modules` holds the running CLI and its prebuilds: the
|
|
166
|
+
* install prefix above the first `node_modules` segment, or the workspace root
|
|
167
|
+
* for an in-repo CLI (`packages/infra/cli/dist/cli.gjs.mjs`).
|
|
168
|
+
*/
|
|
169
|
+
export function selfShimScanRoot(selfEntry) {
|
|
170
|
+
// Both separators: the entry is a HOST path, and win32 spells it with a backslash.
|
|
171
|
+
const at = selfEntry.search(/[\\/]node_modules[\\/]/);
|
|
172
|
+
if (at > 0)
|
|
173
|
+
return selfEntry.slice(0, at);
|
|
174
|
+
return findWorkspaceRoot(dirname(selfEntry)) ?? dirname(selfEntry);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The env preamble for a shim re-invoking `selfEntry` — {@link buildNativeEnvPreamble}
|
|
178
|
+
* with the inputs `gjsify install -g` gives it: the prefix to glob at launch, and
|
|
179
|
+
* the prebuild dirs the walk finds above it (a hoisted ancestor's `node_modules`).
|
|
180
|
+
*/
|
|
181
|
+
export function selfShimPreamble(selfEntry) {
|
|
182
|
+
let baked = [];
|
|
183
|
+
try {
|
|
184
|
+
baked = detectNativePackages(dirname(selfEntry)).map((p) => p.prebuildsDir);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// The walk is filesystem I/O (EACCES on an unreadable node_modules). The
|
|
188
|
+
// launch-time glob of the scan root still runs; only ancestors go unseen.
|
|
189
|
+
}
|
|
190
|
+
return buildNativeEnvPreamble(selfShimScanRoot(selfEntry), baked);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The POSIX `sh` body of the self-shim: re-invoke `target` under `interpreter`.
|
|
194
|
+
*
|
|
195
|
+
* On darwin it RE-EXPORTS the running CLI's `DYLD_*` values first. SIP strips every
|
|
196
|
+
* `DYLD_*` variable when a protected binary is exec'd, and `/bin/sh` — this shim —
|
|
197
|
+
* is one, so the child `gjs` otherwise starts without the loader path the install
|
|
198
|
+
* launcher gave the parent (see `buildNativeEnvPreamble`). `GI_TYPELIB_PATH` is not a
|
|
199
|
+
* `DYLD_*` name and survives, so the child finds a prebuild's typelib and then fails
|
|
200
|
+
* the bare-leaf `dlopen` it names: `gjsify workspace <pkg> build` died with
|
|
201
|
+
* `Failed to load shared library 'libgjsifyterminal.dylib'` while the same script run
|
|
202
|
+
* through the tree's own launcher worked.
|
|
203
|
+
*
|
|
204
|
+
* Baking the values is not the snapshot `buildNativeEnvPreamble` warns about: this
|
|
205
|
+
* shim lives in a temp dir for ONE process tree and carries exactly the environment
|
|
206
|
+
* that tree's root was started with.
|
|
207
|
+
*/
|
|
208
|
+
export function buildSelfShimScript(opts) {
|
|
209
|
+
const argv = opts.interpreterArgs.length > 0 ? `${opts.interpreterArgs.join(' ')} ` : '';
|
|
210
|
+
const preamble = dyldCarryPreamble(opts.platform ?? process.platform, opts.env ?? process.env);
|
|
211
|
+
return `#!/bin/sh\n${preamble}exec "${opts.interpreter}" ${argv}"${opts.target}" "$@"\n`;
|
|
212
|
+
}
|
|
213
|
+
/** `export` lines restoring each set `DYLD_*` variable on darwin; `''` elsewhere. */
|
|
214
|
+
export function dyldCarryPreamble(platform, env) {
|
|
215
|
+
if (platform !== 'darwin')
|
|
216
|
+
return '';
|
|
217
|
+
return Object.keys(env)
|
|
218
|
+
.filter((name) => /^DYLD_[A-Z_]+$/.test(name) && env[name])
|
|
219
|
+
.sort()
|
|
220
|
+
.map((name) => `${name}=${shQuoteArg(env[name])}\nexport ${name}\n`)
|
|
221
|
+
.join('');
|
|
137
222
|
}
|
|
138
223
|
/** Deliberately NOT the shim dir itself — see {@link nodeShimDir}. */
|
|
139
224
|
const NODE_SHIM_SUBDIR = 'node-shim';
|
|
@@ -183,7 +268,7 @@ export function nodeShimDir() {
|
|
|
183
268
|
* resolves NOWHERE, and it is reachable only from a package script's PATH (see
|
|
184
269
|
* {@link nodeShimDir}).
|
|
185
270
|
*/
|
|
186
|
-
function writeNodeShim(dir, gjs, selfEntry) {
|
|
271
|
+
function writeNodeShim(dir, gjs, selfEntry, envPreamble) {
|
|
187
272
|
if (!gjs)
|
|
188
273
|
return;
|
|
189
274
|
const sub = join(dir, NODE_SHIM_SUBDIR);
|
|
@@ -199,11 +284,15 @@ function writeNodeShim(dir, gjs, selfEntry) {
|
|
|
199
284
|
// `--node-script` can honour, and yargs would take `--test` FOR the script
|
|
200
285
|
// path (`unknown-options-as-args` turns an unknown flag into a positional).
|
|
201
286
|
writeFileSync(shim, '#!/bin/sh\n' +
|
|
287
|
+
// Same SIP hop as the self-shim — see `buildSelfShimScript`.
|
|
288
|
+
dyldCarryPreamble(process.platform, process.env) +
|
|
202
289
|
'case "$1" in\n' +
|
|
203
290
|
' -*) echo "gjsify: this host has no node; the gjsify shim runs a SCRIPT FILE only" >&2\n' +
|
|
204
291
|
' echo "gjsify: got: node $*" >&2\n' +
|
|
205
292
|
' exit 127 ;;\n' +
|
|
206
293
|
'esac\n' +
|
|
294
|
+
// The same hop as the `gjsify` shim, so the same preamble.
|
|
295
|
+
envPreamble +
|
|
207
296
|
`exec "${interpreter}" -m "${selfEntry}" run --node-script "$@"\n`, { mode: 0o755 });
|
|
208
297
|
chmodSync(shim, 0o755);
|
|
209
298
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** One line of the heartbeat file. */
|
|
2
|
+
export interface Heartbeat {
|
|
3
|
+
/** Epoch ms the in-flight work is due by; `0` means the harness set no deadline. */
|
|
4
|
+
deadlineMs: number;
|
|
5
|
+
/** `<suite> › <test>`, or `<test run>` between tests. */
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
/** Grace on top of the harness's own deadline before a late test is called a hung one. */
|
|
9
|
+
export declare const DEFAULT_GRACE_MS = 30000;
|
|
10
|
+
/** How often the file is read. A hang is a minutes-scale event; a second of latency is free. */
|
|
11
|
+
export declare const DEFAULT_POLL_MS = 1000;
|
|
12
|
+
/**
|
|
13
|
+
* Grace on top of the harness's own deadline, read from `GJSIFY_HANG_GRACE_MS`. `0` turns the
|
|
14
|
+
* watchdog off — the escape hatch for a debugger parked on a breakpoint, which looks exactly
|
|
15
|
+
* like a hang from out here.
|
|
16
|
+
*
|
|
17
|
+
* A BLANK value is the DEFAULT, not `0`. `Number('')` is `0`, so a matrix leg that sets the
|
|
18
|
+
* variable to an empty string (the shape every `${{ }}` expansion takes when its input is
|
|
19
|
+
* missing) would have switched the guard off without anyone writing a zero — a guard silently
|
|
20
|
+
* disarmed by an empty string is the failure mode this whole PR exists to stop.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hangGraceMs(env: Record<string, string | undefined>): number;
|
|
23
|
+
/**
|
|
24
|
+
* The child's environment with `GJSIFY_UNIT_HEARTBEAT` pointing at `path` — or REMOVED when
|
|
25
|
+
* there is none.
|
|
26
|
+
*
|
|
27
|
+
* Removing it is the load-bearing half. The variable is INHERITED, so a spawn that mints no
|
|
28
|
+
* file of its own (`GJSIFY_HANG_GRACE_MS=0`, or a `/tmp` that refused) used to hand the child
|
|
29
|
+
* an OUTER run's heartbeat path. The inner run then wrote its own per-test deadlines into the
|
|
30
|
+
* outer run's file, and the outer watchdog judged them against the outer child's pid: a short
|
|
31
|
+
* inner deadline kills a healthy outer child, and the inner run's closing `0\t<run finished>`
|
|
32
|
+
* disarms the outer guard outright. Measured before the fix — with the variable preset and the
|
|
33
|
+
* grace at 0, the child wrote `0\t<run finished>` into the inherited path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function heartbeatEnv(base: Record<string, string | undefined>, path: string | undefined): Record<string, string | undefined>;
|
|
36
|
+
/**
|
|
37
|
+
* Parse one heartbeat line. Returns `null` for anything that is not one — an absent file, a
|
|
38
|
+
* half-written line, a bundle that is not a test run — because every one of those means "no
|
|
39
|
+
* claim was made", and a watchdog that guesses when nobody claimed anything is a watchdog that
|
|
40
|
+
* kills healthy processes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseHeartbeat(text: string | null | undefined): Heartbeat | null;
|
|
43
|
+
/**
|
|
44
|
+
* The message a CI reader gets instead of 86 minutes of silence. Names the test, says why the
|
|
45
|
+
* run could not say so itself, and carries whatever the backtrace probe found — the native
|
|
46
|
+
* frame is the part that points at the actual wedge (a `gst_pad_*` call, a `pthread_cond_wait`)
|
|
47
|
+
* and is the one thing no amount of re-running recovers once the process is gone.
|
|
48
|
+
*/
|
|
49
|
+
export declare function formatHangReport(input: {
|
|
50
|
+
label: string;
|
|
51
|
+
overdueMs: number;
|
|
52
|
+
pid: number;
|
|
53
|
+
graceMs?: number;
|
|
54
|
+
backtrace?: string;
|
|
55
|
+
}): string;
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort native backtrace of a wedged process, capped at `maxLines`.
|
|
58
|
+
*
|
|
59
|
+
* `eu-stack` (elfutils) needs no debuginfo to name the frames. It is NOT in the base image of
|
|
60
|
+
* every runner — `.docker/ci-fedora.Dockerfile` installs it on purpose, and macOS and Windows
|
|
61
|
+
* runners have no such tool at all, so this returning `undefined` is a normal outcome. On
|
|
62
|
+
* the measured webrtc shape it prints the answer outright — `g_main_loop_run` under
|
|
63
|
+
* `ffi_call`, i.e. a GI call that entered a loop it never left — which is what no amount of
|
|
64
|
+
* re-running recovers once the process is gone.
|
|
65
|
+
*
|
|
66
|
+
* WHATEVER it produced is kept, even on a non-zero exit: unwinding a live process is partial
|
|
67
|
+
* by nature (a thread that moves mid-walk fails the walk), and the frames it did get are
|
|
68
|
+
* exactly the ones worth having. Absent, unpermitted (`ptrace_scope`) or slow, this returns
|
|
69
|
+
* `undefined` and the report is printed without it — diagnostics must never be the reason a
|
|
70
|
+
* hang report fails to appear, and 5 s is the most this may add to a report someone is waiting
|
|
71
|
+
* for.
|
|
72
|
+
*/
|
|
73
|
+
export declare function captureNativeBacktrace(pid: number, maxLines?: number): string | undefined;
|
|
74
|
+
export interface HangWatchdogDeps {
|
|
75
|
+
/** Read the heartbeat file; `null` when it is absent or unreadable. */
|
|
76
|
+
read: () => string | null;
|
|
77
|
+
/** Injected for the spec, which drives a fake clock rather than waiting 30 s. */
|
|
78
|
+
now?: () => number;
|
|
79
|
+
graceMs?: number;
|
|
80
|
+
/** Called once, with the in-flight breadcrumb and how far past its deadline it is. */
|
|
81
|
+
onHang: (heartbeat: Heartbeat, overdueMs: number) => void;
|
|
82
|
+
}
|
|
83
|
+
export interface HangWatchdog {
|
|
84
|
+
/** One poll. Exposed so the spec needs neither real timers nor real files. */
|
|
85
|
+
tick: () => void;
|
|
86
|
+
start: (pollMs?: number) => void;
|
|
87
|
+
stop: () => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Poll the heartbeat and fire `onHang` at most once.
|
|
91
|
+
*
|
|
92
|
+
* Latched deliberately: the child is still wedged on the next tick, and a second report would
|
|
93
|
+
* bury the first under repeats of itself.
|
|
94
|
+
*/
|
|
95
|
+
export declare function createHangWatchdog(deps: HangWatchdogDeps): HangWatchdog;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The parent half of `@gjsify/unit`'s hang breadcrumb: a deadline enforced from OUTSIDE the
|
|
2
|
+
// process that missed it.
|
|
3
|
+
//
|
|
4
|
+
// A GJS test bundle that blocks its own main loop — a spin, a nested `GLib.MainLoop.run()`, a
|
|
5
|
+
// blocking GI call into GStreamer — cannot fail itself. `@gjsify/unit`'s per-test, per-suite
|
|
6
|
+
// and per-run timeouts are all `setTimeout` races, and both halves of a race need the loop, so
|
|
7
|
+
// the run simply goes quiet and keeps the job's stdio open until CI's own cap kills it: run
|
|
8
|
+
// 34945600666 burned 90 minutes that way and arrived as an unexplained red on an unrelated PR.
|
|
9
|
+
// The full measurement is in `packages/gjs/unit/src/heartbeat.ts`.
|
|
10
|
+
//
|
|
11
|
+
// So the child writes `<deadline-epoch-ms>\t<label>` before each test, and this polls that
|
|
12
|
+
// file. Past the deadline plus a grace margin this names the test, grabs a native backtrace
|
|
13
|
+
// while the wedge is still standing, and kills the child.
|
|
14
|
+
//
|
|
15
|
+
// The grace is a POLICY, not an inference from the harness, and the difference is measured. A
|
|
16
|
+
// body that blocks the loop past its own timeout and then RETURNS passes today: `withTimeout`
|
|
17
|
+
// arms its timer before calling `fn()`, but the expired timer is cleared in `finally` before
|
|
18
|
+
// the loop ever turns again, so the already-resolved body wins the race. A 12 s synchronous
|
|
19
|
+
// body under a 5 s timeout exits 0 on `main`; with a 3 s grace this guard kills it. The grace
|
|
20
|
+
// is therefore "how long a body may legitimately block the loop beyond its own budget" — at
|
|
21
|
+
// the 30 s default, 7x the 5 s test timeout — which is why the report names
|
|
22
|
+
// `GJSIFY_HANG_GRACE_MS`: a false positive has to be fixable by whoever hits it.
|
|
23
|
+
//
|
|
24
|
+
// Inert by construction: a bundle that is not an `@gjsify/unit` run writes no heartbeat, so
|
|
25
|
+
// `parseHeartbeat` returns `null` forever and a GUI launched through `gjsify run` is never
|
|
26
|
+
// touched. That is why this needs no flag to be safe.
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
/** Grace on top of the harness's own deadline before a late test is called a hung one. */
|
|
29
|
+
export const DEFAULT_GRACE_MS = 30_000;
|
|
30
|
+
/** How often the file is read. A hang is a minutes-scale event; a second of latency is free. */
|
|
31
|
+
export const DEFAULT_POLL_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* Grace on top of the harness's own deadline, read from `GJSIFY_HANG_GRACE_MS`. `0` turns the
|
|
34
|
+
* watchdog off — the escape hatch for a debugger parked on a breakpoint, which looks exactly
|
|
35
|
+
* like a hang from out here.
|
|
36
|
+
*
|
|
37
|
+
* A BLANK value is the DEFAULT, not `0`. `Number('')` is `0`, so a matrix leg that sets the
|
|
38
|
+
* variable to an empty string (the shape every `${{ }}` expansion takes when its input is
|
|
39
|
+
* missing) would have switched the guard off without anyone writing a zero — a guard silently
|
|
40
|
+
* disarmed by an empty string is the failure mode this whole PR exists to stop.
|
|
41
|
+
*/
|
|
42
|
+
export function hangGraceMs(env) {
|
|
43
|
+
const raw = env.GJSIFY_HANG_GRACE_MS;
|
|
44
|
+
if (raw === undefined || raw.trim() === '')
|
|
45
|
+
return DEFAULT_GRACE_MS;
|
|
46
|
+
const n = Number(raw);
|
|
47
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_GRACE_MS;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The child's environment with `GJSIFY_UNIT_HEARTBEAT` pointing at `path` — or REMOVED when
|
|
51
|
+
* there is none.
|
|
52
|
+
*
|
|
53
|
+
* Removing it is the load-bearing half. The variable is INHERITED, so a spawn that mints no
|
|
54
|
+
* file of its own (`GJSIFY_HANG_GRACE_MS=0`, or a `/tmp` that refused) used to hand the child
|
|
55
|
+
* an OUTER run's heartbeat path. The inner run then wrote its own per-test deadlines into the
|
|
56
|
+
* outer run's file, and the outer watchdog judged them against the outer child's pid: a short
|
|
57
|
+
* inner deadline kills a healthy outer child, and the inner run's closing `0\t<run finished>`
|
|
58
|
+
* disarms the outer guard outright. Measured before the fix — with the variable preset and the
|
|
59
|
+
* grace at 0, the child wrote `0\t<run finished>` into the inherited path.
|
|
60
|
+
*/
|
|
61
|
+
export function heartbeatEnv(base, path) {
|
|
62
|
+
const env = { ...base };
|
|
63
|
+
if (path)
|
|
64
|
+
env.GJSIFY_UNIT_HEARTBEAT = path;
|
|
65
|
+
else
|
|
66
|
+
delete env.GJSIFY_UNIT_HEARTBEAT;
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse one heartbeat line. Returns `null` for anything that is not one — an absent file, a
|
|
71
|
+
* half-written line, a bundle that is not a test run — because every one of those means "no
|
|
72
|
+
* claim was made", and a watchdog that guesses when nobody claimed anything is a watchdog that
|
|
73
|
+
* kills healthy processes.
|
|
74
|
+
*/
|
|
75
|
+
export function parseHeartbeat(text) {
|
|
76
|
+
if (!text)
|
|
77
|
+
return null;
|
|
78
|
+
const line = text.split('\n', 1)[0] ?? '';
|
|
79
|
+
const tab = line.indexOf('\t');
|
|
80
|
+
if (tab < 0)
|
|
81
|
+
return null;
|
|
82
|
+
const deadlineMs = Number(line.slice(0, tab));
|
|
83
|
+
if (!Number.isFinite(deadlineMs) || deadlineMs < 0)
|
|
84
|
+
return null;
|
|
85
|
+
const label = line.slice(tab + 1).trim();
|
|
86
|
+
if (label.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
return { deadlineMs, label };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The message a CI reader gets instead of 86 minutes of silence. Names the test, says why the
|
|
92
|
+
* run could not say so itself, and carries whatever the backtrace probe found — the native
|
|
93
|
+
* frame is the part that points at the actual wedge (a `gst_pad_*` call, a `pthread_cond_wait`)
|
|
94
|
+
* and is the one thing no amount of re-running recovers once the process is gone.
|
|
95
|
+
*/
|
|
96
|
+
export function formatHangReport(input) {
|
|
97
|
+
const lines = [
|
|
98
|
+
`⏱ hang: "${input.label}" is ${Math.round(input.overdueMs / 1000)}s past its own deadline`,
|
|
99
|
+
` gjs pid ${input.pid} stopped turning its main loop, so @gjsify/unit's timeout cannot fire`,
|
|
100
|
+
` (see packages/gjs/unit/src/heartbeat.ts). Killing it rather than holding the job open.`,
|
|
101
|
+
];
|
|
102
|
+
if (input.backtrace) {
|
|
103
|
+
lines.push(' native backtrace at the moment of the hang:');
|
|
104
|
+
for (const l of input.backtrace.split('\n'))
|
|
105
|
+
lines.push(` ${l}`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Said out loud. Silence here reads as "the guard had nothing to add", when what it
|
|
109
|
+
// means is that the one artefact a dead process cannot be asked for again was missed.
|
|
110
|
+
lines.push(' no native backtrace: eu-stack (elfutils) absent, refused the attach, or found nothing');
|
|
111
|
+
}
|
|
112
|
+
if (input.graceMs !== undefined) {
|
|
113
|
+
// The knob travels WITH the accusation: whoever reads this is the one person who can
|
|
114
|
+
// tell a wedge from a body that legitimately blocks the loop this long.
|
|
115
|
+
lines.push(` a test that legitimately blocks the loop this long: raise GJSIFY_HANG_GRACE_MS`);
|
|
116
|
+
lines.push(` (now ${input.graceMs} ms, 0 disables this guard).`);
|
|
117
|
+
}
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Best-effort native backtrace of a wedged process, capped at `maxLines`.
|
|
122
|
+
*
|
|
123
|
+
* `eu-stack` (elfutils) needs no debuginfo to name the frames. It is NOT in the base image of
|
|
124
|
+
* every runner — `.docker/ci-fedora.Dockerfile` installs it on purpose, and macOS and Windows
|
|
125
|
+
* runners have no such tool at all, so this returning `undefined` is a normal outcome. On
|
|
126
|
+
* the measured webrtc shape it prints the answer outright — `g_main_loop_run` under
|
|
127
|
+
* `ffi_call`, i.e. a GI call that entered a loop it never left — which is what no amount of
|
|
128
|
+
* re-running recovers once the process is gone.
|
|
129
|
+
*
|
|
130
|
+
* WHATEVER it produced is kept, even on a non-zero exit: unwinding a live process is partial
|
|
131
|
+
* by nature (a thread that moves mid-walk fails the walk), and the frames it did get are
|
|
132
|
+
* exactly the ones worth having. Absent, unpermitted (`ptrace_scope`) or slow, this returns
|
|
133
|
+
* `undefined` and the report is printed without it — diagnostics must never be the reason a
|
|
134
|
+
* hang report fails to appear, and 5 s is the most this may add to a report someone is waiting
|
|
135
|
+
* for.
|
|
136
|
+
*/
|
|
137
|
+
export function captureNativeBacktrace(pid, maxLines = 40) {
|
|
138
|
+
try {
|
|
139
|
+
const r = spawnSync('eu-stack', ['-p', String(pid)], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
timeout: 5_000,
|
|
142
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
143
|
+
});
|
|
144
|
+
const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
|
|
145
|
+
if (out.length === 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
return out.split('\n').slice(0, maxLines).join('\n');
|
|
148
|
+
}
|
|
149
|
+
catch (_e) {
|
|
150
|
+
/* no eu-stack, or no permission to attach — the report stands without it */
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Poll the heartbeat and fire `onHang` at most once.
|
|
156
|
+
*
|
|
157
|
+
* Latched deliberately: the child is still wedged on the next tick, and a second report would
|
|
158
|
+
* bury the first under repeats of itself.
|
|
159
|
+
*/
|
|
160
|
+
export function createHangWatchdog(deps) {
|
|
161
|
+
const now = deps.now ?? Date.now;
|
|
162
|
+
const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS;
|
|
163
|
+
let timer;
|
|
164
|
+
let fired = false;
|
|
165
|
+
const tick = () => {
|
|
166
|
+
if (fired)
|
|
167
|
+
return;
|
|
168
|
+
const hb = parseHeartbeat(deps.read());
|
|
169
|
+
// `deadlineMs === 0` is the harness saying "no deadline here" (`{ timeout: 0 }`, or
|
|
170
|
+
// the window after the run finished). Enforcing one it declined to set is exactly the
|
|
171
|
+
// false positive that would make this guard get turned off.
|
|
172
|
+
if (!hb || hb.deadlineMs <= 0)
|
|
173
|
+
return;
|
|
174
|
+
const overdueMs = now() - (hb.deadlineMs + graceMs);
|
|
175
|
+
if (overdueMs < 0)
|
|
176
|
+
return;
|
|
177
|
+
fired = true;
|
|
178
|
+
stop();
|
|
179
|
+
deps.onHang(hb, now() - hb.deadlineMs);
|
|
180
|
+
};
|
|
181
|
+
const stop = () => {
|
|
182
|
+
if (timer !== undefined)
|
|
183
|
+
clearInterval(timer);
|
|
184
|
+
timer = undefined;
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
tick,
|
|
188
|
+
start: (pollMs = DEFAULT_POLL_MS) => {
|
|
189
|
+
if (timer === undefined)
|
|
190
|
+
timer = setInterval(tick, pollMs);
|
|
191
|
+
},
|
|
192
|
+
stop,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -22,6 +22,12 @@ export interface ResolvedNode {
|
|
|
22
22
|
/** Ranges keyed by name, as published. */
|
|
23
23
|
dependencies: Record<string, string>;
|
|
24
24
|
optionalDependencies: Record<string, string>;
|
|
25
|
+
/** As published; absent when the version declares none. Read through {@link requiredPeerEntries}. */
|
|
26
|
+
peerDependencies?: Record<string, string>;
|
|
27
|
+
/** As published — `optional: true` here is what keeps a peer from being installed. */
|
|
28
|
+
peerDependenciesMeta?: Record<string, {
|
|
29
|
+
optional?: boolean;
|
|
30
|
+
}>;
|
|
25
31
|
bin?: string | Record<string, string>;
|
|
26
32
|
/**
|
|
27
33
|
* The version's `os`/`cpu`/`libc` declaration, as published. Recorded here
|
|
@@ -88,6 +94,20 @@ export interface NativeInstallOptions extends InstallOptions {
|
|
|
88
94
|
* required, the pre-platform-filter behaviour.
|
|
89
95
|
*/
|
|
90
96
|
optionalSpecs?: Set<string>;
|
|
97
|
+
/**
|
|
98
|
+
* Names provided by a `gjsify link` development override (`utils/dev-link.ts`):
|
|
99
|
+
* `node_modules/<name>` is a symlink into a local checkout, so the registry copy
|
|
100
|
+
* must not be fetched over it — `assertNodeModulesDest` refuses that extract and
|
|
101
|
+
* aborts the whole install, correctly, since it would delete the checkout.
|
|
102
|
+
*
|
|
103
|
+
* DELIBERATELY NOT `workspaceNames`, the set it is otherwise shaped like. That
|
|
104
|
+
* one also reaches `resolveDeps` and removes the subtree from the RESOLVE — and
|
|
105
|
+
* the resolve is what writes `gjsify-lock.json`. A link is a local development
|
|
106
|
+
* decision that must leave the consumer's committed lockfile byte-identical, so
|
|
107
|
+
* these names are dropped only from what gets DOWNLOADED, after the lockfile has
|
|
108
|
+
* been written from the full resolved tree.
|
|
109
|
+
*/
|
|
110
|
+
linkedNames?: Set<string>;
|
|
91
111
|
}
|
|
92
112
|
export declare function installPackagesNative(opts: NativeInstallOptions): Promise<InstalledTopLevel[]>;
|
|
93
113
|
/**
|
|
@@ -142,6 +162,17 @@ export declare function warnMissingNativeBuilds(nodes: ResolvedNode[], prefix: s
|
|
|
142
162
|
* `dependencies` entry to override), covered by {@link resolveDeps}'s second queue loop.
|
|
143
163
|
*/
|
|
144
164
|
export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies'>): [string, string][];
|
|
165
|
+
/**
|
|
166
|
+
* A node's REQUIRED peer edges: its `peerDependencies` minus those
|
|
167
|
+
* `peerDependenciesMeta` marks `optional` (npm ≥ 7 installs the rest and skips
|
|
168
|
+
* these) and minus any name it also lists as a dependency — that edge is walked
|
|
169
|
+
* from the node itself by {@link requiredDepEntries}, or is optional because the
|
|
170
|
+
* publisher said so in `optionalDependencies`.
|
|
171
|
+
*
|
|
172
|
+
* Shared by the resolve walk and the optionality fixpoint, for the reason
|
|
173
|
+
* {@link requiredDepEntries} is: two readings of one manifest are two answers.
|
|
174
|
+
*/
|
|
175
|
+
export declare function requiredPeerEntries(node: Pick<ResolvedNode, 'dependencies' | 'optionalDependencies' | 'peerDependencies' | 'peerDependenciesMeta'>): [string, string][];
|
|
145
176
|
/**
|
|
146
177
|
* Recompute every node's `optional` flag as a FIXPOINT over the placed graph. The
|
|
147
178
|
* only writer of the final flag; whatever the nodes arrive carrying is an input
|
|
@@ -149,7 +180,8 @@ export declare function requiredDepEntries(node: Pick<ResolvedNode, 'dependencie
|
|
|
149
180
|
* file on the lockfile path) and is overwritten unconditionally.
|
|
150
181
|
*
|
|
151
182
|
* DEFINITION: a node is REQUIRED iff reachable from `requiredNames` through
|
|
152
|
-
* REQUIRED edges alone ({@link requiredDepEntries}
|
|
183
|
+
* REQUIRED edges alone ({@link requiredDepEntries} plus {@link requiredPeerEntries} —
|
|
184
|
+
* a peer npm installs is as mandatory as a dependency); every other node is optional.
|
|
153
185
|
* Optionality is therefore INHERITED — a plain dependency OF an optional package is
|
|
154
186
|
* still optional, which is what npm's `optionalSet` computes.
|
|
155
187
|
*
|