nx 22.7.8 → 22.7.9
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/src/command-line/reset/reset.js +6 -1
- package/dist/src/daemon/client/client.d.ts +45 -3
- package/dist/src/daemon/client/client.js +138 -42
- package/dist/src/daemon/client/daemon-socket-messenger.js +4 -0
- package/dist/src/daemon/message-types/daemon-message.d.ts +33 -0
- package/dist/src/daemon/message-types/daemon-message.js +48 -0
- package/dist/src/daemon/server/server.js +22 -0
- package/dist/src/daemon/server/shutdown-utils.d.ts +5 -0
- package/dist/src/daemon/server/shutdown-utils.js +11 -4
- package/dist/src/daemon/socket-utils.d.ts +1 -0
- package/dist/src/daemon/socket-utils.js +24 -3
- package/dist/src/daemon/tmp-dir.d.ts +43 -3
- package/dist/src/daemon/tmp-dir.js +437 -15
- package/dist/src/devkit-internals.d.ts +1 -0
- package/dist/src/devkit-internals.js +4 -1
- package/dist/src/native/index.js +71 -28
- package/dist/src/native/native-file-cache-location.d.ts +17 -1
- package/dist/src/native/native-file-cache-location.js +86 -12
- package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
- package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.d.ts +1 -0
- package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +18 -1
- package/dist/src/project-graph/plugins/isolation/messaging.d.ts +16 -3
- package/dist/src/project-graph/plugins/isolation/messaging.js +3 -0
- package/dist/src/project-graph/plugins/isolation/plugin-worker.js +58 -8
- package/dist/src/project-graph/project-graph.js +20 -0
- package/dist/src/utils/nx-tmp-dir.d.ts +18 -0
- package/dist/src/utils/nx-tmp-dir.js +40 -0
- package/dist/src/utils/owned-private-dir.d.ts +132 -0
- package/dist/src/utils/owned-private-dir.js +323 -0
- package/dist/src/utils/safe-spawn.d.ts +24 -0
- package/dist/src/utils/safe-spawn.js +104 -0
- package/dist/src/utils/shell-quoting.d.ts +15 -0
- package/dist/src/utils/shell-quoting.js +35 -0
- package/dist/src/utils/wait-for-socket-connection.d.ts +11 -0
- package/dist/src/utils/wait-for-socket-connection.js +14 -8
- package/package.json +11 -11
|
@@ -137,7 +137,12 @@ function cleanupCacheEntries() {
|
|
|
137
137
|
}
|
|
138
138
|
function cleanupNativeFileCache() {
|
|
139
139
|
return incrementalBackoff(INCREMENTAL_BACKOFF_FIRST_DELAY, INCREMENTAL_BACKOFF_MAX_DURATION, () => {
|
|
140
|
-
|
|
140
|
+
// Null when the native cache root is not a real directory we own, which
|
|
141
|
+
// would mean deleting through a path another user planted.
|
|
142
|
+
const cacheDir = (0, native_file_cache_location_1.getNativeFileCacheLocationToDelete)();
|
|
143
|
+
if (cacheDir) {
|
|
144
|
+
(0, node_fs_1.rmSync)(cacheDir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
141
146
|
});
|
|
142
147
|
}
|
|
143
148
|
function cleanupWorkspaceData() {
|
|
@@ -8,6 +8,11 @@ import { ConfigurationSourceMaps } from '../../project-graph/utils/project-confi
|
|
|
8
8
|
import type { FlushSyncGeneratorChangesResult, SyncGeneratorRunResult } from '../../utils/sync-generators';
|
|
9
9
|
import { type ConfigureAiAgentsStatusResponse } from '../message-types/configure-ai-agents';
|
|
10
10
|
import { type NxConsoleStatusResponse, type SetNxConsolePreferenceAndInstallResponse } from '../message-types/nx-console';
|
|
11
|
+
/** A refused connect: the errno, and the path it was made against. */
|
|
12
|
+
type ConnectRefusal = {
|
|
13
|
+
error: NodeJS.ErrnoException;
|
|
14
|
+
socketPath: string;
|
|
15
|
+
};
|
|
11
16
|
export type UnregisterCallback = () => void;
|
|
12
17
|
export type ChangedFile = {
|
|
13
18
|
path: string;
|
|
@@ -26,8 +31,6 @@ export declare class DaemonClient {
|
|
|
26
31
|
private _daemonStatus;
|
|
27
32
|
private _waitForDaemonReady;
|
|
28
33
|
private _daemonReady;
|
|
29
|
-
private _out;
|
|
30
|
-
private _err;
|
|
31
34
|
private fileWatcherMessenger;
|
|
32
35
|
private fileWatcherReconnecting;
|
|
33
36
|
private fileWatcherCallbacks;
|
|
@@ -95,6 +98,12 @@ export declare class DaemonClient {
|
|
|
95
98
|
resetConfigureAiAgentsStatus(): Promise<{
|
|
96
99
|
success: boolean;
|
|
97
100
|
}>;
|
|
101
|
+
/**
|
|
102
|
+
* The pre-start probe, returning why it failed rather than leaving it on the
|
|
103
|
+
* instance: `_daemonStatus` does not serialize `isServerAvailable`'s five
|
|
104
|
+
* public callers against `startDaemonIfNecessary`.
|
|
105
|
+
*/
|
|
106
|
+
private probeServer;
|
|
98
107
|
isServerAvailable(): Promise<boolean>;
|
|
99
108
|
private startDaemonIfNecessary;
|
|
100
109
|
private sendToDaemonViaQueue;
|
|
@@ -110,8 +119,41 @@ export declare class DaemonClient {
|
|
|
110
119
|
private sendMessageToDaemon;
|
|
111
120
|
private registerDaemonProcessWithMetricsService;
|
|
112
121
|
private handleMessage;
|
|
113
|
-
|
|
122
|
+
/**
|
|
123
|
+
* @param probeRefusal what the caller's pre-start probe saw. The only evidence
|
|
124
|
+
* when a daemon refuses us and then exits — the poll cannot reproduce it
|
|
125
|
+
* once the process json is gone. `nx daemon --start` passes nothing.
|
|
126
|
+
*/
|
|
127
|
+
startInBackground(probeRefusal?: ConnectRefusal): Promise<ChildProcess['pid']>;
|
|
114
128
|
stop(): Promise<void>;
|
|
115
129
|
}
|
|
116
130
|
export declare const daemonClient: DaemonClient;
|
|
117
131
|
export declare function isDaemonEnabled(): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* EACCES and EPERM are the two errnos that mean the OS refused us rather than
|
|
134
|
+
* that nothing was listening. They need opposite remedies — a socket owned by
|
|
135
|
+
* someone else versus a sandbox refusing the connect syscall — but they share
|
|
136
|
+
* the property that retrying cannot change the answer.
|
|
137
|
+
*/
|
|
138
|
+
export declare function isPermissionErrno(error: NodeJS.ErrnoException): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* The operating system refused the connection. Most often the socket belongs to
|
|
141
|
+
* another user, which is the guarantee the owner-only socket directory buys —
|
|
142
|
+
* but a sandbox that denies unix-socket connects produces the same errno, so the
|
|
143
|
+
* message does not assert which. Either way it is an environment condition
|
|
144
|
+
* rather than a defect in Nx, and it deliberately does not
|
|
145
|
+
* carry `internalDaemonError`: that tag tells the user to file an issue and
|
|
146
|
+
* disables the daemon until `nx reset`, which would outlast the stale socket
|
|
147
|
+
* that caused it.
|
|
148
|
+
*
|
|
149
|
+
* It also skips the daemon log that `daemonProcessException` appends. The log
|
|
150
|
+
* belongs to *our* daemon; the process holding this socket is someone else's, so
|
|
151
|
+
* quoting it would describe an unrelated run.
|
|
152
|
+
*/
|
|
153
|
+
export declare function daemonPermissionException(socketPath: string, cause: string): Error;
|
|
154
|
+
/**
|
|
155
|
+
* Exported for testing: the `internalDaemonError` tag decides whether a daemon
|
|
156
|
+
* failure degrades to a daemonless graph build or aborts the command.
|
|
157
|
+
*/
|
|
158
|
+
export declare function daemonProcessException(message: string): Error;
|
|
159
|
+
export {};
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.daemonClient = exports.DaemonClient = void 0;
|
|
4
4
|
exports.isDaemonEnabled = isDaemonEnabled;
|
|
5
|
+
exports.isPermissionErrno = isPermissionErrno;
|
|
6
|
+
exports.daemonPermissionException = daemonPermissionException;
|
|
7
|
+
exports.daemonProcessException = daemonProcessException;
|
|
5
8
|
const child_process_1 = require("child_process");
|
|
6
|
-
const promises_1 = require("fs/promises");
|
|
7
9
|
const net_1 = require("net");
|
|
8
10
|
const node_fs_1 = require("node:fs");
|
|
9
11
|
const path_1 = require("path");
|
|
@@ -62,8 +64,6 @@ class DaemonClient {
|
|
|
62
64
|
this._daemonStatus = DaemonStatus.DISCONNECTED;
|
|
63
65
|
this._waitForDaemonReady = null;
|
|
64
66
|
this._daemonReady = null;
|
|
65
|
-
this._out = null;
|
|
66
|
-
this._err = null;
|
|
67
67
|
this.fileWatcherReconnecting = false;
|
|
68
68
|
this.fileWatcherCallbacks = new Map();
|
|
69
69
|
this.fileWatcherConfigs = new Map();
|
|
@@ -127,10 +127,6 @@ class DaemonClient {
|
|
|
127
127
|
this.currentResolve = null;
|
|
128
128
|
this.currentReject = null;
|
|
129
129
|
this._enabled = undefined;
|
|
130
|
-
this._out?.close();
|
|
131
|
-
this._err?.close();
|
|
132
|
-
this._out = null;
|
|
133
|
-
this._err = null;
|
|
134
130
|
// Clean up file watcher and project graph listener connections
|
|
135
131
|
this.fileWatcherMessenger?.close();
|
|
136
132
|
this.fileWatcherMessenger = undefined;
|
|
@@ -282,9 +278,9 @@ class DaemonClient {
|
|
|
282
278
|
// Wait for daemon server to be available before trying to reconnect
|
|
283
279
|
let serverAvailable;
|
|
284
280
|
try {
|
|
285
|
-
serverAvailable = await this.waitForServerToBeAvailable({
|
|
281
|
+
({ available: serverAvailable } = await this.waitForServerToBeAvailable({
|
|
286
282
|
ignoreVersionMismatch: false,
|
|
287
|
-
});
|
|
283
|
+
}));
|
|
288
284
|
}
|
|
289
285
|
catch (err) {
|
|
290
286
|
// Version mismatch - pass error to callbacks so they can handle it
|
|
@@ -434,9 +430,9 @@ class DaemonClient {
|
|
|
434
430
|
// Wait for daemon server to be available before trying to reconnect
|
|
435
431
|
let serverAvailable;
|
|
436
432
|
try {
|
|
437
|
-
serverAvailable = await this.waitForServerToBeAvailable({
|
|
433
|
+
({ available: serverAvailable } = await this.waitForServerToBeAvailable({
|
|
438
434
|
ignoreVersionMismatch: false,
|
|
439
|
-
});
|
|
435
|
+
}));
|
|
440
436
|
}
|
|
441
437
|
catch (err) {
|
|
442
438
|
// Version mismatch - pass error to callbacks so they can handle it
|
|
@@ -671,30 +667,40 @@ class DaemonClient {
|
|
|
671
667
|
};
|
|
672
668
|
return this.sendToDaemonViaQueue(message);
|
|
673
669
|
}
|
|
674
|
-
|
|
670
|
+
/**
|
|
671
|
+
* The pre-start probe, returning why it failed rather than leaving it on the
|
|
672
|
+
* instance: `_daemonStatus` does not serialize `isServerAvailable`'s five
|
|
673
|
+
* public callers against `startDaemonIfNecessary`.
|
|
674
|
+
*/
|
|
675
|
+
async probeServer() {
|
|
675
676
|
return new Promise((resolve, reject) => {
|
|
676
677
|
try {
|
|
677
678
|
const socketPath = this.getSocketPath();
|
|
678
679
|
if (!socketPath) {
|
|
679
|
-
resolve(false);
|
|
680
|
+
resolve({ available: false });
|
|
680
681
|
return;
|
|
681
682
|
}
|
|
682
683
|
const socket = (0, net_1.connect)(socketPath, () => {
|
|
683
684
|
socket.destroy();
|
|
684
|
-
resolve(true);
|
|
685
|
+
resolve({ available: true });
|
|
685
686
|
});
|
|
686
|
-
socket.once('error', () => {
|
|
687
|
-
|
|
687
|
+
socket.once('error', (err) => {
|
|
688
|
+
// The only place the errno for "the socket is there but refuses us"
|
|
689
|
+
// is produced, and the only thing separating it from "no daemon yet".
|
|
690
|
+
resolve({ available: false, refusal: { error: err, socketPath } });
|
|
688
691
|
});
|
|
689
692
|
}
|
|
690
693
|
catch (err) {
|
|
691
694
|
if (err instanceof daemon_socket_messenger_1.VersionMismatchError) {
|
|
692
695
|
reject(err); // Let version mismatch bubble up
|
|
693
696
|
}
|
|
694
|
-
resolve(false);
|
|
697
|
+
resolve({ available: false });
|
|
695
698
|
}
|
|
696
699
|
});
|
|
697
700
|
}
|
|
701
|
+
async isServerAvailable() {
|
|
702
|
+
return (await this.probeServer()).available;
|
|
703
|
+
}
|
|
698
704
|
async startDaemonIfNecessary() {
|
|
699
705
|
if (this._daemonStatus == DaemonStatus.CONNECTED) {
|
|
700
706
|
return;
|
|
@@ -703,21 +709,22 @@ class DaemonClient {
|
|
|
703
709
|
if (this._daemonStatus == DaemonStatus.DISCONNECTED) {
|
|
704
710
|
this._daemonStatus = DaemonStatus.CONNECTING;
|
|
705
711
|
let daemonPid = null;
|
|
706
|
-
let
|
|
712
|
+
let probe;
|
|
707
713
|
try {
|
|
708
|
-
|
|
714
|
+
probe = await this.probeServer();
|
|
709
715
|
}
|
|
710
716
|
catch (err) {
|
|
711
717
|
// Version mismatch - treat as server not available, start new one
|
|
712
718
|
if (err instanceof daemon_socket_messenger_1.VersionMismatchError) {
|
|
713
|
-
|
|
719
|
+
probe = { available: false };
|
|
714
720
|
}
|
|
715
721
|
else {
|
|
716
722
|
throw err;
|
|
717
723
|
}
|
|
718
724
|
}
|
|
719
|
-
if (!
|
|
720
|
-
|
|
725
|
+
if (!probe.available) {
|
|
726
|
+
// Carried as a value so no other caller's probe can substitute for it.
|
|
727
|
+
daemonPid = await this.startInBackground(probe.refusal);
|
|
721
728
|
}
|
|
722
729
|
this.setUpConnection();
|
|
723
730
|
this._daemonStatus = DaemonStatus.CONNECTED;
|
|
@@ -768,6 +775,11 @@ class DaemonClient {
|
|
|
768
775
|
if (err.message.startsWith('connect ENOENT')) {
|
|
769
776
|
error = daemonProcessException('The Daemon Server is not running');
|
|
770
777
|
}
|
|
778
|
+
else if (isPermissionErrno(err)) {
|
|
779
|
+
// The 0700 dir and 0600 socket mean the OS refuses this rather than the
|
|
780
|
+
// connect silently succeeding.
|
|
781
|
+
error = daemonPermissionException(socketPath, err.message);
|
|
782
|
+
}
|
|
771
783
|
else if (err.message.startsWith('connect ECONNREFUSED')) {
|
|
772
784
|
error = daemonProcessException(`A server instance had not been fully shut down. Please try running the command again.`);
|
|
773
785
|
}
|
|
@@ -788,9 +800,9 @@ class DaemonClient {
|
|
|
788
800
|
this._daemonStatus = DaemonStatus.CONNECTING;
|
|
789
801
|
let serverAvailable;
|
|
790
802
|
try {
|
|
791
|
-
serverAvailable = await this.waitForServerToBeAvailable({
|
|
803
|
+
({ available: serverAvailable } = await this.waitForServerToBeAvailable({
|
|
792
804
|
ignoreVersionMismatch: false,
|
|
793
|
-
});
|
|
805
|
+
}));
|
|
794
806
|
}
|
|
795
807
|
catch (err) {
|
|
796
808
|
if (err instanceof daemon_socket_messenger_1.VersionMismatchError) {
|
|
@@ -836,6 +848,11 @@ class DaemonClient {
|
|
|
836
848
|
*/
|
|
837
849
|
async waitForServerToBeAvailable(options) {
|
|
838
850
|
logger_1.clientLogger.log(`[Client] Waiting for server (max: ${WAIT_FOR_SERVER_CONFIG.maxAttempts} attempts, ${WAIT_FOR_SERVER_CONFIG.delayMs}ms interval)`);
|
|
851
|
+
// Poll-scoped, not instance state: reconnect paths have their own flags, so
|
|
852
|
+
// several attempts can be in flight and a shared slot would let one report
|
|
853
|
+
// another's socket.
|
|
854
|
+
let stoppedOnRefusal = false;
|
|
855
|
+
let refusal;
|
|
839
856
|
const socket = await (0, wait_for_socket_connection_1.waitForSocketConnection)(() => {
|
|
840
857
|
try {
|
|
841
858
|
return this.getSocketPath();
|
|
@@ -852,14 +869,29 @@ class DaemonClient {
|
|
|
852
869
|
}, {
|
|
853
870
|
maxAttempts: WAIT_FOR_SERVER_CONFIG.maxAttempts,
|
|
854
871
|
delayMs: WAIT_FOR_SERVER_CONFIG.delayMs,
|
|
872
|
+
onConnectError: (error, socketPath) => {
|
|
873
|
+
refusal = { error, socketPath };
|
|
874
|
+
// A refusal is not expected to become an acceptance, so polling the
|
|
875
|
+
// full 60s budget only delays the same answer. Not absolute:
|
|
876
|
+
// `server.ts` binds before it chmods to 0600, so under a umask that
|
|
877
|
+
// strips owner write a same-user connect can lose a microsecond race
|
|
878
|
+
// and see EACCES. That costs a specific error rather than a retry — a
|
|
879
|
+
// better trade than a 60s hang.
|
|
880
|
+
return (stoppedOnRefusal = isPermissionErrno(error));
|
|
881
|
+
},
|
|
855
882
|
});
|
|
856
883
|
if (socket) {
|
|
857
884
|
socket.destroy();
|
|
858
885
|
logger_1.clientLogger.log(`[Client] Server available`);
|
|
859
|
-
return true;
|
|
886
|
+
return { available: true };
|
|
860
887
|
}
|
|
861
|
-
|
|
862
|
-
|
|
888
|
+
// Keyed on the early exit taken, not on whether an errno was recorded:
|
|
889
|
+
// every failed connect records one, including an ordinary cold start's
|
|
890
|
+
// ENOENT.
|
|
891
|
+
logger_1.clientLogger.log(stoppedOnRefusal
|
|
892
|
+
? `[Client] Server refused the connection (${refusal?.error.code}), stopped polling`
|
|
893
|
+
: `[Client] Server not available after ${WAIT_FOR_SERVER_CONFIG.maxAttempts} attempts`);
|
|
894
|
+
return { available: false, refusal };
|
|
863
895
|
}
|
|
864
896
|
async sendMessageToDaemon(message, force) {
|
|
865
897
|
// the first message sent to the daemon includes an env prop
|
|
@@ -941,7 +973,12 @@ class DaemonClient {
|
|
|
941
973
|
].join('\n')));
|
|
942
974
|
}
|
|
943
975
|
}
|
|
944
|
-
|
|
976
|
+
/**
|
|
977
|
+
* @param probeRefusal what the caller's pre-start probe saw. The only evidence
|
|
978
|
+
* when a daemon refuses us and then exits — the poll cannot reproduce it
|
|
979
|
+
* once the process json is gone. `nx daemon --start` passes nothing.
|
|
980
|
+
*/
|
|
981
|
+
async startInBackground(probeRefusal) {
|
|
945
982
|
if (global.NX_PLUGIN_WORKER) {
|
|
946
983
|
throw new Error('Fatal Error: Something unexpected has occurred. Plugin Workers should not start a new daemon process. Please report this issue.');
|
|
947
984
|
}
|
|
@@ -949,17 +986,24 @@ class DaemonClient {
|
|
|
949
986
|
if (!(0, node_fs_1.existsSync)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE)) {
|
|
950
987
|
(0, node_fs_1.writeFileSync)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE, '');
|
|
951
988
|
}
|
|
952
|
-
|
|
953
|
-
|
|
989
|
+
// Redirect the detached daemon's stdout/stderr into the log file. The
|
|
990
|
+
// child dup's these descriptors at spawn, so we close ours right after
|
|
991
|
+
// instead of holding them for the life of this process (Node >=26 turns a
|
|
992
|
+
// file descriptor closed during garbage collection into a fatal error).
|
|
993
|
+
const outFd = (0, node_fs_1.openSync)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE, 'a');
|
|
994
|
+
const errFd = (0, node_fs_1.openSync)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE, 'a');
|
|
954
995
|
logger_1.clientLogger.log(`[Client] Starting new daemon server in background`);
|
|
955
996
|
const backgroundProcess = (0, child_process_1.spawn)(process.execPath, [(0, path_1.join)(__dirname, `../server/start.js`)], {
|
|
956
997
|
cwd: workspace_root_1.workspaceRoot,
|
|
957
|
-
stdio: ['ignore',
|
|
998
|
+
stdio: ['ignore', outFd, errFd],
|
|
958
999
|
detached: true,
|
|
959
1000
|
windowsHide: true,
|
|
960
1001
|
shell: false,
|
|
961
1002
|
env: (0, daemon_environment_1.getDaemonEnv)(),
|
|
962
1003
|
});
|
|
1004
|
+
// The child now owns dup'd copies of the descriptors, so release ours.
|
|
1005
|
+
(0, node_fs_1.closeSync)(outFd);
|
|
1006
|
+
(0, node_fs_1.closeSync)(errFd);
|
|
963
1007
|
// if this process is the process that spawned the daemon,
|
|
964
1008
|
// the daemon env is already up to date
|
|
965
1009
|
this.envReflectionSent = true;
|
|
@@ -967,14 +1011,25 @@ class DaemonClient {
|
|
|
967
1011
|
/**
|
|
968
1012
|
* Ensure the server is actually available to connect to via IPC before resolving
|
|
969
1013
|
*/
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
});
|
|
973
|
-
if (serverAvailable) {
|
|
1014
|
+
const { available, refusal: polled } = await this.waitForServerToBeAvailable({ ignoreVersionMismatch: true });
|
|
1015
|
+
if (available) {
|
|
974
1016
|
logger_1.clientLogger.log(`[Client] Daemon server started, pid=${backgroundProcess.pid}`);
|
|
975
1017
|
return backgroundProcess.pid;
|
|
976
1018
|
}
|
|
977
1019
|
else {
|
|
1020
|
+
// A permission refusal from either source wins, then the poll's errno,
|
|
1021
|
+
// then the probe's. Recency alone would report a daemon's ENOENT over the
|
|
1022
|
+
// EACCES the probe saw a moment earlier, losing the diagnosis.
|
|
1023
|
+
const refusal = [polled, probeRefusal].find((r) => r && isPermissionErrno(r.error)) ??
|
|
1024
|
+
polled ??
|
|
1025
|
+
probeRefusal;
|
|
1026
|
+
if (refusal && isPermissionErrno(refusal.error)) {
|
|
1027
|
+
// Reported here rather than as a generic startup failure, so it degrades
|
|
1028
|
+
// without disabling the daemon until `nx reset`. Both operands come from
|
|
1029
|
+
// the refusal: resolving a path here instead would throw once a daemon
|
|
1030
|
+
// that failed to bind has unlinked its process json.
|
|
1031
|
+
throw daemonPermissionException(refusal.socketPath, refusal.error.message);
|
|
1032
|
+
}
|
|
978
1033
|
throw daemonProcessException('Failed to start or connect to the Nx Daemon process.');
|
|
979
1034
|
}
|
|
980
1035
|
}
|
|
@@ -1017,24 +1072,65 @@ function isDocker() {
|
|
|
1017
1072
|
function nxJsonIsNotPresent() {
|
|
1018
1073
|
return !(0, nx_json_1.hasNxJson)(workspace_root_1.workspaceRoot);
|
|
1019
1074
|
}
|
|
1075
|
+
/**
|
|
1076
|
+
* EACCES and EPERM are the two errnos that mean the OS refused us rather than
|
|
1077
|
+
* that nothing was listening. They need opposite remedies — a socket owned by
|
|
1078
|
+
* someone else versus a sandbox refusing the connect syscall — but they share
|
|
1079
|
+
* the property that retrying cannot change the answer.
|
|
1080
|
+
*/
|
|
1081
|
+
function isPermissionErrno(error) {
|
|
1082
|
+
return error?.code === 'EACCES' || error?.code === 'EPERM';
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* The operating system refused the connection. Most often the socket belongs to
|
|
1086
|
+
* another user, which is the guarantee the owner-only socket directory buys —
|
|
1087
|
+
* but a sandbox that denies unix-socket connects produces the same errno, so the
|
|
1088
|
+
* message does not assert which. Either way it is an environment condition
|
|
1089
|
+
* rather than a defect in Nx, and it deliberately does not
|
|
1090
|
+
* carry `internalDaemonError`: that tag tells the user to file an issue and
|
|
1091
|
+
* disables the daemon until `nx reset`, which would outlast the stale socket
|
|
1092
|
+
* that caused it.
|
|
1093
|
+
*
|
|
1094
|
+
* It also skips the daemon log that `daemonProcessException` appends. The log
|
|
1095
|
+
* belongs to *our* daemon; the process holding this socket is someone else's, so
|
|
1096
|
+
* quoting it would describe an unrelated run.
|
|
1097
|
+
*/
|
|
1098
|
+
function daemonPermissionException(socketPath, cause) {
|
|
1099
|
+
const error = new Error([
|
|
1100
|
+
`The operating system refused the connection to the Nx Daemon socket (${cause}).`,
|
|
1101
|
+
'',
|
|
1102
|
+
`Socket: ${socketPath}`,
|
|
1103
|
+
'',
|
|
1104
|
+
'Most often the socket belongs to a different user: a daemon left behind by running Nx under `sudo`, a different uid inside a container, or a working copy shared between accounts. If the socket is your own, a sandbox is refusing the connection instead.',
|
|
1105
|
+
'If it belongs to another user, delete the socket above or set NX_SOCKET_DIR to a directory only your user can reach. If you are in a sandbox, allow unix sockets under the Nx socket root — in Claude Code a scoped `allowUnixSockets` only permits connecting, so starting a daemon there needs `allowAllUnixSockets: true`. See https://nx.dev/docs/kb/nx-sandbox-unix-sockets',
|
|
1106
|
+
].join('\n'));
|
|
1107
|
+
error.daemonPermissionError = true;
|
|
1108
|
+
return error;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Exported for testing: the `internalDaemonError` tag decides whether a daemon
|
|
1112
|
+
* failure degrades to a daemonless graph build or aborts the command.
|
|
1113
|
+
*/
|
|
1020
1114
|
function daemonProcessException(message) {
|
|
1115
|
+
// The log is an enrichment, not the classifier: it is absent on a first run,
|
|
1116
|
+
// which is exactly when the daemon is most likely to fail to start.
|
|
1117
|
+
let body = message;
|
|
1021
1118
|
try {
|
|
1022
1119
|
let log = (0, node_fs_1.readFileSync)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE).toString().split('\n');
|
|
1023
1120
|
if (log.length > 20) {
|
|
1024
1121
|
log = log.slice(log.length - 20);
|
|
1025
1122
|
}
|
|
1026
|
-
|
|
1123
|
+
body = [
|
|
1027
1124
|
message,
|
|
1028
1125
|
'',
|
|
1029
1126
|
'Messages from the log:',
|
|
1030
1127
|
...log,
|
|
1031
1128
|
'\n',
|
|
1032
1129
|
`More information: ${tmp_dir_1.DAEMON_OUTPUT_LOG_FILE}`,
|
|
1033
|
-
].join('\n')
|
|
1034
|
-
error.internalDaemonError = true;
|
|
1035
|
-
return error;
|
|
1036
|
-
}
|
|
1037
|
-
catch (e) {
|
|
1038
|
-
return new Error(message);
|
|
1130
|
+
].join('\n');
|
|
1039
1131
|
}
|
|
1132
|
+
catch { }
|
|
1133
|
+
const error = new Error(body);
|
|
1134
|
+
error.internalDaemonError = true;
|
|
1135
|
+
return error;
|
|
1040
1136
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.DaemonSocketMessenger = exports.VersionMismatchError = void 0;
|
|
4
4
|
const perf_hooks_1 = require("perf_hooks");
|
|
5
5
|
const consume_messages_from_socket_1 = require("../../utils/consume-messages-from-socket");
|
|
6
|
+
const workspace_root_1 = require("../../utils/workspace-root");
|
|
6
7
|
const logger_1 = require("../logger");
|
|
7
8
|
const socket_utils_1 = require("../socket-utils");
|
|
8
9
|
class VersionMismatchError extends Error {
|
|
@@ -21,6 +22,9 @@ class DaemonSocketMessenger {
|
|
|
21
22
|
if (!this.socket) {
|
|
22
23
|
throw new Error('Socket not initialized.');
|
|
23
24
|
}
|
|
25
|
+
// Stamp every message with the sending workspace's root so the daemon can
|
|
26
|
+
// reject messages from a different workspace (e.g. a shared NX_SOCKET_DIR).
|
|
27
|
+
messageToDaemon.workspaceRoot = workspace_root_1.workspaceRoot;
|
|
24
28
|
logger_1.clientLogger.log('[Messenger] Sending message type:', messageToDaemon.type);
|
|
25
29
|
perf_hooks_1.performance.mark('daemon-message-serialization-start-' + messageToDaemon.type);
|
|
26
30
|
const serialized = (0, socket_utils_1.serialize)(messageToDaemon, force);
|
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
export type DaemonMessage = {
|
|
2
2
|
type: string;
|
|
3
3
|
env?: Record<string, string>;
|
|
4
|
+
/**
|
|
5
|
+
* Stamped centrally by `DaemonSocketMessenger.sendMessage`, not by the ~30
|
|
6
|
+
* sites that construct messages — hence optional. It can also be absent on a
|
|
7
|
+
* message that predates the field, which is why `isForeignWorkspaceMessage`
|
|
8
|
+
* treats `undefined` as "not foreign".
|
|
9
|
+
*/
|
|
10
|
+
workspaceRoot?: string;
|
|
4
11
|
data?: any;
|
|
5
12
|
};
|
|
6
13
|
export declare function isDaemonMessage(msg: unknown): msg is DaemonMessage;
|
|
14
|
+
/**
|
|
15
|
+
* Any message carrying the sender's workspace root. Structural so the checks
|
|
16
|
+
* below serve the daemon and the plugin worker without either importing the
|
|
17
|
+
* other's message type. `type` is required only so the type is not all-optional,
|
|
18
|
+
* which would accept `{}`.
|
|
19
|
+
*/
|
|
20
|
+
type WorkspaceScopedMessage = {
|
|
21
|
+
type: string;
|
|
22
|
+
workspaceRoot?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A message from a different workspace — two sharing an NX_SOCKET_DIR — must not
|
|
26
|
+
* be processed. Roots are normalized first so that a workspace does not look
|
|
27
|
+
* foreign to itself; see {@link normalizeWorkspaceRoot}.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isForeignWorkspaceMessage(msg: WorkspaceScopedMessage, receiverWorkspaceRoot: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Throws on a message this receiver must not act on. The daemon catches it and
|
|
32
|
+
* responds with the mismatch; the plugin worker catches it and drops the
|
|
33
|
+
* message. `receiverDescription` names whichever raised it.
|
|
34
|
+
*
|
|
35
|
+
* An unstamped message is deliberately accepted: this is accident detection,
|
|
36
|
+
* not a control.
|
|
37
|
+
*/
|
|
38
|
+
export declare function assertNotForeignWorkspaceMessage(msg: WorkspaceScopedMessage, receiverWorkspaceRoot: string, receiverDescription?: string): void;
|
|
39
|
+
export {};
|
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isDaemonMessage = isDaemonMessage;
|
|
4
|
+
exports.isForeignWorkspaceMessage = isForeignWorkspaceMessage;
|
|
5
|
+
exports.assertNotForeignWorkspaceMessage = assertNotForeignWorkspaceMessage;
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
4
7
|
function isDaemonMessage(msg) {
|
|
5
8
|
return typeof msg === 'object' && msg && 'type' in msg;
|
|
6
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Puts a workspace root into a form two independently-resolved spellings of the
|
|
12
|
+
* same directory agree on.
|
|
13
|
+
*
|
|
14
|
+
* The sender's root and the receiver's root do not always come out of the same
|
|
15
|
+
* branch of `workspaceRootInner`: one side can be handed `NX_WORKSPACE_ROOT_PATH`
|
|
16
|
+
* verbatim while the other walks up from `process.cwd()`. On Windows those two
|
|
17
|
+
* sources routinely disagree on the case of the drive letter — an editor or
|
|
18
|
+
* agent may export `d:\repo` while `process.cwd()` reports `D:\repo` — and on
|
|
19
|
+
* separators. Both address the same directory: Windows paths are
|
|
20
|
+
* case-insensitive, and a drive letter is case-insensitive without exception.
|
|
21
|
+
*
|
|
22
|
+
* POSIX roots keep their case, where `/repo` and `/REPO` really can be two
|
|
23
|
+
* directories.
|
|
24
|
+
*/
|
|
25
|
+
function normalizeWorkspaceRoot(workspaceRoot) {
|
|
26
|
+
return process.platform === 'win32'
|
|
27
|
+
? node_path_1.win32.normalize(workspaceRoot).toLowerCase()
|
|
28
|
+
: node_path_1.posix.normalize(workspaceRoot);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A message from a different workspace — two sharing an NX_SOCKET_DIR — must not
|
|
32
|
+
* be processed. Roots are normalized first so that a workspace does not look
|
|
33
|
+
* foreign to itself; see {@link normalizeWorkspaceRoot}.
|
|
34
|
+
*/
|
|
35
|
+
function isForeignWorkspaceMessage(msg, receiverWorkspaceRoot) {
|
|
36
|
+
if (msg.workspaceRoot === undefined) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return (normalizeWorkspaceRoot(msg.workspaceRoot) !==
|
|
40
|
+
normalizeWorkspaceRoot(receiverWorkspaceRoot));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Throws on a message this receiver must not act on. The daemon catches it and
|
|
44
|
+
* responds with the mismatch; the plugin worker catches it and drops the
|
|
45
|
+
* message. `receiverDescription` names whichever raised it.
|
|
46
|
+
*
|
|
47
|
+
* An unstamped message is deliberately accepted: this is accident detection,
|
|
48
|
+
* not a control.
|
|
49
|
+
*/
|
|
50
|
+
function assertNotForeignWorkspaceMessage(msg, receiverWorkspaceRoot, receiverDescription = `The Nx Daemon for '${receiverWorkspaceRoot}'`) {
|
|
51
|
+
if (isForeignWorkspaceMessage(msg, receiverWorkspaceRoot)) {
|
|
52
|
+
throw new Error(`${receiverDescription} received a message from a different workspace ('${msg.workspaceRoot}') and refused to process it. This usually means multiple workspaces are sharing a socket directory; ensure NX_SOCKET_DIR (or NX_DAEMON_SOCKET_DIR) is not set to a shared location.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -116,6 +116,17 @@ async function handleMessage(socket, data) {
|
|
|
116
116
|
await (0, shutdown_utils_1.respondWithErrorAndExit)(socket, `Invalid payload from the client`, new Error(`Unsupported payload sent to daemon server: ${unparsedPayload}`));
|
|
117
117
|
}
|
|
118
118
|
logger_1.serverLogger.log(`Received ${mode} message of type ${payload.type}`);
|
|
119
|
+
// A mismatch means the client reached the wrong daemon (e.g. a shared
|
|
120
|
+
// NX_SOCKET_DIR). Respond, but stay alive for our own workspace.
|
|
121
|
+
if ((0, daemon_message_1.isDaemonMessage)(payload)) {
|
|
122
|
+
try {
|
|
123
|
+
(0, daemon_message_1.assertNotForeignWorkspaceMessage)(payload, workspace_root_1.workspaceRoot);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
await (0, shutdown_utils_1.respondWithError)(socket, `Workspace root mismatch`, e);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
119
130
|
if ((0, daemon_message_1.isDaemonMessage)(payload) && payload.env) {
|
|
120
131
|
let shouldRecomputeGraph = false;
|
|
121
132
|
for (const key in payload.env) {
|
|
@@ -461,6 +472,17 @@ async function startServer() {
|
|
|
461
472
|
server.listen(socketPath, async () => {
|
|
462
473
|
try {
|
|
463
474
|
logger_1.serverLogger.log(`Started listening on: ${socketPath}`);
|
|
475
|
+
// Linux gates connect() on write permission to the socket file; macOS/BSD gate
|
|
476
|
+
// on the directory, which is already 0700. Done after listen because
|
|
477
|
+
// net.Server.listen takes no mode and umask is process-global.
|
|
478
|
+
if (!socket_utils_1.isWindows) {
|
|
479
|
+
try {
|
|
480
|
+
(0, fs_1.chmodSync)(socketPath, 0o600);
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Best effort; the 0700 socket directory is the primary control.
|
|
484
|
+
}
|
|
485
|
+
}
|
|
464
486
|
// this triggers the storage of the lock file hash
|
|
465
487
|
daemonIsOutdated();
|
|
466
488
|
if (!(0, shutdown_utils_1.getWatcherInstance)()) {
|
|
@@ -14,5 +14,10 @@ export declare function handleServerProcessTermination({ server, reason, sockets
|
|
|
14
14
|
export declare function handleServerProcessTerminationWithRestart({ server, reason, sockets, }: HandleServerProcessTerminationParams): Promise<void>;
|
|
15
15
|
export declare function resetInactivityTimeout(cb: () => void): void;
|
|
16
16
|
export declare function respondToClient(socket: Socket, response: string, description: string): Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Send an error to the client without terminating the daemon. Use this when the
|
|
19
|
+
* daemon should keep running for other clients after refusing a request.
|
|
20
|
+
*/
|
|
21
|
+
export declare function respondWithError(socket: Socket, description: string, error: Error): Promise<void>;
|
|
17
22
|
export declare function respondWithErrorAndExit(socket: Socket, description: string, error: Error): Promise<void>;
|
|
18
23
|
export {};
|
|
@@ -9,6 +9,7 @@ exports.handleServerProcessTermination = handleServerProcessTermination;
|
|
|
9
9
|
exports.handleServerProcessTerminationWithRestart = handleServerProcessTerminationWithRestart;
|
|
10
10
|
exports.resetInactivityTimeout = resetInactivityTimeout;
|
|
11
11
|
exports.respondToClient = respondToClient;
|
|
12
|
+
exports.respondWithError = respondWithError;
|
|
12
13
|
exports.respondWithErrorAndExit = respondWithErrorAndExit;
|
|
13
14
|
const workspace_root_1 = require("../../utils/workspace-root");
|
|
14
15
|
const logger_1 = require("../logger");
|
|
@@ -135,9 +136,12 @@ function respondToClient(socket, response, description) {
|
|
|
135
136
|
});
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Send an error to the client without terminating the daemon. Use this when the
|
|
141
|
+
* daemon should keep running for other clients after refusing a request.
|
|
142
|
+
*/
|
|
143
|
+
async function respondWithError(socket, description, error) {
|
|
144
|
+
const normalizedError = error instanceof error_types_1.DaemonProjectGraphError
|
|
141
145
|
? error_types_1.ProjectGraphError.fromDaemonProjectGraphError(error)
|
|
142
146
|
: error;
|
|
143
147
|
// print some extra stuff in the error message
|
|
@@ -145,8 +149,11 @@ async function respondWithErrorAndExit(socket, description, error) {
|
|
|
145
149
|
console.error(normalizedError.stack);
|
|
146
150
|
// Respond with the original error
|
|
147
151
|
await respondToClient(socket, (0, socket_utils_1.serializeResult)(error, null, null), null);
|
|
152
|
+
}
|
|
153
|
+
async function respondWithErrorAndExit(socket, description, error) {
|
|
154
|
+
await respondWithError(socket, description, error);
|
|
148
155
|
// Project Graph errors are okay. Restarting the daemon won't help with this.
|
|
149
|
-
if (!
|
|
156
|
+
if (!(error instanceof error_types_1.DaemonProjectGraphError)) {
|
|
150
157
|
process.exit(1);
|
|
151
158
|
}
|
|
152
159
|
}
|
|
@@ -8,6 +8,7 @@ export declare const isWindows: boolean;
|
|
|
8
8
|
export declare const getFullOsSocketPath: () => string;
|
|
9
9
|
export declare const getForkedProcessOsSocketPath: (id: string) => string;
|
|
10
10
|
export declare const getPluginOsSocketPath: (id: string) => string;
|
|
11
|
+
export declare function getPluginSocketFileName(id: string): string;
|
|
11
12
|
export declare function killSocketOrPath(): void;
|
|
12
13
|
export declare function serializeResult(error: Error | null, serializedProjectGraph: string | null, serializedSourceMaps: string | null): string | null;
|
|
13
14
|
/**
|