UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

1,006 lines 60.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TaskOrchestrator = void 0; exports.getThreadPoolSize = getThreadPoolSize; const tslib_1 = require("tslib"); const events_1 = require("events"); const fs_1 = require("fs"); const pc = tslib_1.__importStar(require("picocolors")); const path_1 = require("path"); const perf_hooks_1 = require("perf_hooks"); const project_graph_1 = require("../project-graph/project-graph"); const run_commands_impl_1 = require("../executors/run-commands/run-commands.impl"); const hash_task_1 = require("../hasher/hash-task"); const task_graph_utils_1 = require("./task-graph-utils"); const task_hasher_1 = require("../hasher/task-hasher"); const native_1 = require("../native"); const db_connection_1 = require("../utils/db-connection"); const output_1 = require("../utils/output"); const params_1 = require("../utils/params"); const workspace_root_1 = require("../utils/workspace-root"); const exit_codes_1 = require("../utils/exit-codes"); const cache_1 = require("./cache"); const forked_process_task_runner_1 = require("./forked-process-task-runner"); const is_tui_enabled_1 = require("./is-tui-enabled"); const pseudo_terminal_1 = require("./pseudo-terminal"); const output_prefix_1 = require("./running-tasks/output-prefix"); const noop_child_process_1 = require("./running-tasks/noop-child-process"); const task_env_1 = require("./task-env"); const tasks_schedule_1 = require("./tasks-schedule"); const utils_1 = require("./utils"); const shared_running_task_1 = require("./running-tasks/shared-running-task"); class TaskOrchestrator { // endregion internal state constructor(hasher, initiatingProject, initiatingTasks, projectGraph, taskGraph, nxJson, options, bail, daemon, outputStyle, taskGraphForHashing = taskGraph) { this.hasher = hasher; this.initiatingProject = initiatingProject; this.initiatingTasks = initiatingTasks; this.projectGraph = projectGraph; this.taskGraph = taskGraph; this.nxJson = nxJson; this.options = options; this.bail = bail; this.daemon = daemon; this.outputStyle = outputStyle; this.taskGraphForHashing = taskGraphForHashing; this.taskDetails = (0, hash_task_1.getTaskDetails)(); this.cache = (0, cache_1.getCache)(this.options); this.tuiEnabled = (0, is_tui_enabled_1.isTuiEnabled)(); // Derived from projectGraph once — passed to getExecutorForTask / // getCustomHasher so they don't have to re-walk the graph per call. this.projects = (0, project_graph_1.readProjectsConfigurationFromProjectGraph)(this.projectGraph).projects; this.forkedProcessTaskRunner = new forked_process_task_runner_1.ForkedProcessTaskRunner(this.options, this.tuiEnabled); this.runningTasksService = !native_1.IS_WASM ? new native_1.RunningTasksService((0, db_connection_1.getLocalDbConnection)()) : null; this.tasksSchedule = new tasks_schedule_1.TasksSchedule(this.projectGraph, this.projects, this.taskGraph, this.options); // region internal state this.batchEnv = (0, task_env_1.getEnvVariablesForBatchProcess)(this.options.skipNxCache, this.options.captureStderr); this.reverseTaskDeps = (0, utils_1.calculateReverseDeps)(this.taskGraph); this.initializingTaskIds = new Set(this.initiatingTasks.map((t) => t.id)); this.processedTasks = new Map(); this.completedTasks = new Map(); this.waitingForTasks = []; this.pendingDiscreteWorkers = new Set(); this.groups = []; this.continuousTasksStarted = 0; this.bailed = false; this.resolveStopPromise = null; this.stopRequested = false; this.runningContinuousTasks = new Map(); this.runningRunCommandsTasks = new Map(); this.runningDiscreteTasks = new Map(); this.discreteTaskExitHandled = new Map(); this.continuousTaskExitHandled = new Map(); this.cleanupPromise = null; } async init() { this.setupSignalHandlers(); // Init the ForkedProcessTaskRunner, TasksSchedule, and Cache await Promise.all([ this.forkedProcessTaskRunner.init(), this.tasksSchedule.init().then(() => { return this.tasksSchedule.scheduleNextTasks(); }), 'init' in this.cache ? this.cache.init() : null, ]); // Pass estimated timings to TUI after TasksSchedule is initialized if (this.tuiEnabled) { const estimatedTimings = this.tasksSchedule.getEstimatedTaskTimings(); this.options.lifeCycle.setEstimatedTaskTimings(estimatedTimings); } } async run() { await this.init(); perf_hooks_1.performance.mark('task-execution:start'); const { discrete, continuous, total } = getThreadPoolSize(this.options, this.taskGraph); process.stdout.setMaxListeners(total + events_1.defaultMaxListeners); process.stderr.setMaxListeners(total + events_1.defaultMaxListeners); process.setMaxListeners(total + events_1.defaultMaxListeners); const doNotSkipCache = this.options.skipNxCache === false || this.options.skipNxCache === undefined; // Start continuous task loops (these run independently) const continuousLoops = []; for (let i = 0; i < continuous; ++i) { continuousLoops.push(this.executeContinuousTaskLoop(continuous)); } // Set up forced shutdown handler const shutdownPromise = this.tuiEnabled ? new Promise((resolve) => { this.options.lifeCycle.registerForcedShutdownCallback(() => { this.stopRequested = true; resolve(undefined); }); }) : new Promise((resolve) => { this.resolveStopPromise = resolve; }); const coordinatorLoop = this.executeCoordinatorLoop(doNotSkipCache, discrete); await Promise.race([ Promise.all([coordinatorLoop, ...continuousLoops]), shutdownPromise, ]); perf_hooks_1.performance.mark('task-execution:end'); perf_hooks_1.performance.measure('task-execution', 'task-execution:start', 'task-execution:end'); if (!this.stopRequested) { this.cache.removeOldCacheRecords(); } await this.cleanup(); // Public API (defaultTasksRunner) returns a plain object keyed by // task id. Internal state is a Map for faster lookup. return Object.fromEntries(this.completedTasks); } nextBatch() { return this.tasksSchedule.nextBatch(); } /** * Coordinator loop. All batch operations (hashing, cache resolution) * happen on this single thread — no races. Cache misses are dispatched * as fire-and-forget workers. Workers signal completion via * scheduleNextTasksAndReleaseThreads which wakes all waiting loops. * * Safety: the dispatch phase (step 5) is fully synchronous — no * worker can run during it. So all tasks picked up by nextTask() * are guaranteed to be in processedTasks from step 1. */ async executeCoordinatorLoop(doNotSkipCache, parallelism) { while (true) { if (this.bailed || this.stopRequested) break; // 1. Hash BEFORE processAll so processTask sees hashes set, and so // resolveCachedTasksBulk can look them up in the cache. Each task // is hashed with its own task-specific env (project/target .env // files, custom hasher env reads) — the shared batchEnv would // compute a different cache key than the single-task path and // risk stale cache reuse after env changes. { const { scheduledTasks } = this.tasksSchedule.getAllScheduledTasks(); const unhashed = scheduledTasks .map((id) => this.taskGraph.tasks[id]) .filter((t) => !t.hash && this.taskGraph.dependencies[t.id].every((depId) => this.completedTasks.has(depId))); if (unhashed.length > 0) { const perTaskEnvs = {}; for (const task of unhashed) { perTaskEnvs[task.id] = (0, task_env_1.getTaskSpecificEnv)(task, this.projectGraph); } await (0, hash_task_1.hashTasks)(this.hasher, this.projectGraph, this.taskGraphForHashing, perTaskEnvs, this.taskDetails, unhashed); } } // 2. Bulk-resolve cache hits before processTask — avoids N // lifecycle calls for tasks that will be resolved from cache. if (doNotSkipCache) { const resolved = await this.resolveCachedTasksBulk(); if (resolved) continue; } // 3. Process remaining scheduled tasks (cache misses + non-cacheable). this.processAllScheduledTasks(); // 4. Handle batch executors const batch = this.nextBatch(); if (batch) { const groupId = this.closeGroup(); await this.applyFromCacheOrRunBatch(doNotSkipCache, batch, groupId); this.openGroup(groupId); continue; } // 5. Dispatch cache misses as individual workers while (this.pendingDiscreteWorkers.size < parallelism) { const task = this.tasksSchedule.nextTask((t) => !t.continuous); if (!task) break; const groupId = this.closeGroup(); this.dispatchDiscreteWorker(doNotSkipCache, task, groupId); } // 6. Nothing left to dispatch and nothing in flight — done. if (!this.tasksSchedule.hasTasks() && this.pendingDiscreteWorkers.size === 0) { break; } // 7. Wait for a worker to finish (woken by scheduleNextTasksAndReleaseThreads) await new Promise((res) => this.waitingForTasks.push(res)); } } async executeContinuousTaskLoop(continuousTaskCount) { while (true) { // completed all the tasks if (!this.tasksSchedule.hasTasks() || this.bailed || this.stopRequested) { return null; } this.processAllScheduledTasks(); const task = this.tasksSchedule.nextTask((t) => t.continuous); if (task) { // Use a separate groupId space (parallel..parallel+N) so continuous tasks // don't consume discrete group slots const groupId = this.options.parallel + this.continuousTasksStarted++; const runningTask = await this.startContinuousTask(task, groupId); if (this.initializingTaskIds.has(task.id)) { await this.continuousTaskExitHandled.get(task.id); } // all continuous tasks have been started, thread can exit if (this.continuousTasksStarted >= continuousTaskCount) { return null; } continue; } // all continuous tasks have been started, thread can exit if (this.continuousTasksStarted >= continuousTaskCount) { return null; } // block until some other task completes, then try again await new Promise((res) => this.waitingForTasks.push(res)); } } // region Processing Scheduled Tasks async processTask(taskId) { const task = this.taskGraph.tasks[taskId]; const taskSpecificEnv = (0, task_env_1.getTaskSpecificEnv)(task, this.projectGraph); if (!task.hash) { await (0, hash_task_1.hashTask)(this.hasher, this.projectGraph, this.taskGraphForHashing, task, taskSpecificEnv, this.taskDetails); } await this.options.lifeCycle.scheduleTask(task); return taskSpecificEnv; } processAllScheduledTasks() { const { scheduledTasks } = this.tasksSchedule.getAllScheduledTasks(); for (const taskId of scheduledTasks) { // Task is already handled or being handled if (!this.processedTasks.has(taskId)) { this.processedTasks.set(taskId, this.processTask(taskId)); } } } // endregion Processing Scheduled Tasks // region Applying Cache async applyCachedResults(tasks) { const cacheableTasks = tasks.filter((t) => (0, utils_1.isCacheableTask)(t, this.options)); if (cacheableTasks.length === 0) return []; const cacheHits = await this.fetchCacheHits(cacheableTasks); if (cacheHits.length === 0) return []; return this.finalizeCacheHits(cacheHits); } /** * Batch cache lookup + filter to successful entries. Handles both * local (one rarray SQL call) and remote (parallel HTTP retrievals) * inside DbCache.getBatch. */ async fetchCacheHits(tasks) { const batchResults = await this.cache.getBatch(tasks); const cacheHits = []; for (const task of tasks) { const cachedResult = batchResults.get(task.hash); if (cachedResult && cachedResult.code === 0) { cacheHits.push({ task, cachedResult }); } } return cacheHits; } /** * For each confirmed cache hit: decide whether to copy outputs from * the cache (skipping if the on-disk outputs already match the * recorded hash), copy in parallel, derive the task status, print * terminal output, and return the assembled results. */ async finalizeCacheHits(cacheHits) { // Batch-check which tasks need outputs copied from cache. Remote // cache entries come pre-restored to their output dirs when the // db cache is on, so we only check ones that aren't. const usingDbCache = (0, cache_1.dbCacheEnabled)(); const tasksNeedingOutputCheck = cacheHits.filter(({ task, cachedResult }) => task.outputs.length > 0 && (!cachedResult.remote || !usingDbCache)); const shouldCopyMap = await this.shouldCopyOutputsFromCacheBatch(tasksNeedingOutputCheck.map(({ task }) => ({ outputs: task.outputs, hash: task.hash, }))); // Copy outputs in parallel for tasks that need it. await Promise.all(cacheHits.map(async ({ task, cachedResult }) => { if (shouldCopyMap.get(task.hash)) { await this.cache.copyFilesFromCache(task.hash, cachedResult, task.outputs); } })); // Derive status, print terminal output, build results. const results = []; for (const { task, cachedResult } of cacheHits) { const shouldCopy = shouldCopyMap.get(task.hash) ?? false; const status = cachedResult.remote ? 'remote-cache' : shouldCopy ? 'local-cache' : 'local-cache-kept-existing'; this.options.lifeCycle.printTaskTerminalOutput(task, status, cachedResult.terminalOutput); results.push({ task, code: cachedResult.code, status, terminalOutput: cachedResult.terminalOutput, }); } return results; } /** * Coordinator wrapper around {@link resolveCachedTasks}: peeks at * scheduledTasks (without removing anything from the schedule), * filters to cacheable hashed discrete candidates, and delegates the * cache fetch + lifecycle to the public method. Returns true if any * tasks were resolved. * * The coordinator relies on this running unconditionally (when cache * is enabled): tasks dispatched in step 5 via runTaskDirectly skip * their own cache lookup on the assumption that this has already * confirmed them as misses. Don't add length-based bails. */ async resolveCachedTasksBulk() { const { scheduledTasks } = this.tasksSchedule.getAllScheduledTasks(); const candidates = []; for (const id of scheduledTasks) { const task = this.taskGraph.tasks[id]; if (task.hash && !task.continuous && (0, utils_1.isCacheableTask)(task, this.options)) { candidates.push(task); } } if (candidates.length === 0) return false; // postRunSteps → complete() → tasksSchedule.complete() will filter // resolved hits out of scheduledTasks before we return, so there's // no need to mutate the schedule here. const groupId = this.closeGroup(); try { const results = await this.resolveCachedTasks(true, candidates, groupId); return results.length > 0; } finally { this.openGroup(groupId); } } // endregion Applying Cache // region Batch /** * Hash all batch tasks and resolve cache hits topologically. * * Walks the task graph level by level. Every task gets a preliminary hash * (so startTasks always has a valid hash for Cloud). Tasks with depsOutputs * whose deps weren't cached are ineligible for cache lookup but still * receive a preliminary hash — they'll be re-hashed after execution. */ async applyBatchCachedResults(batch, doNotSkipCache, groupId) { const cachedResults = []; const needsRehashAfterExecution = new Set(); const tasks = Object.values(batch.taskGraph.tasks); if (!doNotSkipCache) { // Cache skipped — just hash so startTasks has valid hashes await this.hashBatchTasks(tasks); return { cachedResults, needsRehashAfterExecution }; } const nonCachedTaskIds = new Set(); await (0, task_graph_utils_1.walkTaskGraph)(batch.taskGraph, async (rootTaskIds) => { const rootTasks = rootTaskIds.map((id) => batch.taskGraph.tasks[id]); await this.hashBatchTasks(rootTasks); const eligible = []; for (const task of rootTasks) { const depIds = batch.taskGraph.dependencies[task.id]; const hasNonCachedDep = depIds.some((id) => nonCachedTaskIds.has(id)); if (hasNonCachedDep && (0, task_hasher_1.getInputs)(task, this.projectGraph, this.nxJson).depsOutputs.length > 0) { nonCachedTaskIds.add(task.id); needsRehashAfterExecution.add(task.id); } else { eligible.push(task); } } if (eligible.length > 0) { const cacheResults = await this.applyCachedResults(eligible); const cachedIds = new Set(cacheResults.map((r) => r.task.id)); cachedResults.push(...cacheResults); if (cacheResults.length > 0) { const cachedTasks = cacheResults.map((r) => r.task); await Promise.all(cachedTasks.map((task) => this.options.lifeCycle.scheduleTask(task))); await this.preRunSteps(cachedTasks, { groupId }); await this.postRunSteps(cacheResults, doNotSkipCache, { groupId }); } for (const task of eligible) { if (!cachedIds.has(task.id)) { nonCachedTaskIds.add(task.id); } } } }); return { cachedResults, needsRehashAfterExecution }; } async hashBatchTasks(tasks) { // Batch executors run every task in the same forked process, but // each task still has its own .env files / custom-hasher env — use // task-specific env for hashing so the cache key matches the // single-task path. const perTaskEnvs = {}; for (const task of tasks) { perTaskEnvs[task.id] = (0, task_env_1.getTaskSpecificEnv)(task, this.projectGraph); } await (0, hash_task_1.hashTasks)(this.hasher, this.projectGraph, this.taskGraphForHashing, perTaskEnvs, this.taskDetails, tasks); } async applyFromCacheOrRunBatch(doNotSkipCache, batch, groupId) { const applyFromCacheOrRunBatchStart = perf_hooks_1.performance.mark('TaskOrchestrator-apply-from-cache-or-run-batch:start'); const taskEntries = Object.entries(batch.taskGraph.tasks); const tasks = taskEntries.map(([, task]) => task); this.options.lifeCycle.registerRunningBatch?.(batch.id, { executorName: batch.executorName, taskIds: Object.keys(batch.taskGraph.tasks), }); const { cachedResults, needsRehashAfterExecution } = await this.applyBatchCachedResults(batch, doNotSkipCache, groupId); // Schedule and start non-cached tasks (cached tasks were already // started and completed inside applyBatchCachedResults) const cachedTaskIds = new Set(cachedResults.map((r) => r.task.id)); const nonCachedTasks = tasks.filter((t) => !cachedTaskIds.has(t.id)); if (nonCachedTasks.length > 0) { await Promise.all(nonCachedTasks.map((task) => this.options.lifeCycle.scheduleTask(task))); await this.preRunSteps(nonCachedTasks, { groupId }); } // Phase 2: Run non-cached tasks, then re-hash depsOutputs tasks const taskIdsToSkip = cachedResults.map((r) => r.task.id); let batchResults = []; if (taskIdsToSkip.length < tasks.length) { const runGraph = (0, utils_1.removeTasksFromTaskGraph)(batch.taskGraph, taskIdsToSkip); batchResults = await this.runBatch({ id: batch.id, executorName: batch.executorName, taskGraph: runGraph, }, this.batchEnv, groupId); // Re-hash depsOutputs tasks — their dep outputs are now on disk const tasksToRehash = batchResults .filter((r) => needsRehashAfterExecution.has(r.task.id) && (r.status === 'success' || r.status === 'failure')) .map((r) => r.task); if (tasksToRehash.length > 0) { await this.hashBatchTasks(tasksToRehash); } } if (batchResults.length > 0) { await this.postRunSteps(batchResults, doNotSkipCache, { groupId }); } // Update batch status based on all task results const hasFailures = taskEntries.some(([taskId]) => { const status = this.completedTasks.get(taskId); return status === 'failure' || status === 'skipped'; }); this.options.lifeCycle.setBatchStatus?.(batch.id, hasFailures ? "Failure" /* BatchStatus.Failure */ : "Success" /* BatchStatus.Success */); this.forkedProcessTaskRunner.cleanUpBatchProcesses(); const tasksCompleted = taskEntries.filter(([taskId]) => this.completedTasks.has(taskId)); // Batch is still not done, run it again if (tasksCompleted.length !== taskEntries.length) { await this.applyFromCacheOrRunBatch(doNotSkipCache, { id: batch.id, executorName: batch.executorName, taskGraph: (0, utils_1.removeTasksFromTaskGraph)(batch.taskGraph, tasksCompleted.map(([taskId]) => taskId)), }, groupId); } // Batch is done, mark it as completed const applyFromCacheOrRunBatchEnd = perf_hooks_1.performance.mark('TaskOrchestrator-apply-from-cache-or-run-batch:end'); perf_hooks_1.performance.measure('TaskOrchestrator-apply-from-cache-or-run-batch', applyFromCacheOrRunBatchStart.name, applyFromCacheOrRunBatchEnd.name); return [...cachedResults, ...batchResults]; } async runBatch(batch, env, groupId) { const runBatchStart = perf_hooks_1.performance.mark('TaskOrchestrator-run-batch:start'); try { const batchProcess = await this.forkedProcessTaskRunner.forkProcessForBatch(batch, this.projectGraph, this.taskGraph, env); // Stream output from batch process to the batch batchProcess.onOutput((output) => { this.options.lifeCycle.appendBatchOutput?.(batch.id, output); }); // Stream task results as they complete // Heavy operations (caching, scheduling, complete) happen at batch-end in postRunSteps batchProcess.onTaskResults((taskId, result) => { const task = this.taskGraph.tasks[taskId]; const status = result.success ? 'success' : 'failure'; this.options.lifeCycle.printTaskTerminalOutput(task, status, result.terminalOutput ?? ''); if (result.terminalOutput) { this.options.lifeCycle.appendTaskOutput(taskId, result.terminalOutput, false); } task.startTime = result.startTime; task.endTime = result.endTime; if (result.startTime && result.endTime) { this.options.lifeCycle.setTaskTiming?.(taskId, result.startTime, result.endTime); } this.options.lifeCycle.setTaskStatus(taskId, (0, native_1.parseTaskStatus)(status)); }); const results = await batchProcess.getResults(); const batchResultEntries = Object.entries(results); return batchResultEntries.map(([taskId, result]) => { const task = this.taskGraph.tasks[taskId]; task.startTime = result.startTime; task.endTime = result.endTime; return { code: result.success ? 0 : 1, task, status: (result.success ? 'success' : 'failure'), terminalOutput: result.terminalOutput, }; }); } catch (e) { const isBatchStopping = this.stopRequested; return Object.keys(batch.taskGraph.tasks).map((taskId) => { const task = this.taskGraph.tasks[taskId]; if (isBatchStopping) { task.endTime = Date.now(); } return { task, code: 1, status: (isBatchStopping ? 'stopped' : 'failure'), terminalOutput: isBatchStopping ? '' : (e.stack ?? e.message ?? ''), }; }); } finally { const runBatchEnd = perf_hooks_1.performance.mark('TaskOrchestrator-run-batch:end'); perf_hooks_1.performance.measure('TaskOrchestrator-run-batch', runBatchStart.name, runBatchEnd.name); } } // endregion Batch // region Single Task /** * Bulk-resolve cache hits for a set of tasks: fetch cached entries, * copy outputs as needed, fire lifecycle, and return the TaskResults * for the hits. Tasks that aren't in the cache (or aren't cacheable) * are silently omitted from the return value — callers are responsible * for running those via {@link runTaskDirectly}. * * Fires scheduleTask lifecycle for hits that haven't been through * processAllScheduledTasks yet. That's a coordinator gap-filler and * a no-op for callers that pre-process the schedule. * * The caller provides `groupId` — cache hits share one slot since they * don't actually compete for parallelism. */ async resolveCachedTasks(doNotSkipCache, tasks, groupId) { if (!doNotSkipCache || tasks.length === 0) return []; const cacheableTasks = tasks.filter((t) => (0, utils_1.isCacheableTask)(t, this.options)); if (cacheableTasks.length === 0) return []; // Wait for any queued processTask promises to settle so task.hash is // populated before cache.getBatch maps it into a Rust String. await Promise.all(cacheableTasks.map((t) => this.processedTasks.get(t.id))); const cacheHits = await this.fetchCacheHits(cacheableTasks); if (cacheHits.length === 0) return []; // scheduleTask lifecycle for hits the coordinator resolved before // processAllScheduledTasks could fire it. No-op for callers that // already ran processAllScheduledTasks (every hit is in processedTasks). await Promise.all(cacheHits .filter(({ task }) => !this.processedTasks.has(task.id)) .map(({ task }) => this.options.lifeCycle.scheduleTask(task))); const hitTasks = cacheHits.map((h) => h.task); await this.preRunSteps(hitTasks, { groupId }); const results = await this.finalizeCacheHits(cacheHits); await this.postRunSteps(results, doNotSkipCache, { groupId }); return results; } /** * Fire a discrete-task worker and track it in pendingDiscreteWorkers until * it settles. Uses runTaskDirectly (not applyFromCacheOrRun*) because * resolveCachedTasksBulk already confirmed this task is a cache miss — * another lookup would re-query the DB and (for Nx Cloud users) repeat * the remote HTTP retrieval. */ dispatchDiscreteWorker(doNotSkipCache, task, groupId) { const worker = this.runTaskDirectly(doNotSkipCache, task, groupId) .catch((e) => this.handleDiscreteWorkerFailure(doNotSkipCache, task, groupId, e)) .finally(() => { this.openGroup(groupId); this.pendingDiscreteWorkers.delete(worker); // Wake coordinator — the delete above may satisfy the exit condition // (pendingDiscreteWorkers.size === 0) that was missed when // scheduleNextTasksAndReleaseThreads fired earlier. this.waitingForTasks.forEach((f) => f(null)); this.waitingForTasks.length = 0; }); this.pendingDiscreteWorkers.add(worker); } /** * Route a worker rejection (e.g. remote cache errors) through the normal * failure path instead of letting it become an unhandled promise. Guard * against double-finalize: completeTasks() populates `completedTasks`, * so a rejection arriving after postRunSteps has already finalized the * task must not run postRunSteps again. */ async handleDiscreteWorkerFailure(doNotSkipCache, task, groupId, e) { if (this.completedTasks.has(task.id)) return; const terminalOutput = e?.message ?? ''; this.options.lifeCycle.printTaskTerminalOutput(task, 'failure', terminalOutput); await this.postRunSteps([{ task, status: 'failure', terminalOutput }], doNotSkipCache, { groupId }); } /** * Spawn and wait on a task's child process, unconditionally — no cache * lookup. Callers must have already confirmed the task is a cache miss * (or disabled caching entirely). */ async runTaskDirectly(doNotSkipCache, task, groupId) { // Wait for task to be processed const taskSpecificEnv = await this.processedTasks.get(task.id); await this.preRunSteps([task], { groupId }); const pipeOutput = await this.pipeOutputCapture(task); const temporaryOutputPath = this.cache.temporaryOutputPath(task); const streamOutput = this.outputStyle === 'static' ? false : (0, utils_1.shouldStreamOutput)(task, this.initiatingProject); const env = pipeOutput ? (0, task_env_1.getEnvVariablesForTask)(task, taskSpecificEnv, process.env.FORCE_COLOR === undefined ? 'true' : process.env.FORCE_COLOR, this.options.skipNxCache, this.options.captureStderr, null, null) : (0, task_env_1.getEnvVariablesForTask)(task, taskSpecificEnv, undefined, this.options.skipNxCache, this.options.captureStderr, temporaryOutputPath, streamOutput); let resolveDiscreteExit; const discreteExitHandled = new Promise((r) => (resolveDiscreteExit = r)); this.discreteTaskExitHandled.set(task.id, discreteExitHandled); const childProcess = await this.runTask(task, streamOutput, env, temporaryOutputPath, pipeOutput); this.runningDiscreteTasks.set(task.id, { runningTask: childProcess, stopping: false, }); const { code, terminalOutput } = await childProcess.getResults(); const isStopping = this.runningDiscreteTasks.get(task.id)?.stopping ?? false; this.runningDiscreteTasks.delete(task.id); const result = { task, code, status: isStopping ? 'stopped' : code === 0 ? 'success' : 'failure', terminalOutput, }; try { await this.postRunSteps([result], doNotSkipCache, { groupId }); } finally { this.discreteTaskExitHandled.delete(task.id); resolveDiscreteExit(); } return result; } async runTask(task, streamOutput, env, temporaryOutputPath, pipeOutput) { const shouldPrefix = streamOutput && process.env.NX_PREFIX_OUTPUT === 'true' && !this.tuiEnabled; const targetConfiguration = (0, utils_1.getTargetConfigurationForTask)(task, this.projectGraph); if (process.env.NX_RUN_COMMANDS_DIRECTLY !== 'false' && targetConfiguration.executor === 'nx:run-commands') { try { const { schema } = (0, utils_1.getExecutorForTask)(task, this.projects); const combinedOptions = (0, params_1.combineOptionsForExecutor)(task.overrides, task.target.configuration ?? targetConfiguration.defaultConfiguration, targetConfiguration, schema, task.target.project, (0, path_1.relative)(task.projectRoot ?? workspace_root_1.workspaceRoot, process.cwd()), process.env.NX_VERBOSE_LOGGING === 'true'); if (combinedOptions.env) { env = { ...env, ...combinedOptions.env, }; } if (streamOutput) { const args = (0, utils_1.getPrintableCommandArgsForTask)(task); output_1.output.logCommand(args.join(' ')); } const runCommandsOptions = { ...combinedOptions, env, usePty: this.tuiEnabled || (!this.tasksSchedule.hasTasks() && this.runningContinuousTasks.size === 0), streamOutput: streamOutput && !shouldPrefix, }; const runningTask = await (0, run_commands_impl_1.runCommands)(runCommandsOptions, { root: workspace_root_1.workspaceRoot, // only root is needed in runCommands }, task.id); this.runningRunCommandsTasks.set(task.id, runningTask); runningTask.onExit(() => { this.runningRunCommandsTasks.delete(task.id); }); if (shouldPrefix) { const color = (0, output_prefix_1.getColor)(task.target.project); const formattedPrefix = pc.bold(color(`${task.target.project}:`)); runningTask.onOutput((chunk) => { (0, output_prefix_1.writePrefixedLines)(chunk, formattedPrefix); }); } else if (this.tuiEnabled) { if (runningTask instanceof pseudo_terminal_1.PseudoTtyProcess) { // This is an external of a the pseudo terminal where a task is running and can be passed to the TUI this.options.lifeCycle.registerRunningTask(task.id, runningTask.getParserAndWriter()); runningTask.onOutput((output) => { this.options.lifeCycle.appendTaskOutput(task.id, output, true); }); } else { this.options.lifeCycle.registerRunningTaskWithEmptyParser(task.id); runningTask.onOutput((output) => { this.options.lifeCycle.appendTaskOutput(task.id, output, false); }); } } if (!streamOutput && !shouldPrefix) { // TODO: shouldn't this be checking if the task is continuous before writing anything to disk or calling printTaskTerminalOutput? runningTask.onExit((code, terminalOutput) => { this.options.lifeCycle.printTaskTerminalOutput(task, code === 0 ? 'success' : 'failure', terminalOutput); (0, fs_1.writeFileSync)(temporaryOutputPath, terminalOutput); }); } return runningTask; } catch (e) { if (process.env.NX_VERBOSE_LOGGING === 'true') { console.error(e); } else { console.error(e.message); } const terminalOutput = e.stack ?? e.message ?? ''; (0, fs_1.writeFileSync)(temporaryOutputPath, terminalOutput); return new noop_child_process_1.NoopChildProcess({ code: 1, terminalOutput, }); } } else if (targetConfiguration.executor === 'nx:noop') { (0, fs_1.writeFileSync)(temporaryOutputPath, ''); return new noop_child_process_1.NoopChildProcess({ code: 0, terminalOutput: '', }); } else { // cache prep const runningTask = await this.runTaskInForkedProcess(task, env, pipeOutput, temporaryOutputPath, streamOutput); if (this.tuiEnabled) { if (runningTask instanceof pseudo_terminal_1.PseudoTtyProcess) { // This is an external of a the pseudo terminal where a task is running and can be passed to the TUI this.options.lifeCycle.registerRunningTask(task.id, runningTask.getParserAndWriter()); runningTask.onOutput((output) => { this.options.lifeCycle.appendTaskOutput(task.id, output, true); }); } else if ('onOutput' in runningTask && typeof runningTask.onOutput === 'function') { // Register task that can provide progressive output but isn't interactive (e.g., NodeChildProcessWithNonDirectOutput) this.options.lifeCycle.registerRunningTaskWithEmptyParser(task.id); runningTask.onOutput((output) => { this.options.lifeCycle.appendTaskOutput(task.id, output, false); }); } else { // Fallback for tasks that don't support progressive output this.options.lifeCycle.registerRunningTaskWithEmptyParser(task.id); } } return runningTask; } } async runTaskInForkedProcess(task, env, pipeOutput, temporaryOutputPath, streamOutput) { try { const usePtyFork = process.env.NX_NATIVE_COMMAND_RUNNER !== 'false'; // Disable the pseudo terminal if this is a run-many or when running a continuous task as part of a run-one const disablePseudoTerminal = !this.tuiEnabled && (!this.initiatingProject || task.continuous); // execution const childProcess = usePtyFork ? await this.forkedProcessTaskRunner.forkProcess(task, { temporaryOutputPath, streamOutput, pipeOutput, taskGraph: this.taskGraph, env, disablePseudoTerminal, }) : await this.forkedProcessTaskRunner.forkProcessLegacy(task, { temporaryOutputPath, streamOutput, pipeOutput, taskGraph: this.taskGraph, env, }); return childProcess; } catch (e) { if (process.env.NX_VERBOSE_LOGGING === 'true') { console.error(e); } return new noop_child_process_1.NoopChildProcess({ code: 1, terminalOutput: e.stack ?? e.message ?? '', }); } } async startContinuousTask(task, groupId) { if (this.runningTasksService && this.runningTasksService.getRunningTasks([task.id]).length) { await this.preRunSteps([task], { groupId }); if (this.tuiEnabled) { this.options.lifeCycle.setTaskStatus(task.id, 8 /* NativeTaskStatus.Shared */); } const runningTask = new shared_running_task_1.SharedRunningTask(this.runningTasksService, task.id); this.runningContinuousTasks.set(task.id, { runningTask, groupId, ownsRunningTasksService: false, }); this.continuousTaskExitHandled.set(task.id, new Promise((resolve) => { runningTask.onExit(async (code) => { await this.handleContinuousTaskExit(code, task, groupId, false); resolve(); }); })); // task is already running by another process, we schedule the next tasks // and release the threads await this.scheduleNextTasksAndReleaseThreads(); return runningTask; } const taskSpecificEnv = await this.processedTasks.get(task.id); await this.preRunSteps([task], { groupId }); const pipeOutput = await this.pipeOutputCapture(task); // obtain metadata const temporaryOutputPath = this.cache.temporaryOutputPath(task); const streamOutput = this.outputStyle === 'static' ? false : (0, utils_1.shouldStreamOutput)(task, this.initiatingProject); let env = pipeOutput ? (0, task_env_1.getEnvVariablesForTask)(task, taskSpecificEnv, process.env.FORCE_COLOR === undefined ? 'true' : process.env.FORCE_COLOR, this.options.skipNxCache, this.options.captureStderr, null, null) : (0, task_env_1.getEnvVariablesForTask)(task, taskSpecificEnv, undefined, this.options.skipNxCache, this.options.captureStderr, temporaryOutputPath, streamOutput); const childProcess = await this.runTask(task, streamOutput, env, temporaryOutputPath, pipeOutput); this.runningTasksService?.addRunningTask(task.id); this.runningContinuousTasks.set(task.id, { runningTask: childProcess, groupId, ownsRunningTasksService: true, }); this.continuousTaskExitHandled.set(task.id, new Promise((resolve) => { childProcess.onExit(async (code) => { await this.handleContinuousTaskExit(code, task, groupId, true); resolve(); }); })); await this.scheduleNextTasksAndReleaseThreads(); return childProcess; } // endregion Single Task // region Lifecycle async preRunSteps(tasks, metadata) { const now = Date.now(); for (const task of tasks) { task.startTime = now; } await this.options.lifeCycle.startTasks(tasks, metadata); } async postRunSteps(results, doNotSkipCache, { groupId }) { const now = Date.now(); const tasksToRecord = []; for (const { task, status } of results) { // Only set endTime as fallback (batch provides timing via result.task) task.endTime ??= now; // Skip recording for tasks whose outputs already match the cache — // the daemon already has the correct hash recorded. if (!this.stopRequested && task.outputs.length > 0 && status !== 'local-cache-kept-existing') { tasksToRecord.push({ outputs: task.outputs, hash: task.hash }); } } if (tasksToRecord.length > 0) { await this.recordOutputsHashBatch(tasksToRecord); } if (doNotSkipCache && !this.stopRequested) { // cache the results perf_hooks_1.performance.mark('cache-results-start'); await Promise.all(results .filter(({ status }) => status !== 'local-cache' && status !== 'local-cache-kept-existing' && status !== 'remote-cache' && status !== 'skipped' && status !== 'stopped') .map((result) => ({ ...result, code: result.status === 'local-cache' || result.status === 'local-cache-kept-existing' || result.status === 'remote-cache' || result.status === 'success' ? 0 : 1, outputs: result.task.outputs, })) .filter(({ task, code }) => this.shouldCacheTaskResult(task, code)) .filter(({ terminalOutput, outputs }) => terminalOutput || outputs) .map(async ({ task, code, terminalOutput, outputs }) => this.cache.put(task, terminalOutput, outputs, code))); perf_hooks_1.performance.mark('cache-results-end'); perf_hooks_1.performance.measure('cache-results', 'cache-results-start', 'cache-results-end'); } await this.complete(results, groupId); await this.scheduleNextTasksAndReleaseThreads(); } async scheduleNextTasksAndReleaseThreads() { if (this.stopRequested) { this.waitingForTasks.forEach((f) => f(null)); this.waitingForTasks.length = 0; return; } await this.tasksSchedule.scheduleNextTasks(); // release blocked threads this.waitingForTasks.forEach((f) => f(null)); this.waitingForTasks.length = 0; } async complete(results, groupId) { await this.completeTasks(results, groupId); this.cleanUpUnneededContinuousTasks(); } /** * Unified task completion handler for a set of tasks. * - Calls endTasks() lifecycle hook (non-skipped only) * - Marks complete in scheduler * - Sets completedTasks * - Updates TUI status * - Skip dependent tasks */ async completeTasks(results, groupId) { // 1. endTasks FIRST (non-skipped only) const tasksToReport = []; const taskIds = []; for (const { task, status, terminalOutput } of results) { taskIds.push(task.id); if (!this.completedTasks.has(task.id) && status !== 'skipped') { tasksToReport.push({ task, status, terminalOutput, code: status === 'success' || status === 'local-cache' || status === 'local-cache-kept-existing' || status === 'remote-cache' ? 0 : 1, }); } } if (tasksToReport.length > 0) { await this.options.lifeCycle.endTasks(tasksToReport, { groupId }); } // 2. Mark complete in scheduler this.tasksSchedule.complete(taskIds); // 3. Set completedTasks + update TUI + collect dependent tasks to skip const dependentTasksToSkip = []; for (const { task, status, displayStatus } of results) { if (this.completedTasks.has(task.id)) continue; this.completedTasks.set(task.id, status); if (this.tuiEnabled) { this.options.lifeCycle.setTaskStatus(task.id, displayStatus ?? (0, native_1.parseTaskStatus)(status)); } if (status === 'failure' || status === 'skipped' || status === 'stopped') { if (this.bail) { // mark the execution as bailed which will stop all further execution // only the tasks that are currently running will finish this.bailed = true; } else { // Collect reverse deps to skip for (const depTaskId of this.reverseTaskDeps[task.id]) { const depTask = this.taskGraph.tasks[depTaskId]; if (depTask) { // Don't skip tasks that are still running/stopping — their own // exit handler will set the correct terminal status if (this.runningDiscreteTasks.has(depTaskId) || this.runningContinuousTasks.has(depTaskId)) { continue; } dependentTasksToSkip.push({ task: depTask, status: 'skipped' }); } } } } } // 4. Skip dependent tasks if (dependentTasksToSkip.length > 0) { await this.completeTasks(dependentTasksToSkip, groupId); } } //endregion Lifecycle // region