Skip to content

Commit f31bef7

Browse files
authored
fix(core): make daemon socket path unique per process to prevent race condition (#33580)
## Current Behavior 1. **Socket Race Condition**: All daemon servers listen on the same socket path, causing a race condition where shutting down daemons remove sockets that newly started daemons are listening on. 2. **Daemon Console Check Blocks**: The daemon availability check runs synchronously and blocks the main thread. 3. **Version Mismatch Issues**: Packages using a different nx version than what's installed in the workspace could still use the daemon, leading to potential issues. ## Expected Behavior 1. Each daemon server creates a unique socket path based on its process ID, preventing race conditions. 2. The daemon console check runs in the background without blocking. 3. The daemon is disabled when there's a version mismatch between the running nx and the workspace's installed version. ## Changes ### 1. Unique Daemon Socket Paths - Include `process.pid` in the socket directory hash to make each daemon's path unique - Store the socket path in `server-process.json` so clients know where to connect - Clients read the socket path from the file instead of calculating it ### 2. Backgroundable Daemon Check - Reapplied #33491 which makes the Nx Console install check run on the daemon in the background - This was previously reverted due to the socket race condition (now fixed by change 1.) - Running in background also allows pulling the latest check logic from npm ### 3. Version Mismatch Check - Added `isNxVersionMismatch()` check in `DaemonClient.enabled()` - Created shared utility `is-nx-version-mismatch.ts` for version comparison - Refactored server.ts to use the shared utility - Uses `require.resolve('nx/package.json', { paths: [workspaceRoot] })` to properly resolve the workspace's installed nx version ## Related Issue(s) Fixes daemon socket path race condition and improves daemon reliability.
1 parent 12f42ca commit f31bef7

12 files changed

Lines changed: 579 additions & 110 deletions

File tree

packages/nx/bin/init-local.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { performance } from 'perf_hooks';
33
import { commandsObject } from '../src/command-line/nx-commands';
44
import { WorkspaceTypeAndRoot } from '../src/utils/find-workspace-root';
55
import { stripIndents } from '../src/utils/strip-indents';
6-
import { ensureNxConsoleInstalled } from '../src/utils/nx-console-prompt';
6+
import { daemonClient } from '../src/daemon/client/client';
7+
import { prompt } from 'enquirer';
8+
import { output } from '../src/utils/output';
79

810
/**
911
* Nx is being run inside a workspace.
@@ -33,7 +35,7 @@ export async function initLocal(workspace: WorkspaceTypeAndRoot) {
3335

3436
// Ensure NxConsole is installed if the user has it configured.
3537
try {
36-
await ensureNxConsoleInstalled();
38+
await ensureNxConsoleInstalledViaDaemon();
3739
} catch {}
3840

3941
const command = process.argv[2];
@@ -121,6 +123,51 @@ function shouldDelegateToAngularCLI() {
121123
return commands.indexOf(command) > -1;
122124
}
123125

126+
async function ensureNxConsoleInstalledViaDaemon(): Promise<void> {
127+
// Only proceed if daemon is available
128+
if (!daemonClient.enabled()) {
129+
return;
130+
}
131+
132+
// Get status from daemon
133+
const status = await daemonClient.getNxConsoleStatus();
134+
135+
// If we should prompt the user
136+
if (status.shouldPrompt && process.stdout.isTTY) {
137+
output.log({
138+
title: "Install Nx's official editor extension to:",
139+
bodyLines: [
140+
'- Enable your AI assistant to do more by understanding your workspace',
141+
'- Add IntelliSense for Nx configuration files',
142+
'- Explore your workspace visually',
143+
],
144+
});
145+
146+
try {
147+
const { shouldInstallNxConsole } = await prompt<{
148+
shouldInstallNxConsole: boolean;
149+
}>({
150+
type: 'confirm',
151+
name: 'shouldInstallNxConsole',
152+
message: 'Install Nx Console? (you can uninstall anytime)',
153+
initial: true,
154+
});
155+
156+
// Set preference and install if user said yes
157+
const result = await daemonClient.setNxConsolePreferenceAndInstall(
158+
shouldInstallNxConsole
159+
);
160+
161+
if (result.installed) {
162+
output.log({ title: 'Successfully installed Nx Console!' });
163+
}
164+
} catch (error) {
165+
// User cancelled or error occurred, save preference as false
166+
await daemonClient.setNxConsolePreferenceAndInstall(false);
167+
}
168+
}
169+
}
170+
124171
function handleAngularCLIFallbacks(workspace: WorkspaceTypeAndRoot) {
125172
if (process.argv[2] === 'update' && process.env.FORCE_NG_UPDATE != 'true') {
126173
console.log(

packages/nx/src/daemon/cache.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { existsSync, unlinkSync } from 'node:fs';
22
import { join } from 'path';
33
import { DAEMON_DIR_FOR_CURRENT_WORKSPACE } from './tmp-dir';
44
import { readJsonFile, writeJsonFileAsync } from '../utils/fileutils';
5+
import { nxVersion } from '../utils/versions';
56

67
export interface DaemonProcessJson {
78
processId: number;
9+
socketPath: string;
10+
nxVersion: string;
811
}
912

1013
export const serverProcessJsonPath = join(
@@ -13,10 +16,16 @@ export const serverProcessJsonPath = join(
1316
);
1417

1518
export function readDaemonProcessJsonCache(): DaemonProcessJson | null {
16-
if (!existsSync(serverProcessJsonPath)) {
19+
try {
20+
const daemonJson = readJsonFile(serverProcessJsonPath);
21+
// If the daemon version doesn't match the client version, treat it as stale
22+
if (daemonJson.nxVersion !== nxVersion) {
23+
return null;
24+
}
25+
return daemonJson;
26+
} catch {
1727
return null;
1828
}
19-
return readJsonFile(serverProcessJsonPath);
2029
}
2130

2231
export function deleteDaemonJsonProcessCache(): void {
@@ -35,31 +44,6 @@ export async function writeDaemonJsonProcessCache(
3544
});
3645
}
3746

38-
export async function waitForDaemonToExitAndCleanupProcessJson(): Promise<void> {
39-
const daemonProcessJson = readDaemonProcessJsonCache();
40-
if (daemonProcessJson && daemonProcessJson.processId) {
41-
await new Promise<void>((resolve, reject) => {
42-
let count = 0;
43-
const interval = setInterval(() => {
44-
try {
45-
// sending a signal 0 to a process checks if the process is running instead of actually killing it
46-
process.kill(daemonProcessJson.processId, 0);
47-
} catch (e) {
48-
clearInterval(interval);
49-
resolve();
50-
}
51-
if ((count += 1) > 200) {
52-
clearInterval(interval);
53-
reject(
54-
`Daemon process ${daemonProcessJson.processId} didn't exit after 2 seconds.`
55-
);
56-
}
57-
}, 10);
58-
});
59-
deleteDaemonJsonProcessCache();
60-
}
61-
}
62-
6347
// Must be sync for the help output use case
6448
export function getDaemonProcessIdSync(): number | null {
6549
if (!existsSync(serverProcessJsonPath)) {

packages/nx/src/daemon/client/client.ts

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { connect } from 'net';
1212
import { join } from 'path';
1313
import { performance } from 'perf_hooks';
1414
import { output } from '../../utils/output';
15-
import { getFullOsSocketPath, killSocketOrPath } from '../socket-utils';
1615
import {
1716
DAEMON_DIR_FOR_CURRENT_WORKSPACE,
1817
DAEMON_OUTPUT_LOG_FILE,
@@ -25,10 +24,8 @@ import { hasNxJson, NxJsonConfiguration } from '../../config/nx-json';
2524
import { readNxJson } from '../../config/configuration';
2625
import { PromisedBasedQueue } from '../../utils/promised-based-queue';
2726
import { DaemonSocketMessenger, Message } from './daemon-socket-messenger';
28-
import {
29-
getDaemonProcessIdSync,
30-
waitForDaemonToExitAndCleanupProcessJson,
31-
} from '../cache';
27+
import { getDaemonProcessIdSync, readDaemonProcessJsonCache } from '../cache';
28+
import { isNxVersionMismatch } from '../is-nx-version-mismatch';
3229
import { Hash } from '../../hasher/task-hasher';
3330
import { Task, TaskGraph } from '../../config/task-graph';
3431
import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils';
@@ -100,6 +97,14 @@ import {
10097
PRE_TASKS_EXECUTION,
10198
} from '../message-types/run-tasks-execution-hooks';
10299
import { REGISTER_PROJECT_GRAPH_LISTENER } from '../message-types/register-project-graph-listener';
100+
import {
101+
GET_NX_CONSOLE_STATUS,
102+
type HandleGetNxConsoleStatusMessage,
103+
type HandleSetNxConsolePreferenceAndInstallMessage,
104+
type NxConsoleStatusResponse,
105+
SET_NX_CONSOLE_PREFERENCE_AND_INSTALL,
106+
type SetNxConsolePreferenceAndInstallResponse,
107+
} from '../message-types/nx-console';
103108
import { deserialize } from 'node:v8';
104109
import { isJsonMessage } from '../../utils/consume-messages-from-socket';
105110
import { isV8SerializerEnabled } from '../is-v8-serializer-enabled';
@@ -173,7 +178,9 @@ export class DaemonClient {
173178
// docker=true,env=false => no daemon
174179
// docker=true,env=true => daemon
175180
// WASM => no daemon because file watching does not work
181+
// version mismatch => no daemon because the installed nx version differs from the running one
176182
if (
183+
isNxVersionMismatch() ||
177184
((isCI() || isDocker()) && env !== 'true') ||
178185
isDaemonDisabled() ||
179186
nxJsonIsNotPresent() ||
@@ -216,6 +223,18 @@ export class DaemonClient {
216223
);
217224
}
218225

226+
private getSocketPath(): string {
227+
const daemonProcessJson = readDaemonProcessJsonCache();
228+
229+
if (daemonProcessJson?.socketPath) {
230+
return daemonProcessJson.socketPath;
231+
} else {
232+
throw daemonProcessException(
233+
'Unable to connect to daemon: no socket path available'
234+
);
235+
}
236+
}
237+
219238
async requestShutdown(): Promise<void> {
220239
return this.sendToDaemonViaQueue({ type: 'REQUEST_SHUTDOWN' });
221240
}
@@ -300,10 +319,12 @@ export class DaemonClient {
300319
}
301320
let messenger: DaemonSocketMessenger | undefined;
302321

303-
await this.queue.sendToQueue(() => {
304-
messenger = new DaemonSocketMessenger(
305-
connect(getFullOsSocketPath())
306-
).listen(
322+
await this.queue.sendToQueue(async () => {
323+
await this.startDaemonIfNecessary();
324+
325+
const socketPath = this.getSocketPath();
326+
327+
messenger = new DaemonSocketMessenger(connect(socketPath)).listen(
307328
(message) => {
308329
try {
309330
const parsedMessage = isJsonMessage(message)
@@ -338,10 +359,12 @@ export class DaemonClient {
338359
): Promise<UnregisterCallback> {
339360
let messenger: DaemonSocketMessenger | undefined;
340361

341-
await this.queue.sendToQueue(() => {
342-
messenger = new DaemonSocketMessenger(
343-
connect(getFullOsSocketPath())
344-
).listen(
362+
await this.queue.sendToQueue(async () => {
363+
await this.startDaemonIfNecessary();
364+
365+
const socketPath = this.getSocketPath();
366+
367+
messenger = new DaemonSocketMessenger(connect(socketPath)).listen(
345368
(message) => {
346369
try {
347370
const parsedMessage = isJsonMessage(message)
@@ -552,10 +575,32 @@ export class DaemonClient {
552575
return this.sendToDaemonViaQueue(message);
553576
}
554577

578+
getNxConsoleStatus(): Promise<NxConsoleStatusResponse> {
579+
const message: HandleGetNxConsoleStatusMessage = {
580+
type: GET_NX_CONSOLE_STATUS,
581+
};
582+
return this.sendToDaemonViaQueue(message);
583+
}
584+
585+
setNxConsolePreferenceAndInstall(
586+
preference: boolean
587+
): Promise<SetNxConsolePreferenceAndInstallResponse> {
588+
const message: HandleSetNxConsolePreferenceAndInstallMessage = {
589+
type: SET_NX_CONSOLE_PREFERENCE_AND_INSTALL,
590+
preference,
591+
};
592+
return this.sendToDaemonViaQueue(message);
593+
}
594+
555595
async isServerAvailable(): Promise<boolean> {
556596
return new Promise((resolve) => {
557597
try {
558-
const socket = connect(getFullOsSocketPath(), () => {
598+
const socketPath = this.getSocketPath();
599+
if (!socketPath) {
600+
resolve(false);
601+
return;
602+
}
603+
const socket = connect(socketPath, () => {
559604
socket.destroy();
560605
resolve(true);
561606
});
@@ -568,6 +613,31 @@ export class DaemonClient {
568613
});
569614
}
570615

616+
private async startDaemonIfNecessary() {
617+
if (this._daemonStatus == DaemonStatus.CONNECTED) {
618+
return;
619+
}
620+
// Ensure daemon is running and socket path is available
621+
if (this._daemonStatus == DaemonStatus.DISCONNECTED) {
622+
this._daemonStatus = DaemonStatus.CONNECTING;
623+
624+
let daemonPid: number | null = null;
625+
if (!(await this.isServerAvailable())) {
626+
daemonPid = await this.startInBackground();
627+
}
628+
this.setUpConnection();
629+
this._daemonStatus = DaemonStatus.CONNECTED;
630+
this._daemonReady();
631+
632+
daemonPid ??= getDaemonProcessIdSync();
633+
await this.registerDaemonProcessWithMetricsService(daemonPid);
634+
} else if (this._daemonStatus == DaemonStatus.CONNECTING) {
635+
await this._waitForDaemonReady;
636+
const daemonPid = getDaemonProcessIdSync();
637+
await this.registerDaemonProcessWithMetricsService(daemonPid);
638+
}
639+
}
640+
571641
private async sendToDaemonViaQueue(
572642
messageToDaemon: Message,
573643
force?: 'v8' | 'json'
@@ -578,8 +648,10 @@ export class DaemonClient {
578648
}
579649

580650
private setUpConnection() {
651+
const socketPath = this.getSocketPath();
652+
581653
this.socketMessenger = new DaemonSocketMessenger(
582-
connect(getFullOsSocketPath())
654+
connect(socketPath)
583655
).listen(
584656
(message) => this.handleMessage(message),
585657
() => {
@@ -616,7 +688,6 @@ export class DaemonClient {
616688
error = daemonProcessException(
617689
`A server instance had not been fully shut down. Please try running the command again.`
618690
);
619-
killSocketOrPath();
620691
} else if (err.message.startsWith('read ECONNRESET')) {
621692
error = daemonProcessException(
622693
`Unable to connect to the daemon process.`
@@ -633,24 +704,7 @@ export class DaemonClient {
633704
message: Message,
634705
force?: 'v8' | 'json'
635706
): Promise<any> {
636-
if (this._daemonStatus == DaemonStatus.DISCONNECTED) {
637-
this._daemonStatus = DaemonStatus.CONNECTING;
638-
639-
let daemonPid: number | null = null;
640-
if (!(await this.isServerAvailable())) {
641-
daemonPid = await this.startInBackground();
642-
}
643-
this.setUpConnection();
644-
this._daemonStatus = DaemonStatus.CONNECTED;
645-
this._daemonReady();
646-
647-
daemonPid ??= getDaemonProcessIdSync();
648-
await this.registerDaemonProcessWithMetricsService(daemonPid);
649-
} else if (this._daemonStatus == DaemonStatus.CONNECTING) {
650-
await this._waitForDaemonReady;
651-
const daemonPid = getDaemonProcessIdSync();
652-
await this.registerDaemonProcessWithMetricsService(daemonPid);
653-
}
707+
await this.startDaemonIfNecessary();
654708
// An open promise isn't enough to keep the event loop
655709
// alive, so we set a timeout here and clear it when we hear
656710
// back
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { readJsonFile } from '../utils/fileutils';
2+
import type { PackageJson } from '../utils/package-json';
3+
import { nxVersion } from '../utils/versions';
4+
import { workspaceRoot } from '../utils/workspace-root';
5+
import { getNxRequirePaths } from '../utils/installation-directory';
6+
7+
export function getInstalledNxVersion(): string | null {
8+
try {
9+
const nxPackageJsonPath = require.resolve('nx/package.json', {
10+
paths: getNxRequirePaths(workspaceRoot),
11+
});
12+
const { version } = readJsonFile<PackageJson>(nxPackageJsonPath);
13+
return version;
14+
} catch {
15+
// node modules are absent
16+
return null;
17+
}
18+
}
19+
20+
export function isNxVersionMismatch(): boolean {
21+
return getInstalledNxVersion() !== nxVersion;
22+
}

0 commit comments

Comments
 (0)