nx 22.7.8 → 22.7.10

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.
Files changed (40) hide show
  1. package/dist/src/command-line/reset/reset.js +6 -1
  2. package/dist/src/daemon/client/client.d.ts +45 -3
  3. package/dist/src/daemon/client/client.js +138 -42
  4. package/dist/src/daemon/client/daemon-socket-messenger.js +4 -0
  5. package/dist/src/daemon/message-types/daemon-message.d.ts +33 -0
  6. package/dist/src/daemon/message-types/daemon-message.js +48 -0
  7. package/dist/src/daemon/server/server.js +22 -0
  8. package/dist/src/daemon/server/shutdown-utils.d.ts +5 -0
  9. package/dist/src/daemon/server/shutdown-utils.js +11 -4
  10. package/dist/src/daemon/socket-utils.d.ts +1 -0
  11. package/dist/src/daemon/socket-utils.js +24 -3
  12. package/dist/src/daemon/tmp-dir.d.ts +43 -3
  13. package/dist/src/daemon/tmp-dir.js +437 -15
  14. package/dist/src/devkit-internals.d.ts +1 -0
  15. package/dist/src/devkit-internals.js +4 -1
  16. package/dist/src/native/index.js +71 -28
  17. package/dist/src/native/native-file-cache-location.d.ts +17 -1
  18. package/dist/src/native/native-file-cache-location.js +86 -12
  19. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  20. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  21. package/dist/src/project-graph/plugins/isolation/isolated-plugin.d.ts +1 -0
  22. package/dist/src/project-graph/plugins/isolation/isolated-plugin.js +18 -1
  23. package/dist/src/project-graph/plugins/isolation/messaging.d.ts +16 -3
  24. package/dist/src/project-graph/plugins/isolation/messaging.js +3 -0
  25. package/dist/src/project-graph/plugins/isolation/plugin-worker.js +58 -8
  26. package/dist/src/project-graph/project-graph.js +20 -0
  27. package/dist/src/utils/nx-tmp-dir.d.ts +18 -0
  28. package/dist/src/utils/nx-tmp-dir.js +40 -0
  29. package/dist/src/utils/owned-private-dir.d.ts +132 -0
  30. package/dist/src/utils/owned-private-dir.js +323 -0
  31. package/dist/src/utils/package-json.js +13 -2
  32. package/dist/src/utils/path.d.ts +6 -0
  33. package/dist/src/utils/path.js +15 -0
  34. package/dist/src/utils/safe-spawn.d.ts +24 -0
  35. package/dist/src/utils/safe-spawn.js +104 -0
  36. package/dist/src/utils/shell-quoting.d.ts +15 -0
  37. package/dist/src/utils/shell-quoting.js +35 -0
  38. package/dist/src/utils/wait-for-socket-connection.d.ts +11 -0
  39. package/dist/src/utils/wait-for-socket-connection.js +14 -8
  40. 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
- (0, node_fs_1.rmSync)((0, native_file_cache_location_1.getNativeFileCacheLocation)(), { recursive: true, force: true });
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
- startInBackground(): Promise<ChildProcess['pid']>;
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
- async isServerAvailable() {
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
- resolve(false);
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 serverAvailable;
712
+ let probe;
707
713
  try {
708
- serverAvailable = await this.isServerAvailable();
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
- serverAvailable = false;
719
+ probe = { available: false };
714
720
  }
715
721
  else {
716
722
  throw err;
717
723
  }
718
724
  }
719
- if (!serverAvailable) {
720
- daemonPid = await this.startInBackground();
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
- logger_1.clientLogger.log(`[Client] Server not available after ${WAIT_FOR_SERVER_CONFIG.maxAttempts} attempts`);
862
- return false;
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
- async startInBackground() {
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
- this._out = await (0, promises_1.open)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE, 'a');
953
- this._err = await (0, promises_1.open)(tmp_dir_1.DAEMON_OUTPUT_LOG_FILE, 'a');
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', this._out.fd, this._err.fd],
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 serverAvailable = await this.waitForServerToBeAvailable({
971
- ignoreVersionMismatch: true,
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
- const error = new Error([
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
- async function respondWithErrorAndExit(socket, description, error) {
139
- const isProjectGraphError = error instanceof error_types_1.DaemonProjectGraphError;
140
- const normalizedError = isProjectGraphError
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 (!isProjectGraphError) {
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
  /**