Skip to content

Commit 4f9be49

Browse files
authored
fix(core): reduce terminal output duplication and allocations in task runner (#34427)
## Current Behavior Terminal output in the task runner is accumulated via repeated string concatenation (`terminalOutput += chunk`). Each `+=` on a growing string causes V8 to allocate a new, larger string and copy the old contents, resulting in O(n²) allocation behavior for tasks with large output. Additionally, `PseudoTtyProcess.onExit` didn't pass `terminalOutput` to its callbacks, forcing callers like `TaskOrchestrator` to duplicate output accumulation logic with a separate `onOutput` listener. ## Expected Behavior - Terminal output is collected in `string[]` arrays and joined once at the end, reducing intermediate allocations from O(n²) to O(n) - `PseudoTtyProcess.onExit` now passes `terminalOutput` as a second argument, matching the signature of other `RunningTask` implementations - `TaskOrchestrator` no longer needs a special code path for `PseudoTtyProcess` — unified `onExit` handling for all task types - `tui-summary-life-cycle` accumulates output in chunks during execution and stores the finalized string on task completion, allowing chunk arrays to be GC'd - `SeriallyRunningTasks` and `RunningNodeProcess` similarly switched to chunk-based accumulation - `BatchProcess` and `NodeChildProcessWithNonDirectOutput` lazily join and cache their terminal output
1 parent 42b5343 commit 4f9be49

File tree

7 files changed

+77
-68
lines changed

7 files changed

+77
-68
lines changed

packages/nx/src/executors/run-commands/running-tasks.ts

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ export class ParallelRunningTasks implements RunningTask {
223223
}
224224

225225
export class SeriallyRunningTasks implements RunningTask {
226-
private terminalOutput = '';
226+
private terminalOutputChunks: string[] = [];
227227
private currentProcess: RunningTask | PseudoTtyProcess | null = null;
228228
private exitCallbacks: Array<(code: number, terminalOutput: string) => void> =
229229
[];
@@ -242,19 +242,21 @@ export class SeriallyRunningTasks implements RunningTask {
242242
this.error = e;
243243
})
244244
.finally(() => {
245+
const terminalOutput = this.terminalOutputChunks.join('');
246+
this.terminalOutputChunks = [];
245247
for (const cb of this.exitCallbacks) {
246-
cb(this.code, this.terminalOutput);
248+
cb(this.code, terminalOutput);
247249
}
248250
});
249251
}
250252

251253
getResults(): Promise<{ code: number; terminalOutput: string }> {
252254
return new Promise((res, rej) => {
253-
this.onExit((code) => {
255+
this.onExit((code, terminalOutput) => {
254256
if (this.error) {
255257
rej(this.error);
256258
} else {
257-
res({ code, terminalOutput: this.terminalOutput });
259+
res({ code, terminalOutput });
258260
}
259261
});
260262
});
@@ -301,15 +303,14 @@ export class SeriallyRunningTasks implements RunningTask {
301303
});
302304

303305
let { code, terminalOutput } = await childProcess.getResults();
304-
this.terminalOutput += terminalOutput;
306+
this.terminalOutputChunks.push(terminalOutput);
305307
this.code = code;
306308
if (code !== 0) {
307309
const output = `Warning: command "${c.command}" exited with non-zero status code`;
308-
terminalOutput += output;
309310
if (options.streamOutput) {
310311
process.stderr.write(output);
311312
}
312-
this.terminalOutput += terminalOutput;
313+
this.terminalOutputChunks.push(output);
313314

314315
// Stop running commands
315316
break;
@@ -374,7 +375,7 @@ export class SeriallyRunningTasks implements RunningTask {
374375
}
375376

376377
class RunningNodeProcess implements RunningTask {
377-
private terminalOutput = '';
378+
private terminalOutputChunks: string[] = [];
378379
private childProcess: ChildProcess;
379380
private exitCallbacks: Array<(code: number, terminalOutput: string) => void> =
380381
[];
@@ -393,9 +394,10 @@ class RunningNodeProcess implements RunningTask {
393394
) {
394395
env = processEnv(color, cwd, env, envFile);
395396
this.command = commandConfig.command;
396-
this.terminalOutput = pc.dim('> ') + commandConfig.command + '\r\n\r\n';
397+
const header = pc.dim('> ') + commandConfig.command + '\r\n\r\n';
398+
this.terminalOutputChunks.push(header);
397399
if (streamOutput) {
398-
process.stdout.write(this.terminalOutput);
400+
process.stdout.write(header);
399401
}
400402
this.childProcess = exec(commandConfig.command, {
401403
maxBuffer: LARGE_BUFFER,
@@ -460,7 +462,7 @@ class RunningNodeProcess implements RunningTask {
460462
this.childProcess.stdout.on('data', (data) => {
461463
const output = addColorAndPrefix(data, commandConfig);
462464

463-
this.terminalOutput += output;
465+
this.terminalOutputChunks.push(output);
464466
this.triggerOutputListeners(output);
465467

466468
if (streamOutput) {
@@ -471,14 +473,14 @@ class RunningNodeProcess implements RunningTask {
471473
isReady(this.readyWhenStatus, data.toString())
472474
) {
473475
for (const cb of this.exitCallbacks) {
474-
cb(0, this.terminalOutput);
476+
cb(0, this.terminalOutputChunks.join(''));
475477
}
476478
}
477479
});
478480
this.childProcess.stderr.on('data', (err) => {
479481
const output = addColorAndPrefix(err, commandConfig);
480482

481-
this.terminalOutput += output;
483+
this.terminalOutputChunks.push(output);
482484
this.triggerOutputListeners(output);
483485

484486
if (streamOutput) {
@@ -489,24 +491,28 @@ class RunningNodeProcess implements RunningTask {
489491
isReady(this.readyWhenStatus, err.toString())
490492
) {
491493
for (const cb of this.exitCallbacks) {
492-
cb(1, this.terminalOutput);
494+
cb(1, this.terminalOutputChunks.join(''));
493495
}
494496
}
495497
});
496498
this.childProcess.on('error', (err) => {
497499
const output = addColorAndPrefix(err.toString(), commandConfig);
498-
this.terminalOutput += output;
500+
this.terminalOutputChunks.push(output);
499501
if (streamOutput) {
500502
process.stderr.write(output);
501503
}
504+
const terminalOutput = this.terminalOutputChunks.join('');
505+
this.terminalOutputChunks = [];
502506
for (const cb of this.exitCallbacks) {
503-
cb(1, this.terminalOutput);
507+
cb(1, terminalOutput);
504508
}
505509
});
506510
this.childProcess.on('exit', (code) => {
507511
if (!this.readyWhenStatus.length || isReady(this.readyWhenStatus)) {
512+
const terminalOutput = this.terminalOutputChunks.join('');
513+
this.terminalOutputChunks = [];
508514
for (const cb of this.exitCallbacks) {
509-
cb(code, this.terminalOutput);
515+
cb(code, terminalOutput);
510516
}
511517
}
512518
});

packages/nx/src/tasks-runner/forked-process-task-runner.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,12 +244,7 @@ export class ForkedProcessTaskRunner {
244244
});
245245
this.processes.add(p);
246246

247-
let terminalOutput = '';
248-
p.onOutput((msg) => {
249-
terminalOutput += msg;
250-
});
251-
252-
p.onExit((code) => {
247+
p.onExit((code, terminalOutput) => {
253248
if (!this.tuiEnabled && code > 128) {
254249
process.exit(code);
255250
}

packages/nx/src/tasks-runner/life-cycles/tui-summary-life-cycle.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,21 +51,27 @@ export function getTuiTerminalSummaryLifeCycle({
5151
const inProgressTasks = new Set<string>();
5252
const stoppedTasks = new Set<string>();
5353

54+
// Chunks accumulated progressively during task execution
55+
const taskOutputChunks: Record<string, string[]> = {};
56+
// Finalized output strings set on task completion — read by summary print functions
5457
const tasksToTerminalOutputs: Record<string, string> = {};
5558
const tasksToTaskStatus: Record<string, TaskStatus> = {};
5659

5760
const taskIdsInTheOrderTheyStart: string[] = [];
5861

62+
const getTerminalOutput = (taskId: string): string =>
63+
tasksToTerminalOutputs[taskId] ?? taskOutputChunks[taskId]?.join('') ?? '';
64+
5965
lifeCycle.startTasks = (tasks) => {
6066
for (let t of tasks) {
61-
tasksToTerminalOutputs[t.id] ??= '';
67+
taskOutputChunks[t.id] ??= [];
6268
taskIdsInTheOrderTheyStart.push(t.id);
6369
inProgressTasks.add(t.id);
6470
}
6571
};
6672

6773
lifeCycle.appendTaskOutput = (taskId, output) => {
68-
tasksToTerminalOutputs[taskId] += output;
74+
taskOutputChunks[taskId].push(output);
6975
};
7076

7177
// TODO(@AgentEnder): The following 2 methods should be one but will need more refactoring
@@ -89,7 +95,7 @@ export function getTuiTerminalSummaryLifeCycle({
8995
};
9096

9197
lifeCycle.endTasks = (taskResults) => {
92-
for (const { task, status } of taskResults) {
98+
for (const { task, status, terminalOutput } of taskResults) {
9399
totalCompletedTasks++;
94100
inProgressTasks.delete(task.id);
95101

@@ -108,6 +114,13 @@ export function getTuiTerminalSummaryLifeCycle({
108114
failedTasks.add(task.id);
109115
break;
110116
}
117+
118+
// Store the final string directly — shares the same reference as
119+
// TaskResultsLifeCycle, old chunks become GC-eligible
120+
if (terminalOutput !== undefined) {
121+
tasksToTerminalOutputs[task.id] = terminalOutput;
122+
delete taskOutputChunks[task.id];
123+
}
111124
}
112125
};
113126

@@ -163,7 +176,7 @@ export function getTuiTerminalSummaryLifeCycle({
163176
// above the summary, since run-one should print all task results.
164177
for (const taskId of taskIdsInTheOrderTheyStart) {
165178
const taskStatus = tasksToTaskStatus[taskId];
166-
const terminalOutput = tasksToTerminalOutputs[taskId];
179+
const terminalOutput = getTerminalOutput(taskId);
167180
output.logCommandOutput(taskId, taskStatus, terminalOutput);
168181
}
169182

@@ -308,7 +321,7 @@ export function getTuiTerminalSummaryLifeCycle({
308321
// First pass: Print task outputs and collect checklist lines
309322
for (const taskId of sortedTaskIds) {
310323
const taskStatus = tasksToTaskStatus[taskId];
311-
const terminalOutput = tasksToTerminalOutputs[taskId];
324+
const terminalOutput = getTerminalOutput(taskId);
312325
// Task Status is null?
313326
if (!taskStatus) {
314327
output.logCommandOutput(taskId, taskStatus, terminalOutput);

packages/nx/src/tasks-runner/pseudo-terminal.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,17 +144,18 @@ export class PseudoTerminal {
144144
export class PseudoTtyProcess implements RunningTask {
145145
isAlive = true;
146146

147-
private exitCallbacks: Array<(code: number) => void> = [];
147+
private exitCallbacks: Array<(code: number, terminalOutput: string) => void> =
148+
[];
148149
private outputCallbacks: Array<(output: string) => void> = [];
149150

150-
private terminalOutput = '';
151+
private terminalOutputChunks: string[] = [];
151152

152153
constructor(
153154
public rustPseudoTerminal: RustPseudoTerminal,
154155
private childProcess: ChildProcess
155156
) {
156157
childProcess.onOutput((output) => {
157-
this.terminalOutput += output;
158+
this.terminalOutputChunks.push(output);
158159
this.outputCallbacks.forEach((cb) => cb(output));
159160
});
160161

@@ -164,19 +165,21 @@ export class PseudoTtyProcess implements RunningTask {
164165
const code = messageToCode(message);
165166
childProcess.cleanup();
166167

167-
this.exitCallbacks.forEach((cb) => cb(code));
168+
const terminalOutput = this.terminalOutputChunks.join('');
169+
this.terminalOutputChunks = [];
170+
this.exitCallbacks.forEach((cb) => cb(code, terminalOutput));
168171
});
169172
}
170173

171174
async getResults(): Promise<{ code: number; terminalOutput: string }> {
172175
return new Promise((res) => {
173-
this.onExit((code) => {
174-
res({ code, terminalOutput: this.terminalOutput });
176+
this.onExit((code, terminalOutput) => {
177+
res({ code, terminalOutput });
175178
});
176179
});
177180
}
178181

179-
onExit(callback: (code: number) => void): void {
182+
onExit(callback: (code: number, terminalOutput: string) => void): void {
180183
this.exitCallbacks.push(callback);
181184
}
182185

packages/nx/src/tasks-runner/running-tasks/batch-process.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export class BatchProcess {
1414
(task: string, result: TaskResult) => void
1515
> = [];
1616
private outputCallbacks: Array<(output: string) => void> = [];
17-
private terminalOutput: string = '';
17+
private terminalOutputChunks: string[] = [];
18+
private joinedTerminalOutput: string | undefined;
1819

1920
constructor(
2021
private childProcess: ChildProcess,
@@ -58,7 +59,7 @@ export class BatchProcess {
5859
if (this.childProcess.stdout) {
5960
this.childProcess.stdout.on('data', (chunk) => {
6061
const output = chunk.toString();
61-
this.terminalOutput += output;
62+
this.terminalOutputChunks.push(output);
6263

6364
// Maintain current terminal output behavior
6465
process.stdout.write(chunk);
@@ -74,7 +75,7 @@ export class BatchProcess {
7475
if (this.childProcess.stderr) {
7576
this.childProcess.stderr.on('data', (chunk) => {
7677
const output = chunk.toString();
77-
this.terminalOutput += output;
78+
this.terminalOutputChunks.push(output);
7879

7980
// Maintain current terminal output behavior
8081
process.stderr.write(chunk);
@@ -135,6 +136,7 @@ export class BatchProcess {
135136
}
136137

137138
getTerminalOutput(): string {
138-
return this.terminalOutput;
139+
this.joinedTerminalOutput ??= this.terminalOutputChunks.join('');
140+
return this.joinedTerminalOutput;
139141
}
140142
}

packages/nx/src/tasks-runner/running-tasks/node-child-process.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { signalToCode } from '../../utils/exit-codes';
77
import type { RunningTask } from './running-task';
88

99
export class NodeChildProcessWithNonDirectOutput implements RunningTask {
10-
private terminalOutput: string = '';
10+
private terminalOutputChunks: string[] = [];
11+
private joinedTerminalOutput: string | undefined;
1112
private exitCallbacks: Array<(code: number, terminalOutput: string) => void> =
1213
[];
1314
private outputCallbacks: Array<(output: string) => void> = [];
@@ -46,8 +47,11 @@ export class NodeChildProcessWithNonDirectOutput implements RunningTask {
4647
this.childProcess.on('exit', (code, signal) => {
4748
if (code === null) code = signalToCode(signal);
4849
this.exitCode = code;
50+
// Join once and cache before notifying exit callbacks
51+
this.joinedTerminalOutput = this.terminalOutputChunks.join('');
52+
this.terminalOutputChunks = [];
4953
for (const cb of this.exitCallbacks) {
50-
cb(code, this.terminalOutput);
54+
cb(code, this.joinedTerminalOutput);
5155
}
5256
});
5357

@@ -59,15 +63,15 @@ export class NodeChildProcessWithNonDirectOutput implements RunningTask {
5963
});
6064
this.childProcess.stdout.on('data', (chunk) => {
6165
const output = chunk.toString();
62-
this.terminalOutput += output;
66+
this.terminalOutputChunks.push(output);
6367
// Stream output to TUI via callbacks
6468
for (const cb of this.outputCallbacks) {
6569
cb(output);
6670
}
6771
});
6872
this.childProcess.stderr.on('data', (chunk) => {
6973
const output = chunk.toString();
70-
this.terminalOutput += output;
74+
this.terminalOutputChunks.push(output);
7175
// Stream output to TUI via callbacks
7276
for (const cb of this.outputCallbacks) {
7377
cb(output);
@@ -87,7 +91,8 @@ export class NodeChildProcessWithNonDirectOutput implements RunningTask {
8791
if (typeof this.exitCode === 'number') {
8892
return {
8993
code: this.exitCode,
90-
terminalOutput: this.terminalOutput,
94+
terminalOutput:
95+
this.joinedTerminalOutput ?? this.terminalOutputChunks.join(''),
9196
};
9297
}
9398
return new Promise((res) => {

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

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -674,30 +674,15 @@ export class TaskOrchestrator {
674674
}
675675

676676
if (!streamOutput) {
677-
if (runningTask instanceof PseudoTtyProcess) {
678-
// TODO: shouldn't this be checking if the task is continuous before writing anything to disk or calling printTaskTerminalOutput?
679-
let terminalOutput = '';
680-
runningTask.onOutput((data) => {
681-
terminalOutput += data;
682-
});
683-
runningTask.onExit((code) => {
684-
this.options.lifeCycle.printTaskTerminalOutput(
685-
task,
686-
code === 0 ? 'success' : 'failure',
687-
terminalOutput
688-
);
689-
writeFileSync(temporaryOutputPath, terminalOutput);
690-
});
691-
} else {
692-
runningTask.onExit((code, terminalOutput) => {
693-
this.options.lifeCycle.printTaskTerminalOutput(
694-
task,
695-
code === 0 ? 'success' : 'failure',
696-
terminalOutput
697-
);
698-
writeFileSync(temporaryOutputPath, terminalOutput);
699-
});
700-
}
677+
// TODO: shouldn't this be checking if the task is continuous before writing anything to disk or calling printTaskTerminalOutput?
678+
runningTask.onExit((code, terminalOutput) => {
679+
this.options.lifeCycle.printTaskTerminalOutput(
680+
task,
681+
code === 0 ? 'success' : 'failure',
682+
terminalOutput
683+
);
684+
writeFileSync(temporaryOutputPath, terminalOutput);
685+
});
701686
}
702687

703688
return runningTask;

0 commit comments

Comments
 (0)