nx 23.2.0-rc.2 → 23.2.1
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/release/changelog-renderer/index.d.ts +4 -0
- package/dist/release/changelog-renderer/index.js +17 -1
- package/dist/src/command-line/migrate/prompt-files.js +2 -9
- package/dist/src/command-line/release/changelog.js +3 -1
- package/dist/src/command-line/report/report.d.ts +2 -0
- package/dist/src/command-line/report/report.js +28 -1
- package/dist/src/command-line/reset/reset.js +1 -3
- package/dist/src/core/graph/main.js +1 -1
- package/dist/src/daemon/client/daemon-environment.d.ts +10 -0
- package/dist/src/daemon/client/daemon-environment.js +44 -0
- package/dist/src/daemon/server/handle-client-env.js +24 -3
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/nx-cloud/update-manager.js +4 -3
- package/dist/src/plugins/js/lock-file/pruned-output.d.ts +10 -9
- package/dist/src/plugins/js/lock-file/pruned-output.js +37 -13
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.d.ts +16 -0
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +59 -22
- package/dist/src/tasks-runner/forked-process-task-runner.d.ts +3 -0
- package/dist/src/tasks-runner/forked-process-task-runner.js +15 -6
- package/dist/src/tasks-runner/init-tasks-runner.js +32 -2
- package/dist/src/tasks-runner/life-cycles/task-history-life-cycle.js +16 -11
- package/dist/src/tasks-runner/running-tasks/batch-process.js +3 -0
- package/dist/src/tasks-runner/running-tasks/shared-running-task.d.ts +1 -0
- package/dist/src/tasks-runner/running-tasks/shared-running-task.js +12 -1
- package/dist/src/tasks-runner/task-orchestrator.d.ts +3 -0
- package/dist/src/tasks-runner/task-orchestrator.js +31 -3
- package/dist/src/utils/min-release-age/behavior/pnpm.js +17 -9
- package/dist/src/utils/package-json.js +13 -2
- package/dist/src/utils/params.js +1 -1
- package/dist/src/utils/path.d.ts +6 -0
- package/dist/src/utils/path.js +15 -0
- package/package.json +11 -11
|
@@ -62,3 +62,13 @@ export declare function getAppliedDaemonClientEnv(): {
|
|
|
62
62
|
* previous client is stale all the same.
|
|
63
63
|
*/
|
|
64
64
|
export declare function applyDaemonEnvFromClient(newEnv: NodeJS.ProcessEnv): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Yarn Berry creates a fresh BERRY_BIN_FOLDER for every invocation and puts it
|
|
67
|
+
* first on PATH. The wrappers inside are runtime state the daemon and workers
|
|
68
|
+
* must receive, but the folder's random name is not project-graph identity:
|
|
69
|
+
* two invocations whose environments differ only by it would compute the same
|
|
70
|
+
* graph. Strips the folder from PATH and pins the var to a sentinel so such
|
|
71
|
+
* environments compare equal. Returns a copy; never mutates runtime env.
|
|
72
|
+
*/
|
|
73
|
+
export declare function normalizeDaemonEnvironmentForGraph(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
74
|
+
export declare function getChangedEnvKeys(before: NodeJS.ProcessEnv, after: NodeJS.ProcessEnv): string[];
|
|
@@ -6,6 +6,8 @@ exports.getDaemonClientEnvGeneration = getDaemonClientEnvGeneration;
|
|
|
6
6
|
exports.getDaemonSpawnEnv = getDaemonSpawnEnv;
|
|
7
7
|
exports.getAppliedDaemonClientEnv = getAppliedDaemonClientEnv;
|
|
8
8
|
exports.applyDaemonEnvFromClient = applyDaemonEnvFromClient;
|
|
9
|
+
exports.normalizeDaemonEnvironmentForGraph = normalizeDaemonEnvironmentForGraph;
|
|
10
|
+
exports.getChangedEnvKeys = getChangedEnvKeys;
|
|
9
11
|
const file_hasher_1 = require("../../hasher/file-hasher");
|
|
10
12
|
const DAEMON_ENV_REQUIRED_SETTINGS = {
|
|
11
13
|
NX_PROJECT_GLOB_CACHE: 'false',
|
|
@@ -376,3 +378,45 @@ function applyDaemonEnvFromClient(newEnv) {
|
|
|
376
378
|
appliedClientEnv = { ...newEnv };
|
|
377
379
|
return [...changedKeys];
|
|
378
380
|
}
|
|
381
|
+
const BERRY_BIN_FOLDER_GRAPH_IDENTITY = '<YARN_BERRY_BIN_FOLDER>';
|
|
382
|
+
/**
|
|
383
|
+
* Yarn Berry creates a fresh BERRY_BIN_FOLDER for every invocation and puts it
|
|
384
|
+
* first on PATH. The wrappers inside are runtime state the daemon and workers
|
|
385
|
+
* must receive, but the folder's random name is not project-graph identity:
|
|
386
|
+
* two invocations whose environments differ only by it would compute the same
|
|
387
|
+
* graph. Strips the folder from PATH and pins the var to a sentinel so such
|
|
388
|
+
* environments compare equal. Returns a copy; never mutates runtime env.
|
|
389
|
+
*/
|
|
390
|
+
function normalizeDaemonEnvironmentForGraph(env, platform = process.platform) {
|
|
391
|
+
const normalized = { ...env };
|
|
392
|
+
const berryBinFolder = normalized.BERRY_BIN_FOLDER;
|
|
393
|
+
if (!berryBinFolder) {
|
|
394
|
+
return normalized;
|
|
395
|
+
}
|
|
396
|
+
const pathKey = Object.keys(normalized).find((key) => key.toUpperCase() === 'PATH');
|
|
397
|
+
if (pathKey && normalized[pathKey]) {
|
|
398
|
+
const pathDelimiter = platform === 'win32' ? ';' : ':';
|
|
399
|
+
const canonicalize = (value) => {
|
|
400
|
+
const withPlatformSeparators = platform === 'win32' ? value.replaceAll('/', '\\') : value;
|
|
401
|
+
return platform === 'win32'
|
|
402
|
+
? withPlatformSeparators.toLowerCase()
|
|
403
|
+
: withPlatformSeparators;
|
|
404
|
+
};
|
|
405
|
+
const canonicalBerryBinFolder = canonicalize(berryBinFolder);
|
|
406
|
+
normalized[pathKey] = normalized[pathKey]
|
|
407
|
+
.split(pathDelimiter)
|
|
408
|
+
.filter((entry) => canonicalize(entry) !== canonicalBerryBinFolder)
|
|
409
|
+
.join(pathDelimiter);
|
|
410
|
+
}
|
|
411
|
+
normalized.BERRY_BIN_FOLDER = BERRY_BIN_FOLDER_GRAPH_IDENTITY;
|
|
412
|
+
return normalized;
|
|
413
|
+
}
|
|
414
|
+
function getChangedEnvKeys(before, after) {
|
|
415
|
+
const changedKeys = [];
|
|
416
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
417
|
+
if (before[key] !== after[key]) {
|
|
418
|
+
changedKeys.push(key);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return changedKeys;
|
|
422
|
+
}
|
|
@@ -29,12 +29,30 @@ async function handleClientEnv(env) {
|
|
|
29
29
|
while (inFlightApply) {
|
|
30
30
|
await inFlightApply;
|
|
31
31
|
}
|
|
32
|
+
const graphEnvBefore = (0, daemon_environment_1.normalizeDaemonEnvironmentForGraph)(process.env);
|
|
33
|
+
const previousClientEnv = (0, daemon_environment_1.getAppliedDaemonClientEnv)()?.env;
|
|
32
34
|
const changedEnvKeys = (0, daemon_environment_1.applyDaemonEnvFromClient)(env);
|
|
33
35
|
if (changedEnvKeys.length === 0) {
|
|
34
36
|
return;
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Runtime changes always reach the workers, but only changes that survive
|
|
39
|
+
// graph normalization (e.g. Yarn Berry's per-invocation BERRY_BIN_FOLDER
|
|
40
|
+
// does not) invalidate the computed graph.
|
|
41
|
+
const graphChangedKeys = new Set([
|
|
42
|
+
...(0, daemon_environment_1.getChangedEnvKeys)(graphEnvBefore, (0, daemon_environment_1.normalizeDaemonEnvironmentForGraph)(process.env)),
|
|
43
|
+
...(previousClientEnv
|
|
44
|
+
? (0, daemon_environment_1.getChangedEnvKeys)((0, daemon_environment_1.normalizeDaemonEnvironmentForGraph)(previousClientEnv), (0, daemon_environment_1.normalizeDaemonEnvironmentForGraph)(env))
|
|
45
|
+
: []),
|
|
46
|
+
]);
|
|
47
|
+
if (graphChangedKeys.size > 0) {
|
|
48
|
+
logger_1.serverLogger.log(`Graph recompute necessary due to env variable refresh. Changed keys: ${[
|
|
49
|
+
...graphChangedKeys,
|
|
50
|
+
].join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
logger_1.serverLogger.log(`Env variable refresh changed only runtime values (${changedEnvKeys.join(', ')}); graph identity is unchanged, keeping the cached graph.`);
|
|
54
|
+
}
|
|
55
|
+
const apply = applyEnvChange(env, graphChangedKeys.size > 0);
|
|
38
56
|
inFlightApply = apply;
|
|
39
57
|
try {
|
|
40
58
|
await apply;
|
|
@@ -45,8 +63,11 @@ async function handleClientEnv(env) {
|
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
}
|
|
48
|
-
async function applyEnvChange(env) {
|
|
66
|
+
async function applyEnvChange(env, invalidateGraph) {
|
|
49
67
|
await forwardEnvToPluginWorkers(env);
|
|
68
|
+
if (!invalidateGraph) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
50
71
|
// Discarding the cached graph makes the next request recompute under the
|
|
51
72
|
// new env, and chains any in-flight compute (started under the old env) to
|
|
52
73
|
// that successor.
|
|
Binary file
|
|
Binary file
|
|
@@ -12,6 +12,7 @@ const axios_1 = require("./utilities/axios");
|
|
|
12
12
|
const debug_logger_1 = require("./debug-logger");
|
|
13
13
|
const tar = tslib_1.__importStar(require("tar-stream"));
|
|
14
14
|
const cache_directory_1 = require("../utils/cache-directory");
|
|
15
|
+
const is_ci_1 = require("../utils/is-ci");
|
|
15
16
|
const crypto_1 = require("crypto");
|
|
16
17
|
const workspace_root_1 = require("../utils/workspace-root");
|
|
17
18
|
class NxCloudEnterpriseOutdatedError extends Error {
|
|
@@ -106,9 +107,9 @@ function getBundleInstallDefaultLocation() {
|
|
|
106
107
|
if ((0, fs_1.existsSync)(legacyPath)) {
|
|
107
108
|
return legacyPath;
|
|
108
109
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// The bundle `require`s a bare `nx`, so it must sit where that resolves: on CI
|
|
111
|
+
// the shared per-user root is outside the checkout (NXC-4944).
|
|
112
|
+
return (0, path_1.join)((0, is_ci_1.isCI)() ? (0, cache_directory_1.cacheDirectoryForWorkspace)(workspace_root_1.workspaceRoot) : cache_directory_1.cacheDir, 'cloud');
|
|
112
113
|
}
|
|
113
114
|
const runnerBundleInstallDirectory = getBundleInstallDefaultLocation();
|
|
114
115
|
function getLatestInstalledRunnerBundle() {
|
|
@@ -42,15 +42,16 @@ type PrunedPnpmConfig = {
|
|
|
42
42
|
* file is inert, verified installable with identical module resolution on
|
|
43
43
|
* pnpm 9, 10 and 11.
|
|
44
44
|
*
|
|
45
|
-
* pnpm 11
|
|
46
|
-
* `
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* pnpm <=10,
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
* the pruned
|
|
45
|
+
* On pnpm 11+, this file carries declared `minimumReleaseAge`,
|
|
46
|
+
* `minimumReleaseAgeExclude`, `minimumReleaseAgeStrict`, build approvals,
|
|
47
|
+
* `supportedArchitectures`, and applicable `patchedDependencies` from the
|
|
48
|
+
* workspace root. Approvals and patches are scoped to the pruned lockfile.
|
|
49
|
+
*
|
|
50
|
+
* On pnpm <=10, only build approvals, `supportedArchitectures`, and
|
|
51
|
+
* `patchedDependencies` are emitted in `package.json`.
|
|
52
|
+
*
|
|
53
|
+
* Settings encoded in the pruned resolution, such as `overrides` and
|
|
54
|
+
* `packageExtensions`, are not copied (`stripPrunedLockfilePnpmConfig`).
|
|
54
55
|
*
|
|
55
56
|
* The major is the build machine's pnpm, which is all that is knowable at
|
|
56
57
|
* build time: an output built on pnpm <=10 but deployed on pnpm 11+ will not
|
|
@@ -107,15 +107,16 @@ function dropInheritedPnpmPatchedDependencies(packageJson) {
|
|
|
107
107
|
* file is inert, verified installable with identical module resolution on
|
|
108
108
|
* pnpm 9, 10 and 11.
|
|
109
109
|
*
|
|
110
|
-
* pnpm 11
|
|
111
|
-
* `
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* pnpm <=10,
|
|
116
|
-
* `
|
|
117
|
-
*
|
|
118
|
-
* the pruned
|
|
110
|
+
* On pnpm 11+, this file carries declared `minimumReleaseAge`,
|
|
111
|
+
* `minimumReleaseAgeExclude`, `minimumReleaseAgeStrict`, build approvals,
|
|
112
|
+
* `supportedArchitectures`, and applicable `patchedDependencies` from the
|
|
113
|
+
* workspace root. Approvals and patches are scoped to the pruned lockfile.
|
|
114
|
+
*
|
|
115
|
+
* On pnpm <=10, only build approvals, `supportedArchitectures`, and
|
|
116
|
+
* `patchedDependencies` are emitted in `package.json`.
|
|
117
|
+
*
|
|
118
|
+
* Settings encoded in the pruned resolution, such as `overrides` and
|
|
119
|
+
* `packageExtensions`, are not copied (`stripPrunedLockfilePnpmConfig`).
|
|
119
120
|
*
|
|
120
121
|
* The major is the build machine's pnpm, which is all that is knowable at
|
|
121
122
|
* build time: an output built on pnpm <=10 but deployed on pnpm 11+ will not
|
|
@@ -129,6 +130,29 @@ function getPrunedPnpmInstallSettingsYaml(workspaceRootPath = workspace_root_1.w
|
|
|
129
130
|
// list is accepted by pnpm 9-11 without pulling any importer into the install.
|
|
130
131
|
return dump({ packages: [], ...settings });
|
|
131
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* pnpm-workspace.yaml fields carried verbatim from the workspace root to the
|
|
135
|
+
* pruned output, with no interpretation beyond "declared or not" (unlike
|
|
136
|
+
* `allowBuilds`, filtered to the pruned lockfile, and `patchedDependencies`,
|
|
137
|
+
* resolved against it):
|
|
138
|
+
* - `supportedArchitectures`.
|
|
139
|
+
* - `minimumReleaseAge`/`minimumReleaseAgeExclude`: silently dropping these
|
|
140
|
+
* falls back to pnpm's own default cutoff instead of the workspace's,
|
|
141
|
+
* which can reject lockfile entries the workspace itself allows (or vice
|
|
142
|
+
* versa).
|
|
143
|
+
* - `minimumReleaseAgeStrict`: starting with pnpm 11.0.0, a declared
|
|
144
|
+
* `minimumReleaseAge` enables strict mode unless `minimumReleaseAgeStrict:
|
|
145
|
+
* false` is also declared. Dropping an explicit `false` here would
|
|
146
|
+
* silently re-enable strict mode in the pruned output and make its
|
|
147
|
+
* install reject an immature exact dependency the source workspace
|
|
148
|
+
* accepted.
|
|
149
|
+
*/
|
|
150
|
+
const STRAIGHT_COPY_WORKSPACE_SETTINGS = [
|
|
151
|
+
'supportedArchitectures',
|
|
152
|
+
'minimumReleaseAge',
|
|
153
|
+
'minimumReleaseAgeExclude',
|
|
154
|
+
'minimumReleaseAgeStrict',
|
|
155
|
+
];
|
|
132
156
|
/**
|
|
133
157
|
* The settings `getPrunedPnpmInstallSettingsYaml` emits, before serialization,
|
|
134
158
|
* so a caller can tell which of them the workspace actually declares. Null when
|
|
@@ -155,7 +179,7 @@ function getPrunedPnpmWorkspaceSettings(workspaceRootPath, prunedLockfileContent
|
|
|
155
179
|
}
|
|
156
180
|
catch {
|
|
157
181
|
// Unreadable or malformed pnpm-workspace.yaml: skip rather than guess.
|
|
158
|
-
logger_1.logger.warn('Could not read the workspace root pnpm-workspace.yaml; the pruned output will not declare pnpm install settings (build-script approvals, supportedArchitectures, patchedDependencies).');
|
|
182
|
+
logger_1.logger.warn('Could not read the workspace root pnpm-workspace.yaml; the pruned output will not declare pnpm install settings (build-script approvals, supportedArchitectures, minimumReleaseAge, patchedDependencies).');
|
|
159
183
|
return null;
|
|
160
184
|
}
|
|
161
185
|
const settings = {};
|
|
@@ -167,8 +191,8 @@ function getPrunedPnpmWorkspaceSettings(workspaceRootPath, prunedLockfileContent
|
|
|
167
191
|
settings.allowBuilds = allowBuilds;
|
|
168
192
|
}
|
|
169
193
|
}
|
|
170
|
-
|
|
171
|
-
settings
|
|
194
|
+
for (const field of STRAIGHT_COPY_WORKSPACE_SETTINGS) {
|
|
195
|
+
assignDefined(settings, rootSettings, field);
|
|
172
196
|
}
|
|
173
197
|
const patchedDependencies = precomputed?.patchedDependencies ??
|
|
174
198
|
(prunedLockfileContent
|
|
@@ -216,7 +240,7 @@ function getPnpmMajor(workspaceRootPath) {
|
|
|
216
240
|
function getPnpmMajorOrWarn(workspaceRootPath) {
|
|
217
241
|
const pnpmMajor = getPnpmMajor(workspaceRootPath);
|
|
218
242
|
if (pnpmMajor === null) {
|
|
219
|
-
logger_1.logger.warn('Could not determine the pnpm version. The pruned output will not carry pnpm build-script approvals, supportedArchitectures, or patchedDependencies declarations; patch files still ship.');
|
|
243
|
+
logger_1.logger.warn('Could not determine the pnpm version. The pruned output will not carry pnpm build-script approvals, supportedArchitectures, minimumReleaseAge, or patchedDependencies declarations; patch files still ship.');
|
|
220
244
|
}
|
|
221
245
|
return pnpmMajor;
|
|
222
246
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ChildProcess } from 'child_process';
|
|
2
|
+
import { Socket } from 'net';
|
|
1
3
|
import type { PluginConfiguration } from '../../../config/nx-json';
|
|
2
4
|
import type { ProjectGraph } from '../../../config/project-graph';
|
|
3
5
|
import type { RawProjectGraphDependency } from '../../project-graph-builder';
|
|
@@ -40,6 +42,13 @@ export declare class IsolatedPlugin implements LoadedNxPlugin {
|
|
|
40
42
|
static load(plugin: PluginConfiguration, root: string, index?: number): Promise<IsolatedPlugin>;
|
|
41
43
|
private constructor();
|
|
42
44
|
private spawnAndConnect;
|
|
45
|
+
/**
|
|
46
|
+
* Drops the worker from service without judging why. The next hook call
|
|
47
|
+
* respawns it through `ensureAlive`.
|
|
48
|
+
*/
|
|
49
|
+
private markUnusable;
|
|
50
|
+
private failPendingRequests;
|
|
51
|
+
private handleFramingFailure;
|
|
43
52
|
/**
|
|
44
53
|
* Ensures the worker is alive, restarting it if necessary.
|
|
45
54
|
* Called before each hook execution to handle plugins that were
|
|
@@ -61,6 +70,7 @@ export declare class IsolatedPlugin implements LoadedNxPlugin {
|
|
|
61
70
|
private registerProcessMetrics;
|
|
62
71
|
}
|
|
63
72
|
export declare function getPluginWorkerSocketId(): string;
|
|
73
|
+
export declare function connectToWorker(worker: ChildProcess, ipcPath: string, name: string): Promise<Socket>;
|
|
64
74
|
/**
|
|
65
75
|
* Marks a failure to *start or reach* a worker, as distinct from a plugin that
|
|
66
76
|
* loaded and then threw. Only the former can be retried in-process: swallowing
|
|
@@ -75,5 +85,11 @@ export declare const PLUGIN_WORKER_STARTUP_FAILURE: unique symbol;
|
|
|
75
85
|
* also degrading for an OOM kill or a broken install.
|
|
76
86
|
*/
|
|
77
87
|
export declare const PLUGIN_WORKER_SOCKET_REFUSED: unique symbol;
|
|
88
|
+
/**
|
|
89
|
+
* Describes how a worker died for an error message. A `null` code with a signal
|
|
90
|
+
* is an outside kill rather than anything the worker chose, and SIGKILL is what
|
|
91
|
+
* an out-of-memory kill looks like, so both are called out by name.
|
|
92
|
+
*/
|
|
93
|
+
export declare function describeWorkerExit(code: number | null, signal: NodeJS.Signals | null): string;
|
|
78
94
|
export declare function isPluginWorkerStartupFailure(error: unknown): boolean;
|
|
79
95
|
export declare function isPluginWorkerSocketRefusal(error: unknown): boolean;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.PLUGIN_WORKER_SOCKET_REFUSED = exports.PLUGIN_WORKER_STARTUP_FAILURE = exports.IsolatedPlugin = void 0;
|
|
4
4
|
exports.getPluginWorkerSocketId = getPluginWorkerSocketId;
|
|
5
|
+
exports.connectToWorker = connectToWorker;
|
|
6
|
+
exports.describeWorkerExit = describeWorkerExit;
|
|
5
7
|
exports.isPluginWorkerStartupFailure = isPluginWorkerStartupFailure;
|
|
6
8
|
exports.isPluginWorkerSocketRefusal = isPluginWorkerSocketRefusal;
|
|
7
9
|
const child_process_1 = require("child_process");
|
|
@@ -93,32 +95,51 @@ class IsolatedPlugin {
|
|
|
93
95
|
this.worker = worker;
|
|
94
96
|
this.socket = socket;
|
|
95
97
|
this.registerProcessMetrics();
|
|
96
|
-
this.exitHandler = () => {
|
|
97
|
-
this.
|
|
98
|
-
this.
|
|
99
|
-
if (this.worker?.stdout) {
|
|
100
|
-
this.worker.stdout.unpipe(process.stdout);
|
|
101
|
-
}
|
|
102
|
-
if (this.worker?.stderr) {
|
|
103
|
-
this.worker.stderr.unpipe(process.stderr);
|
|
104
|
-
}
|
|
105
|
-
// Reject all pending requests
|
|
106
|
-
const error = new Error(`Plugin worker "${this.name}" exited unexpectedly.`);
|
|
107
|
-
for (const { onError } of this.responseHandlers.values()) {
|
|
108
|
-
onError(error);
|
|
109
|
-
}
|
|
110
|
-
this.responseHandlers.clear();
|
|
98
|
+
this.exitHandler = (code, signal) => {
|
|
99
|
+
this.markUnusable();
|
|
100
|
+
this.failPendingRequests(new Error(`Plugin worker "${this.name}" exited unexpectedly ${describeWorkerExit(code, signal)}.`));
|
|
111
101
|
};
|
|
112
102
|
worker.on('exit', this.exitHandler);
|
|
113
103
|
socket.on('data', (0, consume_messages_from_socket_1.consumeMessagesFromSocket)(this.handleSocketData, (err) => {
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
console.error(err.message);
|
|
104
|
+
// The worker may still be running here; it is the stream that failed, so
|
|
105
|
+
// reporting this as an exit would send whoever reads it hunting for a
|
|
106
|
+
// dead process.
|
|
107
|
+
this.handleFramingFailure(socket, err);
|
|
119
108
|
}));
|
|
120
109
|
return this.sendLoadMessage();
|
|
121
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Drops the worker from service without judging why. The next hook call
|
|
113
|
+
* respawns it through `ensureAlive`.
|
|
114
|
+
*/
|
|
115
|
+
markUnusable() {
|
|
116
|
+
this._alive = false;
|
|
117
|
+
this._connectPromise = null;
|
|
118
|
+
if (this.worker?.stdout) {
|
|
119
|
+
this.worker.stdout.unpipe(process.stdout);
|
|
120
|
+
}
|
|
121
|
+
if (this.worker?.stderr) {
|
|
122
|
+
this.worker.stderr.unpipe(process.stderr);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
failPendingRequests(error) {
|
|
126
|
+
for (const { onError } of this.responseHandlers.values()) {
|
|
127
|
+
onError(error);
|
|
128
|
+
}
|
|
129
|
+
this.responseHandlers.clear();
|
|
130
|
+
}
|
|
131
|
+
handleFramingFailure(socket, error) {
|
|
132
|
+
const framingError = new Error(`Plugin worker "${this.name}" sent a message the host could not read, ` +
|
|
133
|
+
`so its connection was dropped. ${error.message}`);
|
|
134
|
+
this.markUnusable();
|
|
135
|
+
socket.destroy();
|
|
136
|
+
if (this.responseHandlers.size === 0) {
|
|
137
|
+
console.error(framingError.message);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
this.failPendingRequests(framingError);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
122
143
|
/**
|
|
123
144
|
* Ensures the worker is alive, restarting it if necessary.
|
|
124
145
|
* Called before each hook execution to handle plugins that were
|
|
@@ -430,7 +451,7 @@ async function connectToWorker(worker, ipcPath, name) {
|
|
|
430
451
|
let earlyExitError = null;
|
|
431
452
|
// If the worker exits before we connect, abort polling immediately
|
|
432
453
|
// rather than burning through attempts against a dead socket.
|
|
433
|
-
worker.once('exit', (code) => {
|
|
454
|
+
worker.once('exit', (code, signal) => {
|
|
434
455
|
if (!abortController.signal.aborted) {
|
|
435
456
|
// The worker sets this code only after an EPERM/EACCES on its own bind,
|
|
436
457
|
// so it is proof of a refusal rather than an inference from the
|
|
@@ -438,7 +459,7 @@ async function connectToWorker(worker, ipcPath, name) {
|
|
|
438
459
|
// sets no variable `isSandbox()` reads.
|
|
439
460
|
const refused = code === socket_refused_exit_code_1.SOCKET_REFUSED_EXIT_CODE;
|
|
440
461
|
earlyExitError = markWorkerStartupFailure(new Error([
|
|
441
|
-
`Plugin worker for "${name}" exited
|
|
462
|
+
`Plugin worker for "${name}" exited ${describeWorkerExit(code, signal)} before the connection was established.`,
|
|
442
463
|
// The worker's own stderr may be lost with the process, so the
|
|
443
464
|
// cause and the fix are repeated here.
|
|
444
465
|
...(refused || (0, is_sandbox_1.isSandbox)()
|
|
@@ -476,6 +497,22 @@ exports.PLUGIN_WORKER_STARTUP_FAILURE = Symbol.for('nx.pluginWorkerStartupFailur
|
|
|
476
497
|
* also degrading for an OOM kill or a broken install.
|
|
477
498
|
*/
|
|
478
499
|
exports.PLUGIN_WORKER_SOCKET_REFUSED = Symbol.for('nx.pluginWorkerSocketRefused');
|
|
500
|
+
/**
|
|
501
|
+
* Describes how a worker died for an error message. A `null` code with a signal
|
|
502
|
+
* is an outside kill rather than anything the worker chose, and SIGKILL is what
|
|
503
|
+
* an out-of-memory kill looks like, so both are called out by name.
|
|
504
|
+
*/
|
|
505
|
+
function describeWorkerExit(code, signal) {
|
|
506
|
+
if (signal) {
|
|
507
|
+
return signal === 'SIGKILL'
|
|
508
|
+
? `(killed by ${signal}, commonly an out-of-memory kill)`
|
|
509
|
+
: `(killed by ${signal})`;
|
|
510
|
+
}
|
|
511
|
+
if (code !== null) {
|
|
512
|
+
return `(exit code ${code})`;
|
|
513
|
+
}
|
|
514
|
+
return '(no exit code or signal reported)';
|
|
515
|
+
}
|
|
479
516
|
function markWorkerStartupFailure(error, refused = false) {
|
|
480
517
|
error[exports.PLUGIN_WORKER_STARTUP_FAILURE] = true;
|
|
481
518
|
if (refused) {
|
|
@@ -11,6 +11,8 @@ export declare class ForkedProcessTaskRunner {
|
|
|
11
11
|
cliPath: string;
|
|
12
12
|
private readonly verbose;
|
|
13
13
|
private processes;
|
|
14
|
+
private processMessageHandler;
|
|
15
|
+
private processExitHandler;
|
|
14
16
|
private finishedProcesses;
|
|
15
17
|
private pseudoTerminals;
|
|
16
18
|
constructor(options: DefaultTasksRunnerOptions, tuiEnabled: boolean);
|
|
@@ -39,4 +41,5 @@ export declare class ForkedProcessTaskRunner {
|
|
|
39
41
|
private writeTerminalOutput;
|
|
40
42
|
cleanup(signal?: NodeJS.Signals): Promise<void>;
|
|
41
43
|
private setupProcessEventListeners;
|
|
44
|
+
removeProcessEventListeners(): void;
|
|
42
45
|
}
|
|
@@ -269,7 +269,7 @@ class ForkedProcessTaskRunner {
|
|
|
269
269
|
this.cleanUpBatchProcesses();
|
|
270
270
|
}
|
|
271
271
|
setupProcessEventListeners() {
|
|
272
|
-
|
|
272
|
+
this.processMessageHandler = (message) => {
|
|
273
273
|
this.pseudoTerminals.forEach((p) => {
|
|
274
274
|
p.sendMessageToChildren(message);
|
|
275
275
|
});
|
|
@@ -279,21 +279,30 @@ class ForkedProcessTaskRunner {
|
|
|
279
279
|
}
|
|
280
280
|
});
|
|
281
281
|
};
|
|
282
|
+
this.processExitHandler = () => {
|
|
283
|
+
this.cleanup();
|
|
284
|
+
process.off('message', this.processMessageHandler);
|
|
285
|
+
};
|
|
282
286
|
// When the nx process gets a message, it will be sent into the task's process
|
|
283
|
-
process.on('message',
|
|
287
|
+
process.on('message', this.processMessageHandler);
|
|
284
288
|
// Terminate any task processes on exit (sync, last resort).
|
|
285
289
|
// cleanup() is async but the initial signal dispatch is synchronous
|
|
286
290
|
// (killProcessTreeGraceful snapshots and signals before the async
|
|
287
291
|
// grace period). The grace period won't complete here, but each
|
|
288
292
|
// child also has its own sync exit handler as a final fallback.
|
|
289
|
-
process.once('exit',
|
|
290
|
-
this.cleanup();
|
|
291
|
-
process.off('message', messageHandler);
|
|
292
|
-
});
|
|
293
|
+
process.once('exit', this.processExitHandler);
|
|
293
294
|
// No SIGINT/SIGTERM/SIGHUP handlers here. The orchestrator's
|
|
294
295
|
// setupSignalHandlers() owns signal dispatch and calls FPTR.cleanup()
|
|
295
296
|
// in the direct path; in the forked path the process is detached
|
|
296
297
|
// and never receives these signals from the OS.
|
|
297
298
|
}
|
|
299
|
+
// Long-lived processes (Nx Cloud agents) create a runner per invocation;
|
|
300
|
+
// without this each instance stays reachable from `process` forever.
|
|
301
|
+
// Call only after cleanup(): the exit handler is the last-resort child
|
|
302
|
+
// kill, so it must not be removed while children may still be alive.
|
|
303
|
+
removeProcessEventListeners() {
|
|
304
|
+
process.off('message', this.processMessageHandler);
|
|
305
|
+
process.off('exit', this.processExitHandler);
|
|
306
|
+
}
|
|
298
307
|
}
|
|
299
308
|
exports.ForkedProcessTaskRunner = ForkedProcessTaskRunner;
|
|
@@ -9,6 +9,7 @@ const life_cycle_1 = require("./life-cycle");
|
|
|
9
9
|
const task_orchestrator_1 = require("./task-orchestrator");
|
|
10
10
|
const create_task_hasher_1 = require("../hasher/create-task-hasher");
|
|
11
11
|
const client_1 = require("../daemon/client/client");
|
|
12
|
+
const shared_running_task_1 = require("./running-tasks/shared-running-task");
|
|
12
13
|
const task_results_life_cycle_1 = require("./life-cycles/task-results-life-cycle");
|
|
13
14
|
async function createOrchestrator(tasks, projectGraph, fullTaskGraph, nxJson, lifeCycle) {
|
|
14
15
|
(0, dotenv_1.loadRootEnvFiles)();
|
|
@@ -47,6 +48,11 @@ async function createOrchestrator(tasks, projectGraph, fullTaskGraph, nxJson, li
|
|
|
47
48
|
orchestrator.processAllScheduledTasks();
|
|
48
49
|
return orchestrator;
|
|
49
50
|
}
|
|
51
|
+
// Nothing awaits the dispose chains below, so an unhandled rejection would take
|
|
52
|
+
// down a long-lived agent process.
|
|
53
|
+
function logDisposeFailure(e) {
|
|
54
|
+
console.error('Failed to dispose the task orchestrator:', e);
|
|
55
|
+
}
|
|
50
56
|
async function runDiscreteTasks(tasks, projectGraph, fullTaskGraph, nxJson, lifeCycle) {
|
|
51
57
|
const orchestrator = await createOrchestrator(tasks, projectGraph, fullTaskGraph, nxJson, lifeCycle);
|
|
52
58
|
let groupId = 0;
|
|
@@ -75,12 +81,36 @@ async function runDiscreteTasks(tasks, projectGraph, fullTaskGraph, nxJson, life
|
|
|
75
81
|
return [hit];
|
|
76
82
|
return [await orchestrator.runTaskDirectly(true, task, groupId++)];
|
|
77
83
|
});
|
|
78
|
-
|
|
84
|
+
const results = [...batchResults, ...taskResults];
|
|
85
|
+
// Callers like Nx Cloud agents create an orchestrator per invocation in a
|
|
86
|
+
// long-lived process; release its process-level listeners once all tasks
|
|
87
|
+
// settle, otherwise every invocation's orchestrator stays reachable forever.
|
|
88
|
+
// Not awaited, so callers keep consuming handles as they settle; the forked
|
|
89
|
+
// runner's exit handler still reaps children until dispose() runs.
|
|
90
|
+
Promise.allSettled(results)
|
|
91
|
+
.then(() => orchestrator.dispose())
|
|
92
|
+
.catch(logDisposeFailure);
|
|
93
|
+
return results;
|
|
79
94
|
}
|
|
80
95
|
async function runContinuousTasks(tasks, projectGraph, fullTaskGraph, nxJson, lifeCycle) {
|
|
81
96
|
const orchestrator = await createOrchestrator(tasks, projectGraph, fullTaskGraph, nxJson, lifeCycle);
|
|
82
|
-
|
|
97
|
+
const runningTasks = tasks.reduce((current, task, index) => {
|
|
83
98
|
current[task.id] = orchestrator.startContinuousTask(task, index);
|
|
84
99
|
return current;
|
|
85
100
|
}, {});
|
|
101
|
+
// Unlike runDiscreteTasks, this must resolve at task start: callers keep
|
|
102
|
+
// the RunningTask handles to kill later, so disposal has to be deferred
|
|
103
|
+
// until every task actually exits.
|
|
104
|
+
Promise.allSettled(Object.entries(runningTasks).map(async ([taskId, promise]) => {
|
|
105
|
+
const runningTask = await promise;
|
|
106
|
+
// A shared task is owned by another nx process; this orchestrator has
|
|
107
|
+
// no child to protect for it, so disposal does not wait on it.
|
|
108
|
+
if (runningTask instanceof shared_running_task_1.SharedRunningTask) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await orchestrator.waitForContinuousTaskExit(taskId);
|
|
112
|
+
}))
|
|
113
|
+
.then(() => orchestrator.dispose())
|
|
114
|
+
.catch(logDisposeFailure);
|
|
115
|
+
return runningTasks;
|
|
86
116
|
}
|
|
@@ -22,6 +22,8 @@ function getTasksHistoryLifeCycle() {
|
|
|
22
22
|
class TaskHistoryLifeCycle {
|
|
23
23
|
constructor() {
|
|
24
24
|
this.startTimings = {};
|
|
25
|
+
// Holds no terminalOutput: this singleton outlives every run in the
|
|
26
|
+
// process, and agents complete thousands of tasks in one process.
|
|
25
27
|
this.pendingResults = new Map();
|
|
26
28
|
this.taskRuns = new Map();
|
|
27
29
|
this.taskHistory = (0, task_history_1.getTaskHistory)();
|
|
@@ -36,25 +38,28 @@ class TaskHistoryLifeCycle {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
async endTasks(taskResults) {
|
|
39
|
-
for (const
|
|
40
|
-
this.pendingResults.set(
|
|
41
|
+
for (const { task, code, status } of taskResults) {
|
|
42
|
+
this.pendingResults.set(task.id, { task, code, status });
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
async endCommand() {
|
|
46
|
+
const pendingResults = Array.from(this.pendingResults.values());
|
|
47
|
+
this.pendingResults.clear();
|
|
44
48
|
if (!this.taskHistory) {
|
|
45
49
|
return;
|
|
46
50
|
}
|
|
47
51
|
// Build TaskRun objects now — task.hash is guaranteed to be set by this point
|
|
48
|
-
for (const
|
|
49
|
-
this.taskRuns.set(
|
|
50
|
-
hash:
|
|
51
|
-
target:
|
|
52
|
-
code
|
|
53
|
-
status
|
|
54
|
-
start:
|
|
55
|
-
end:
|
|
56
|
-
cacheable:
|
|
52
|
+
for (const { task, code, status } of pendingResults) {
|
|
53
|
+
this.taskRuns.set(task.hash, {
|
|
54
|
+
hash: task.hash,
|
|
55
|
+
target: task.target,
|
|
56
|
+
code,
|
|
57
|
+
status,
|
|
58
|
+
start: task.startTime ?? this.startTimings[task.id],
|
|
59
|
+
end: task.endTime ?? Date.now(),
|
|
60
|
+
cacheable: task.cache === true,
|
|
57
61
|
});
|
|
62
|
+
delete this.startTimings[task.id];
|
|
58
63
|
}
|
|
59
64
|
const runs = [];
|
|
60
65
|
// Only check for flaky tasks among cacheable tasks
|
|
@@ -233,6 +233,9 @@ class BatchProcess {
|
|
|
233
233
|
this.capturedOutputFd = undefined;
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
|
+
// Resolves on the CompleteBatchExecution message, not process exit: the
|
|
237
|
+
// child can outlive its results (open handles keep it alive), so callers
|
|
238
|
+
// must not treat a settled batch as an exited process.
|
|
236
239
|
async getResults() {
|
|
237
240
|
return Promise.race([
|
|
238
241
|
new Promise((_, rej) => {
|
|
@@ -3,6 +3,7 @@ import { RunningTasksService } from '../../native';
|
|
|
3
3
|
export declare class SharedRunningTask implements RunningTask {
|
|
4
4
|
private runningTasksService;
|
|
5
5
|
private exitCallbacks;
|
|
6
|
+
private detached;
|
|
6
7
|
constructor(runningTasksService: RunningTasksService, taskId: string);
|
|
7
8
|
getResults(): Promise<{
|
|
8
9
|
code: number;
|
|
@@ -5,7 +5,11 @@ class SharedRunningTask {
|
|
|
5
5
|
constructor(runningTasksService, taskId) {
|
|
6
6
|
this.runningTasksService = runningTasksService;
|
|
7
7
|
this.exitCallbacks = [];
|
|
8
|
+
this.detached = false;
|
|
8
9
|
this.waitForTaskToFinish(taskId).then(() => {
|
|
10
|
+
if (this.detached) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
9
13
|
// notify exit callbacks
|
|
10
14
|
this.exitCallbacks.forEach((cb) => cb(0));
|
|
11
15
|
});
|
|
@@ -13,8 +17,14 @@ class SharedRunningTask {
|
|
|
13
17
|
async getResults() {
|
|
14
18
|
throw new Error('Results cannot be retrieved from a shared task');
|
|
15
19
|
}
|
|
20
|
+
// Detaches from the shared task; the owning process keeps running it.
|
|
16
21
|
kill() {
|
|
22
|
+
if (this.detached) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
this.detached = true;
|
|
17
26
|
this.exitCallbacks.forEach((cb) => cb(0));
|
|
27
|
+
this.exitCallbacks = [];
|
|
18
28
|
}
|
|
19
29
|
onExit(cb) {
|
|
20
30
|
this.exitCallbacks.push(cb);
|
|
@@ -24,7 +34,8 @@ class SharedRunningTask {
|
|
|
24
34
|
// wait for the running task to finish
|
|
25
35
|
do {
|
|
26
36
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
27
|
-
} while (this.
|
|
37
|
+
} while (!this.detached &&
|
|
38
|
+
this.runningTasksService.getRunningTasks([taskId]).length);
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
exports.SharedRunningTask = SharedRunningTask;
|