Skip to content

Commit b3edd3d

Browse files
AgentEndergraphite-app[bot]nx-cloud[bot]
authored
fix(core): error with helpful error instead of looping nx invocations (#34820)
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: AgentEnder <AgentEnder@users.noreply.github.com>
1 parent b9868f2 commit b3edd3d

9 files changed

Lines changed: 263 additions & 12 deletions

File tree

astro-docs/src/content/docs/reference/environment-variables.mdoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ The following environment variables are ones that you can set to change the beha
5353
| `NX_FORCE_REUSE_CACHED_GRAPH` | boolean | If set to `true`, Nx will reuse an existing cached project graph when available and skip recomputing it. Useful in short-lived CI steps that run immediately after a step which already computed the graph. |
5454
| `NX_FORMAT_SORT_TSCONFIG_PATHS` | boolean | If set to `true`, generators will sort the TypeScript path mappings in the root tsconfig file. |
5555
| `NX_GENERATE_QUIET` | boolean | If set to `true`, will prevent Nx logging file operations during generate |
56+
| `NX_INVOCATION_ROOT_PID` | number | Internal. Set by Nx to the PID of the root Nx process. Used to detect recursive task invocation loops across nested Nx processes. Do not set this manually. |
5657
| `NX_ISOLATE_PLUGINS` | boolean | Forces plugin isolation on or off, overriding the automatic detection. Set to `true` to always run inference plugins in isolated workers, or `false` to always run them in-process. |
5758
| `NX_MIGRATE_INSTALL_CONCURRENCY` | number | Limits the number of concurrent package installs when fetching migration metadata. Useful for avoiding package manager cache conflicts on Windows with private registries. If not set, installs run with no concurrency limit. |
5859
| `NX_MIGRATE_SKIP_REGISTRY_FETCH` | boolean | If set to `true`, will skip fetching metadata from the registry and instead use the installation method directly. |

packages/nx/src/native/db/initialize.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::native::db::connection::NxDbConnection;
22
use crate::native::tasks::details::SCHEMA as TASK_DETAILS_SCHEMA;
33
use crate::native::tasks::running_tasks_service::SCHEMA as RUNNING_TASKS_SCHEMA;
44
use crate::native::tasks::task_history::SCHEMA as TASK_HISTORY_SCHEMA;
5+
use crate::native::tasks::task_invocation_tracker::SCHEMA as TASK_INVOCATIONS_SCHEMA;
56
use rusqlite::vtab::array;
67
use rusqlite::{Connection, OpenFlags};
78
use std::fs::{File, remove_file, write};
@@ -10,7 +11,7 @@ use std::path::{Path, PathBuf};
1011
use tracing::{debug, trace};
1112

1213
/// Bump this ONLY when the database schema changes.
13-
pub const DB_VERSION: &str = "2";
14+
pub const DB_VERSION: &str = "3";
1415

1516
// Error reporting constants - static strings to avoid allocations in error paths
1617
const REPORTING_INSTRUCTIONS_PERSISTENT: &str = "If the issue persists, please help us improve Nx by capturing logs and reporting this issue:\n\
@@ -197,6 +198,7 @@ fn create_all_tables(c: &mut NxDbConnection) -> anyhow::Result<()> {
197198
// Order matters: tables with no FK dependencies first
198199
conn.execute_batch(TASK_DETAILS_SCHEMA)?;
199200
conn.execute_batch(RUNNING_TASKS_SCHEMA)?;
201+
conn.execute_batch(TASK_INVOCATIONS_SCHEMA)?;
200202

201203
// Metadata table (used by telemetry for session tracking)
202204
conn.execute_batch(

packages/nx/src/native/index.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,18 @@ export declare class TaskHasher {
206206
hashPlans(hashPlans: ExternalObject<Record<string, Array<HashInstruction>>>, perTaskEnvs: Record<string, Record<string, string>>, cwd: string, collectTaskInputs?: boolean | undefined | null): Record<string, HashDetails>
207207
}
208208

209+
export declare class TaskInvocationTracker {
210+
constructor(db: ExternalObject<NxDbConnection>, rootPid: number)
211+
/** Register a task as invoked. Throws if the task was already registered (loop detected). */
212+
registerTask(parentPid: number, taskId: string): void
213+
/** Remove a task invocation record after task completes. */
214+
unregisterTask(taskId: string): void
215+
/** Get all invocations for this root_pid, ordered by creation time. */
216+
getInvocationChain(): Array<InvocationRecord>
217+
/** Clean up stale invocations older than 1 day (handles PID recycling). */
218+
cleanupStale(): void
219+
}
220+
209221
export declare class Watcher {
210222
origin: string
211223
/**
@@ -454,6 +466,11 @@ export declare function installNxConsole(): Promise<boolean>
454466

455467
export declare function installNxConsoleForEditor(editor: SupportedEditor): Promise<boolean>
456468

469+
export interface InvocationRecord {
470+
parentPid: number
471+
taskId: string
472+
}
473+
457474
export const IS_WASM: boolean
458475

459476
/**

packages/nx/src/native/native-bindings.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,7 @@ module.exports.RunningTasksService = nativeBinding.RunningTasksService
591591
module.exports.RustPseudoTerminal = nativeBinding.RustPseudoTerminal
592592
module.exports.TaskDetails = nativeBinding.TaskDetails
593593
module.exports.TaskHasher = nativeBinding.TaskHasher
594+
module.exports.TaskInvocationTracker = nativeBinding.TaskInvocationTracker
594595
module.exports.Watcher = nativeBinding.Watcher
595596
module.exports.WorkspaceContext = nativeBinding.WorkspaceContext
596597
module.exports.BatchStatus = nativeBinding.BatchStatus

packages/nx/src/native/tasks/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ pub mod details;
1313
pub mod running_tasks_service;
1414
#[cfg(not(target_arch = "wasm32"))]
1515
pub mod task_history;
16+
#[cfg(not(target_arch = "wasm32"))]
17+
pub mod task_invocation_tracker;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
use crate::native::db::connection::NxDbConnection;
2+
use napi::bindgen_prelude::External;
3+
use rusqlite::params;
4+
use std::sync::{Arc, Mutex};
5+
use tracing::debug;
6+
7+
pub const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS task_invocations (
8+
root_pid INTEGER NOT NULL,
9+
parent_pid INTEGER NOT NULL,
10+
task_id TEXT NOT NULL,
11+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
12+
PRIMARY KEY (root_pid, task_id)
13+
);";
14+
15+
#[napi(object)]
16+
#[derive(Clone, Debug)]
17+
pub struct InvocationRecord {
18+
pub parent_pid: u32,
19+
pub task_id: String,
20+
}
21+
22+
#[napi]
23+
pub struct TaskInvocationTracker {
24+
db: Arc<Mutex<NxDbConnection>>,
25+
root_pid: u32,
26+
}
27+
28+
#[napi]
29+
impl TaskInvocationTracker {
30+
#[napi(constructor)]
31+
pub fn new(
32+
#[napi(ts_arg_type = "ExternalObject<NxDbConnection>")] db: &External<
33+
Arc<Mutex<NxDbConnection>>,
34+
>,
35+
root_pid: u32,
36+
) -> anyhow::Result<Self> {
37+
Ok(Self {
38+
db: Arc::clone(db),
39+
root_pid,
40+
})
41+
}
42+
43+
/// Register a task as invoked. Throws if the task was already registered (loop detected).
44+
#[napi]
45+
pub fn register_task(&self, parent_pid: u32, task_id: String) -> anyhow::Result<()> {
46+
self.db.lock().unwrap().execute(
47+
"INSERT INTO task_invocations (root_pid, parent_pid, task_id) VALUES (?1, ?2, ?3)",
48+
params![self.root_pid, parent_pid, task_id],
49+
)?;
50+
debug!(
51+
"Registered task invocation: root_pid={}, parent_pid={}, task_id={}",
52+
self.root_pid, parent_pid, &task_id
53+
);
54+
Ok(())
55+
}
56+
57+
/// Remove a task invocation record after task completes.
58+
#[napi]
59+
pub fn unregister_task(&self, task_id: String) -> anyhow::Result<()> {
60+
self.db.lock().unwrap().execute(
61+
"DELETE FROM task_invocations WHERE root_pid = ?1 AND task_id = ?2",
62+
params![self.root_pid, task_id],
63+
)?;
64+
debug!(
65+
"Unregistered task invocation: root_pid={}, task_id={}",
66+
self.root_pid, &task_id
67+
);
68+
Ok(())
69+
}
70+
71+
/// Get all invocations for this root_pid, ordered by creation time.
72+
#[napi]
73+
pub fn get_invocation_chain(&self) -> anyhow::Result<Vec<InvocationRecord>> {
74+
let db = self.db.lock().unwrap();
75+
let mut stmt = db.prepare(
76+
"SELECT parent_pid, task_id FROM task_invocations WHERE root_pid = ?1 ORDER BY created_at ASC",
77+
)?;
78+
let records = stmt
79+
.query_map(params![self.root_pid], |row| {
80+
Ok(InvocationRecord {
81+
parent_pid: row.get(0)?,
82+
task_id: row.get(1)?,
83+
})
84+
})?
85+
.collect::<Result<Vec<_>, _>>()?;
86+
Ok(records)
87+
}
88+
89+
/// Clean up stale invocations older than 1 day (handles PID recycling).
90+
#[napi]
91+
pub fn cleanup_stale(&self) -> anyhow::Result<()> {
92+
let deleted = self.db.lock().unwrap().execute(
93+
"DELETE FROM task_invocations WHERE created_at < datetime('now', '-1 day')",
94+
[],
95+
)?;
96+
if deleted > 0 {
97+
debug!("Cleaned up {} stale invocation records", deleted);
98+
}
99+
Ok(())
100+
}
101+
}

packages/nx/src/tasks-runner/task-env.spec.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,73 @@
11
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
4-
import { getEnvFilesForTask, loadAndExpandDotEnvFile } from './task-env';
5-
import { Task } from '../config/task-graph';
64
import { ProjectGraph } from '../config/project-graph';
5+
import { Task } from '../config/task-graph';
6+
import {
7+
getEnvFilesForTask,
8+
getEnvVariablesForTask,
9+
loadAndExpandDotEnvFile,
10+
} from './task-env';
11+
12+
describe('NX_INVOCATION_ROOT_PID', () => {
13+
const originalEnv = process.env;
14+
15+
beforeEach(() => {
16+
process.env = { ...originalEnv };
17+
delete process.env.NX_INVOCATION_ROOT_PID;
18+
});
19+
20+
afterAll(() => {
21+
process.env = originalEnv;
22+
});
23+
24+
function makeTask(
25+
project: string,
26+
target: string,
27+
configuration?: string
28+
): Task {
29+
let id = `${project}:${target}`;
30+
if (configuration) {
31+
id += `:${configuration}`;
32+
}
33+
return {
34+
id,
35+
target: { project, target, configuration },
36+
overrides: {},
37+
outputs: [],
38+
projectRoot: `libs/${project}`,
39+
} as any as Task;
40+
}
41+
42+
it('should set NX_INVOCATION_ROOT_PID to current process PID when no existing root PID', () => {
43+
const task = makeTask('workspace', 'dev');
44+
const env = getEnvVariablesForTask(
45+
task,
46+
{},
47+
'true',
48+
false,
49+
false,
50+
'',
51+
false
52+
);
53+
expect(env.NX_INVOCATION_ROOT_PID).toBe(String(process.pid));
54+
});
55+
56+
it('should preserve NX_INVOCATION_ROOT_PID from parent Nx process', () => {
57+
process.env.NX_INVOCATION_ROOT_PID = '12345';
58+
const task = makeTask('workspace', 'dev');
59+
const env = getEnvVariablesForTask(
60+
task,
61+
{},
62+
'true',
63+
false,
64+
false,
65+
'',
66+
false
67+
);
68+
expect(env.NX_INVOCATION_ROOT_PID).toBe('12345');
69+
});
70+
});
771

872
describe(loadAndExpandDotEnvFile.name, () => {
973
let tempDir: string;

packages/nx/src/tasks-runner/task-env.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Task } from '../config/task-graph';
21
import { config as loadDotEnvFile } from 'dotenv';
32
import { expand } from 'dotenv-expand';
4-
import { workspaceRoot } from '../utils/workspace-root';
53
import { join } from 'node:path';
64
import { ProjectGraph } from '../config/project-graph';
5+
import { Task } from '../config/task-graph';
6+
import { workspaceRoot } from '../utils/workspace-root';
77
import { getEnvPathsForTask } from './task-env-paths';
88

99
export function getEnvVariablesForBatchProcess(
@@ -130,6 +130,10 @@ function getNxEnvVariablesForTask(
130130
env.NX_TERMINAL_CAPTURE_STDERR = 'true';
131131
}
132132

133+
// Pass the root Nx process PID to nested processes for DB-based loop detection.
134+
// The root PID is used as a key in the task_invocations table to track which tasks
135+
// have been invoked across nested Nx processes.
136+
133137
return {
134138
...getNxEnvVariablesForForkedProcess(
135139
forceColor,
@@ -141,6 +145,9 @@ function getNxEnvVariablesForTask(
141145
...env,
142146
// Ensure the TUI does not get spawned within the TUI if ever tasks invoke Nx again
143147
NX_TUI: 'false',
148+
// tracks the root PID for child nx tasks, used to verify nx is infinitely recursing through the same tasks
149+
NX_INVOCATION_ROOT_PID:
150+
process.env.NX_INVOCATION_ROOT_PID ?? String(process.pid),
144151
};
145152
}
146153

0 commit comments

Comments
 (0)