Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ The following environment variables are ones that you can set to change the beha
| `NX_INTERACTIVE` | boolean | If set to `true`, will allow Nx to prompt you in the terminal to answer some further questions when running generators. |
| `NX_LOAD_DOT_ENV_FILES` | boolean | If set to 'false', Nx will not load any environment files (e.g. `.local.env`, `.env.local`) |
| `NX_MAX_CACHE_SIZE` | string | Alternative to configuring `maxCacheSize` in `nx.json`. Defines the maximum size of the local task cache. See [`maxCacheSize`](/docs/reference/nx-json#max-cache-size) for supported units and behavior details. |
| `NX_MAX_MESSAGE_SIZE` | number | Maximum size, in bytes, of a single message on an Nx socket (daemon, plugin worker, and forked process). Defaults to 2147483648 (2 GiB). A peer that declares a larger message is refused before the payload is buffered. Set to 0 to remove the limit. The daemon reads it at startup, so run `nx reset` after changing it. |
| `NX_MIGRATE_CLI_VERSION` | string | The version of Nx to use for running the `nx migrate` command. If not set, it defaults to `latest`. |
| `NX_MIGRATE_SKIP_INSTALL` | boolean | If set to `true`, `nx migrate --run-migrations` will not automatically perform the installation of the packages. |
| `NX_MIGRATE_USE_LOCAL` | boolean | If set to `true`, will use the locally installed version of `nx` instead of downloading the latest version to run the `nx migrate` command. |
Expand Down Expand Up @@ -239,7 +240,7 @@ The following environment variables are ones that you can set to change the beha
| `NX_SOCKET_DIR` | string | Directory for all Nx sockets (daemon, forked process, and plugin). Used as the socket directory itself, replacing the default locations rather than joining them. Mainly a workaround for a socket path that exceeds the OS length limit, or a default location the environment restricts. Takes precedence over `NX_DAEMON_SOCKET_DIR`. Must name a directory only your user can reach. |
| `NX_TASKS_RUNNER` | string | The name of task runner from the config to use. Can be overridden on the command line with `--runner`. |
| `NX_TASKS_RUNNER_DYNAMIC_OUTPUT` | boolean | If set to `false`, will use non-dynamic terminal output strategy (what you see in CI), even when you terminal can support the dynamic version |
| `NX_USE_V8_SERIALIZER` | boolean | If set to `true`, Nx will use v8 serialization in the pseudo-IPC channel between the daemon and task processes instead of JSON. Improves throughput for workspaces with large task payloads. |
| `NX_USE_V8_SERIALIZER` | boolean | If set to `true`, Nx will use v8 serialization on its socket channels (the daemon client and server, plugin workers, and pseudo-IPC) instead of JSON. Improves throughput for workspaces with large task payloads. |
| `NX_WRAPPER_SKIP_INSTALL` | boolean | If set to `true`, the `.nx/nxw.js` wrapper skips verifying and self-installing the pinned Nx version before each command. |

## Plugin environment variables
Expand Down
96 changes: 87 additions & 9 deletions packages/nx/src/daemon/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
import { getPluginResolveConditionNodeArgs } from '../../plugins/js/utils/typescript';
import { preventRecursionInGraphConstruction } from '../../project-graph/project-graph';
import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration/source-maps';
import { parseMessage } from '../../utils/consume-messages-from-socket';
import {
describeMessage,
MessageFramingError,
parseMessage,
} from '../../utils/consume-messages-from-socket';
import { DelayedSpinner } from '../../utils/delayed-spinner';
import { handleImport } from '../../utils/handle-import';
import { isCI } from '../../utils/is-ci';
Expand Down Expand Up @@ -168,6 +172,12 @@ export class WatcherFailedError extends Error {
}
}

/**
* A framing failure repeats on every redial, so the watcher channels stop
* re-dialing once this many land back to back without a message in between.
*/
const MAX_CONSECUTIVE_FRAMING_FAILURES = 3;

export class DaemonClient {
private readonly nxJson: NxJsonConfiguration | null;

Expand Down Expand Up @@ -200,6 +210,7 @@ export class DaemonClient {
// Shared file watcher connection state
private fileWatcherMessenger: DaemonSocketMessenger | undefined;
private fileWatcherReconnecting: boolean = false;
private fileWatcherFramingFailures = 0;
private fileWatcherCallbacks: Map<
string,
(
Expand All @@ -223,6 +234,7 @@ export class DaemonClient {
// Shared project graph listener connection state
private projectGraphListenerMessenger: DaemonSocketMessenger | undefined;
private projectGraphListenerReconnecting: boolean = false;
private projectGraphListenerFramingFailures = 0;
private projectGraphListenerCallbacks: Map<
string,
(
Expand Down Expand Up @@ -441,6 +453,8 @@ export class DaemonClient {
(message) => {
try {
const parsedMessage = parseMessage<any>(message);
// A delivered message means the stream is healthy again.
this.fileWatcherFramingFailures = 0;
if (parsedMessage?.watcherError) {
const error = new WatcherFailedError(parsedMessage.watcherError);
for (const cb of this.fileWatcherCallbacks.values()) {
Expand Down Expand Up @@ -479,6 +493,12 @@ export class DaemonClient {
for (const cb of this.fileWatcherCallbacks.values()) {
cb(err, null);
}
if (err instanceof MessageFramingError) {
this.fileWatcherFramingFailures++;
}
// Close so 'close' fires and the reconnect path runs; a framing
// failure would otherwise leave this channel silent forever.
this.fileWatcherMessenger?.close();
}
);
this.fileWatcherMessenger.sendMessage({
Expand Down Expand Up @@ -506,6 +526,22 @@ export class DaemonClient {
return;
}

// The concurrency guard above is cleared before this method recurses, so it
// bounds overlap rather than iterations. A framing failure is deterministic
// — re-dialing replays it — so without this the channel would reconnect and
// re-fail forever. Reaching a payload over NX_MAX_MESSAGE_SIZE does exactly
// that on every notification.
if (this.fileWatcherFramingFailures >= MAX_CONSECUTIVE_FRAMING_FAILURES) {
clientLogger.log(
`[FileWatcher] Giving up after ${this.fileWatcherFramingFailures} consecutive framing failures`
);
this.fileWatcherReconnecting = false;
for (const cb of this.fileWatcherCallbacks.values()) {
cb('closed', null);
}
return;
}

if (this.fileWatcherCallbacks.size === 0) {
return; // No callbacks to reconnect
}
Expand Down Expand Up @@ -554,6 +590,8 @@ export class DaemonClient {
(message) => {
try {
const parsedMessage = parseMessage<any>(message);
// A delivered message means the stream is healthy again.
this.fileWatcherFramingFailures = 0;
for (const cb of this.fileWatcherCallbacks.values()) {
cb(null, parsedMessage);
}
Expand All @@ -580,7 +618,12 @@ export class DaemonClient {
}
process.exit(1);
}
// Other errors during reconnection - let retry loop handle
if (err instanceof MessageFramingError) {
this.fileWatcherFramingFailures++;
}
// The retry loop is driven by 'close', which a framing failure does
// not emit, so close explicitly to hand off to it.
this.fileWatcherMessenger?.close();
}
);

Expand Down Expand Up @@ -643,6 +686,8 @@ export class DaemonClient {
(message) => {
try {
const parsedMessage = parseMessage<any>(message);
// A delivered message means the stream is healthy again.
this.projectGraphListenerFramingFailures = 0;
// Notify all callbacks
for (const cb of this.projectGraphListenerCallbacks.values()) {
cb(null, parsedMessage);
Expand Down Expand Up @@ -674,6 +719,10 @@ export class DaemonClient {
for (const cb of this.projectGraphListenerCallbacks.values()) {
cb(err, null);
}
if (err instanceof MessageFramingError) {
this.projectGraphListenerFramingFailures++;
}
this.projectGraphListenerMessenger?.close();
}
);
this.projectGraphListenerMessenger.sendMessage({
Expand All @@ -699,6 +748,22 @@ export class DaemonClient {
return;
}

// See reconnectFileWatcher: a framing failure repeats on every redial, so
// the concurrency guard alone cannot bound it.
if (
this.projectGraphListenerFramingFailures >=
MAX_CONSECUTIVE_FRAMING_FAILURES
) {
clientLogger.log(
`[ProjectGraphListener] Giving up after ${this.projectGraphListenerFramingFailures} consecutive framing failures`
);
this.projectGraphListenerReconnecting = false;
for (const cb of this.projectGraphListenerCallbacks.values()) {
cb('closed', null);
}
return;
}

if (this.projectGraphListenerCallbacks.size === 0) {
return; // No callbacks to reconnect
}
Expand Down Expand Up @@ -748,6 +813,8 @@ export class DaemonClient {
(message) => {
try {
const parsedMessage = parseMessage<any>(message);
// A delivered message means the stream is healthy again.
this.projectGraphListenerFramingFailures = 0;
for (const cb of this.projectGraphListenerCallbacks.values()) {
cb(null, parsedMessage);
}
Expand All @@ -774,7 +841,12 @@ export class DaemonClient {
}
process.exit(1);
}
// Other errors during reconnection - let retry loop handle
if (err instanceof MessageFramingError) {
this.projectGraphListenerFramingFailures++;
}
// The retry loop is driven by 'close', which a framing failure does
// not emit, so close explicitly to hand off to it.
this.projectGraphListenerMessenger?.close();
}
);

Expand Down Expand Up @@ -1131,8 +1203,14 @@ export class DaemonClient {
}
},
(err) => {
// Every recovery path below is keyed on the socket 'close' event, and a
// framing failure emits neither 'close' nor 'error'. Without the
// teardown at the end of this handler the connection stays open and
// permanently deaf, and the next request waits out the keep-alive.
if (!err.message) {
return this.currentReject(daemonProcessException(err.toString()));
this.currentReject(daemonProcessException(err.toString()));
this.socketMessenger?.close();
return;
}

let error: any;
Expand Down Expand Up @@ -1163,6 +1241,7 @@ export class DaemonClient {
error = daemonProcessException(err.toString());
}
this.currentReject(error);
this.socketMessenger?.close();
}
);
}
Expand Down Expand Up @@ -1349,7 +1428,7 @@ export class DaemonClient {
}
}

private handleMessage(serializedResult: string) {
private handleMessage(serializedResult: Buffer) {
try {
performance.mark('result-parse-start-' + this.currentMessage.type);
const parsedResult = parseMessage<any>(serializedResult);
Expand Down Expand Up @@ -1384,10 +1463,9 @@ export class DaemonClient {
return this.currentResolve(parsedResult);
}
} catch (e) {
const endOfResponse =
serializedResult.length > 300
? serializedResult.substring(serializedResult.length - 300)
: serializedResult;
const endOfResponse = describeMessage(serializedResult, {
from: 'end',
});
this.currentReject(
daemonProcessException(
[
Expand Down
25 changes: 25 additions & 0 deletions packages/nx/src/daemon/client/daemon-environment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,22 @@ describe('daemon environment', () => {

expect('NX_WORKSPACE_ROOT_PATH' in getDaemonSpawnEnv()).toBe(false);
});

it('should keep NX_MAX_MESSAGE_SIZE so the daemon starts with the spawning client message limit', () => {
process.env.NX_MAX_MESSAGE_SIZE = '1048576';

expect(getDaemonSpawnEnv().NX_MAX_MESSAGE_SIZE).toBe('1048576');
// not reflected: a later client's value must not govern another
// client's connection, whose parser reads the limit before any client
// env is applied
expect(getDaemonEnv().NX_MAX_MESSAGE_SIZE).toBeUndefined();
});

it('should not add NX_MAX_MESSAGE_SIZE when the client does not have it', () => {
delete process.env.NX_MAX_MESSAGE_SIZE;

expect('NX_MAX_MESSAGE_SIZE' in getDaemonSpawnEnv()).toBe(false);
});
});

describe('applyDaemonEnvFromClient', () => {
Expand Down Expand Up @@ -303,6 +319,15 @@ describe('daemon environment', () => {
expect(process.env.TERM_PROGRAM).toBe('ghostty');
});

it('should keep the spawn-time NX_MAX_MESSAGE_SIZE across clients that do not set it', () => {
process.env.NX_MAX_MESSAGE_SIZE = '1048576';

const changed = applyDaemonEnvFromClient({});

expect(changed).not.toContain('NX_MAX_MESSAGE_SIZE');
expect(process.env.NX_MAX_MESSAGE_SIZE).toBe('1048576');
});

it('should converge after one application of a client env payload', () => {
process.env.ATUIN_SESSION = 'daemon-startup-value';
process.env.JAVA_HOME = '/opt/java-client';
Expand Down
11 changes: 11 additions & 0 deletions packages/nx/src/daemon/client/daemon-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ const DAEMON_ENV_VARS_EXCLUSIONS = new Set([
// resolves its root at startup before any client env is applied. The spawn
// env keeps it so that startup resolution honors the pinned root.
'NX_WORKSPACE_ROOT_PATH',
// The message-size ceiling is read when a connection is accepted, before the
// client's env could apply, so reflecting it would hand one client's value
// to the next client's connection. Pinned at daemon spawn instead; see
// getDaemonSpawnEnv.
'NX_MAX_MESSAGE_SIZE',

// Nx UI/logging vars (don't affect graph structure)
'NX_TUI',
Expand Down Expand Up @@ -321,6 +326,9 @@ export function getDaemonClientEnvGeneration(): number {
* the pin, a root without markers under an ancestor that has them
* resolves to the ancestor and the daemon publishes its socket under the
* wrong workspace.
* - NX_MAX_MESSAGE_SIZE: excluded from reflection (see above), so the value
* here holds for the daemon's whole lifetime. Changing it therefore needs a
* daemon restart (`nx reset`).
*/
export function getDaemonSpawnEnv() {
const env = getDaemonEnv();
Expand All @@ -330,6 +338,9 @@ export function getDaemonSpawnEnv() {
if (process.env.NX_WORKSPACE_ROOT_PATH !== undefined) {
env.NX_WORKSPACE_ROOT_PATH = process.env.NX_WORKSPACE_ROOT_PATH;
}
if (process.env.NX_MAX_MESSAGE_SIZE !== undefined) {
env.NX_MAX_MESSAGE_SIZE = process.env.NX_MAX_MESSAGE_SIZE;
}
return env;
}

Expand Down
Loading
Loading