diff --git a/README.md b/README.md index 0da8728656..1f46bad301 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/apps/playwright-browser-tunnel](./apps/playwright-browser-tunnel/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel.svg)](https://badge.fury.io/js/%40rushstack%2Fplaywright-browser-tunnel) | [changelog](./apps/playwright-browser-tunnel/CHANGELOG.md) | [@rushstack/playwright-browser-tunnel](https://www.npmjs.com/package/@rushstack/playwright-browser-tunnel) | | [/apps/rundown](./apps/rundown/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frundown.svg)](https://badge.fury.io/js/%40rushstack%2Frundown) | [changelog](./apps/rundown/CHANGELOG.md) | [@rushstack/rundown](https://www.npmjs.com/package/@rushstack/rundown) | | [/apps/rush](./apps/rush/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush.svg)](https://badge.fury.io/js/%40microsoft%2Frush) | [changelog](./apps/rush/CHANGELOG.md) | [@microsoft/rush](https://www.npmjs.com/package/@microsoft/rush) | +| [/apps/rush-cli-client](./apps/rush-cli-client/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-cli-client.svg)](https://badge.fury.io/js/%40rushstack%2Frush-cli-client) | [changelog](./apps/rush-cli-client/CHANGELOG.md) | [@rushstack/rush-cli-client](https://www.npmjs.com/package/@rushstack/rush-cli-client) | | [/apps/rush-mcp-server](./apps/rush-mcp-server/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fmcp-server.svg)](https://badge.fury.io/js/%40rushstack%2Fmcp-server) | [changelog](./apps/rush-mcp-server/CHANGELOG.md) | [@rushstack/mcp-server](https://www.npmjs.com/package/@rushstack/mcp-server) | | [/apps/trace-import](./apps/trace-import/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Ftrace-import.svg)](https://badge.fury.io/js/%40rushstack%2Ftrace-import) | [changelog](./apps/trace-import/CHANGELOG.md) | [@rushstack/trace-import](https://www.npmjs.com/package/@rushstack/trace-import) | | [/apps/zipsync](./apps/zipsync/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fzipsync.svg)](https://badge.fury.io/js/%40rushstack%2Fzipsync) | [changelog](./apps/zipsync/CHANGELOG.md) | [@rushstack/zipsync](https://www.npmjs.com/package/@rushstack/zipsync) | @@ -98,6 +99,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/libraries/problem-matcher](./libraries/problem-matcher/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) | | [/libraries/reporter](./libraries/reporter/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-reporter.svg)](https://badge.fury.io/js/%40rushstack%2Frush-reporter) | [changelog](./libraries/reporter/CHANGELOG.md) | [@rushstack/rush-reporter](https://www.npmjs.com/package/@rushstack/rush-reporter) | | [/libraries/rig-package](./libraries/rig-package/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) | +| [/libraries/rush-client-core](./libraries/rush-client-core/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-client-core.svg)](https://badge.fury.io/js/%40rushstack%2Frush-client-core) | [changelog](./libraries/rush-client-core/CHANGELOG.md) | [@rushstack/rush-client-core](https://www.npmjs.com/package/@rushstack/rush-client-core) | | [/libraries/rush-daemon](./libraries/rush-daemon/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon) | [changelog](./libraries/rush-daemon/CHANGELOG.md) | [@rushstack/rush-daemon](https://www.npmjs.com/package/@rushstack/rush-daemon) | | [/libraries/rush-daemon-protocol](./libraries/rush-daemon-protocol/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol) | [changelog](./libraries/rush-daemon-protocol/CHANGELOG.md) | [@rushstack/rush-daemon-protocol](https://www.npmjs.com/package/@rushstack/rush-daemon-protocol) | | [/libraries/rush-daemon-transport](./libraries/rush-daemon-transport/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport) | [changelog](./libraries/rush-daemon-transport/CHANGELOG.md) | [@rushstack/rush-daemon-transport](https://www.npmjs.com/package/@rushstack/rush-daemon-transport) | diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index 1a9f0ef99e..1d61e1db0a 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -26,6 +26,7 @@ import type { import type { InternalHeftSession } from '../pluginFramework/InternalHeftSession'; import type { HeftConfiguration } from '../configuration/HeftConfiguration'; import type { LoggingManager } from '../pluginFramework/logging/LoggingManager'; +import type { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; import type { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftParameterManager } from '../pluginFramework/HeftParameterManager'; import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner'; @@ -71,9 +72,13 @@ export function initializeHeft( ): void { // Ensure that verbose is enabled on the terminal if requested. terminalProvider.verboseEnabled // should already be `true` if the `--debug` flag was provided. This is set in HeftCommandLineParser - if (heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider) { - heftConfiguration.terminalProvider.verboseEnabled = - heftConfiguration.terminalProvider.verboseEnabled || isVerbose; + if ( + heftConfiguration.terminalProvider instanceof ConsoleTerminalProvider || + 'verboseEnabled' in heftConfiguration.terminalProvider + ) { + const terminalProvider: ConsoleTerminalProvider | HeftChildReporter = + heftConfiguration.terminalProvider as ConsoleTerminalProvider | HeftChildReporter; + terminalProvider.verboseEnabled = terminalProvider.verboseEnabled || isVerbose; } // Log some information about the execution diff --git a/apps/heft/src/cli/HeftCommandLineParser.ts b/apps/heft/src/cli/HeftCommandLineParser.ts index 0b26079c85..6b7ada3970 100644 --- a/apps/heft/src/cli/HeftCommandLineParser.ts +++ b/apps/heft/src/cli/HeftCommandLineParser.ts @@ -10,7 +10,12 @@ import { type CommandLineAction } from '@rushstack/ts-command-line'; import { InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; -import { Terminal, ConsoleTerminalProvider, type ITerminal } from '@rushstack/terminal'; +import { + Terminal, + ConsoleTerminalProvider, + type ITerminal, + type ITerminalProvider +} from '@rushstack/terminal'; import { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftConfiguration } from '../configuration/HeftConfiguration'; @@ -23,6 +28,7 @@ import type { IHeftActionOptions } from './actions/IHeftAction'; import { AliasAction } from './actions/AliasAction'; import { getToolParameterNamesFromArgs } from '../utilities/CliUtilities'; import { Constants } from '../utilities/Constants'; +import { HeftChildReporter } from '../pluginFramework/logging/HeftChildReporter'; /** * This interfaces specifies values for parameters that must be parsed before the CLI @@ -41,7 +47,8 @@ export class HeftCommandLineParser extends CommandLineParser { readonly #debugFlag: CommandLineFlagParameter; readonly #unmanagedFlag: CommandLineFlagParameter; readonly #debug: boolean; - readonly #terminalProvider: ConsoleTerminalProvider; + readonly #terminalProvider: ITerminalProvider; + readonly #childReporter: HeftChildReporter | undefined; readonly #loggingManager: LoggingManager; readonly #metricsCollector: MetricsCollector; readonly #heftConfiguration: HeftConfiguration; @@ -77,12 +84,22 @@ export class HeftCommandLineParser extends CommandLineParser { this.#debug = !!preInitializationArgumentValues.debug; // Enable debug and verbose logging if the "--debug" flag is set - this.#terminalProvider = new ConsoleTerminalProvider({ - debugEnabled: this.#debug, - verboseEnabled: this.#debug - }); + this.#childReporter = HeftChildReporter.tryInitialize(); + this.#terminalProvider = + this.#childReporter ?? + new ConsoleTerminalProvider({ + debugEnabled: this.#debug, + verboseEnabled: this.#debug + }); + if (this.#debug && this.#childReporter) { + this.#childReporter.debugEnabled = true; + this.#childReporter.verboseEnabled = true; + } this.globalTerminal = new Terminal(this.#terminalProvider); - this.#loggingManager = new LoggingManager({ terminalProvider: this.#terminalProvider }); + this.#loggingManager = new LoggingManager({ + terminalProvider: this.#terminalProvider, + childReporter: this.#childReporter + }); if (this.#debug) { // Enable printing stacktraces if the "--debug" flag is set this.#loggingManager.enablePrintStacks(); @@ -197,6 +214,7 @@ export class HeftCommandLineParser extends CommandLineParser { commandName, unaliasedCommandName }; + this.#childReporter?.setCommandName(commandName); await super.onExecuteAsync(); } catch (e) { await this.#reportErrorAndSetExitCodeAsync(e as Error); @@ -224,7 +242,7 @@ export class HeftCommandLineParser extends CommandLineParser { args: string[] = process.argv ): IPreInitializationArgumentValues { if (!this.#debugFlag) { - // The `this._debugFlag` parameter (the parameter itself, not its value) + // The `this.#debugFlag` parameter (the parameter itself, not its value) // has not yet been defined. Parameters need to be defined before we // try to evaluate any parameters. This is to ensure that the // `--debug` flag is defined correctly before we do this not-so-rigorous @@ -241,7 +259,11 @@ export class HeftCommandLineParser extends CommandLineParser { async #reportErrorAndSetExitCodeAsync(error: Error): Promise { if (!(error instanceof AlreadyReportedError)) { - this.globalTerminal.writeErrorLine(error.toString()); + if (this.#childReporter) { + this.#childReporter.emitDiagnostic(Constants.heftPackageName, error, 'error'); + } else { + this.globalTerminal.writeErrorLine(error.toString()); + } } if (this.#debug) { diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts new file mode 100644 index 0000000000..87885f35d2 --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.test.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import { PackageJsonLookup } from '@rushstack/node-core-library'; + +import { HeftChildReporter } from './HeftChildReporter'; + +describe(HeftChildReporter.name, () => { + it('preserves standalone behavior when no parent descriptors are present', () => { + expect(HeftChildReporter.tryInitialize({})).toBeUndefined(); + }); + + it('does not write to or close descriptors that are not pipes', () => { + const folderPath: string = fs.mkdtempSync(path.join(os.tmpdir(), 'heft-child-reporter-')); + const eventPath: string = path.join(folderPath, 'event'); + const acknowledgementPath: string = path.join(folderPath, 'ack'); + const eventFd: number = fs.openSync(eventPath, 'w+'); + const acknowledgementFd: number = fs.openSync(acknowledgementPath, 'w+'); + try { + expect( + HeftChildReporter.tryInitialize({ + _RUSH_REPORTER_CHILD_FD: String(eventFd), + _RUSH_REPORTER_CHILD_ACK_FD: String(acknowledgementFd) + }) + ).toBeUndefined(); + expect(fs.readFileSync(eventPath, 'utf8')).toBe(''); + expect(() => fs.writeSync(acknowledgementFd, 'still open')).not.toThrow(); + } finally { + fs.closeSync(eventFd); + fs.closeSync(acknowledgementFd); + fs.rmSync(folderPath, { recursive: true }); + } + }); + + it('recognizes pipe mode bits when platform predicates return false', () => { + const fsModule: typeof fs = jest.requireActual('node:fs'); + const stats: fs.Stats = fs.statSync(__filename); + stats.mode = fs.constants.S_IFIFO; + stats.isFIFO = () => false; + stats.isSocket = () => false; + const acknowledgement: Buffer = Buffer.from( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1'], + rejectedRequiredFeatures: [] + })}\n` + ); + const statSpy: jest.SpyInstance = jest.spyOn(fsModule, 'fstatSync').mockReturnValue(stats); + const packageSpy: jest.SpyInstance = jest + .spyOn(PackageJsonLookup.instance, 'tryLoadPackageJsonFor') + .mockReturnValue({ name: '@rushstack/heft', version: '1.0.0' }); + const writeSpy: jest.SpyInstance = jest.spyOn(fsModule, 'writeSync').mockReturnValue(1); + const readSpy: jest.SpyInstance = jest.spyOn(fsModule, 'readSync').mockImplementation((fd, buffer) => { + expect(fd).toBe(4); + if (!Buffer.isBuffer(buffer)) { + throw new Error('Expected the acknowledgement read buffer.'); + } + return acknowledgement.copy(buffer); + }); + const closeSpy: jest.SpyInstance = jest.spyOn(fsModule, 'closeSync').mockImplementation(() => {}); + + try { + const reporter: HeftChildReporter | undefined = HeftChildReporter.tryInitialize({ + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }); + + expect(reporter).toBeDefined(); + expect(reporter?.parentReporterName).toBe('plaintext'); + expect(writeSpy).toHaveBeenCalledWith(3, expect.stringContaining('"kind":"hello"')); + expect(closeSpy).toHaveBeenCalledWith(4); + expect(closeSpy).not.toHaveBeenCalledWith(3); + } finally { + closeSpy.mockRestore(); + readSpy.mockRestore(); + writeSpy.mockRestore(); + packageSpy.mockRestore(); + statSpy.mockRestore(); + } + }); + + it('negotiates context and emits ordered structured output and diagnostics', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const loggingManagerPath: string = require.resolve('./LoggingManager'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const { LoggingManager } = require(process.argv[2]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) { + process.stdout.write('fallback'); + process.exit(2); + } + if (reporter.parentReporterName !== 'json' || reporter.terminalWidth !== 132) process.exit(3); + reporter.setCommandName('build'); + reporter.write('visible output\\n', 0); + reporter.write('hidden verbose output\\n', 3); + const logging = new LoggingManager({ terminalProvider: reporter, childReporter: reporter }); + const logger = logging.requestScopedLogger('typescript'); + logger.emitError(new Error('structured failure')); + if (!logging.errorsHaveBeenEmitted || !logger.hasErrors) process.exit(4); + logging.resetScopedLoggerErrorsAndWarnings(); + if (logging.errorsHaveBeenEmitted || logger.hasErrors) process.exit(5); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath, loggingManagerPath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 132 + } + })}\n` + ); + } + }); + + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + const records: Array> = descriptorText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records[0].kind).toBe('hello'); + expect(records.slice(1).map((record) => record.type)).toEqual(['externalOutput', 'diagnosticEmitted']); + expect(records.slice(1).map((record) => record.sequence)).toEqual([1, 2]); + expect((records[1].scope as { commandName?: string }).commandName).toBe('build'); + expect((records[1].payload as { text?: string }).text).toBe('visible output\n'); + expect((records[2].payload as { severity?: string }).severity).toBe('error'); + }); + + it('uses safe context defaults when the accepted context capability has no payload', async () => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (!reporter) process.exit(2); + if (reporter.parentReporterName !== 'plaintext' || reporter.terminalWidth !== 80) process.exit(3); + reporter.write('structured with defaults\\n', 0); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (descriptorText.split('\n').length === 2) { + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [] + })}\n` + ); + } + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(descriptorText).toContain('structured with defaults'); + }); + + it.each([0, 'wide'])( + 'falls back safely for invalid parent terminal width %p', + async (terminalWidth: unknown) => { + const modulePath: string = require.resolve('./HeftChildReporter'); + const childScript: string = ` + const { HeftChildReporter } = require(process.argv[1]); + const reporter = HeftChildReporter.tryInitialize(process.env); + if (reporter) process.exit(2); + process.stdout.write('context fallback'); + `; + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', childScript, modulePath], + { + env: { + ...process.env, + _RUSH_REPORTER_CHILD_FD: '3', + _RUSH_REPORTER_CHILD_ACK_FD: '4' + }, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'] + } + ); + const descriptor: Readable = child.stdio[3] as Readable; + const acknowledgement: Writable = child.stdio[4] as Writable; + let descriptorText: string = ''; + let acknowledgementSent: boolean = false; + descriptor.setEncoding('utf8'); + descriptor.on('data', (chunk: string) => { + descriptorText += chunk; + if (!acknowledgementSent && descriptorText.includes('\n')) { + acknowledgementSent = true; + acknowledgement.end( + `${JSON.stringify({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 2 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth + } + })}\n` + ); + } + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const exitCode: number | null = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + + expect(exitCode).toBe(0); + expect(acknowledgementSent).toBe(true); + expect(stdout).toBe('context fallback'); + } + ); +}); diff --git a/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts new file mode 100644 index 0000000000..471c053b88 --- /dev/null +++ b/apps/heft/src/pluginFramework/logging/HeftChildReporter.ts @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { EOL } from 'node:os'; + +import { FileError, PackageJsonLookup, type IPackageJson } from '@rushstack/node-core-library'; +import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; + +import { Constants } from '../../utilities/Constants'; + +const CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; +const CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD' = '_RUSH_REPORTER_CHILD_ACK_FD'; +interface IProtocolVersion { + readonly major: number; + readonly minor: number; +} +const PROTOCOL_VERSION: IProtocolVersion = { major: 1, minor: 2 }; +const MAX_RECORD_BYTES: number = 1024 * 1024; +const MAX_OUTPUT_CHUNK_BYTES: number = 64 * 1024; +const CAPABILITIES: readonly string[] = ['heft-child-events-v1', 'reporter-context-v1']; +const REPORTER_NAMES: ReadonlySet = new Set(['default', 'ai', 'json', 'plaintext', 'file', 'legacy']); +const LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +interface IReporterChildContext { + readonly reporter: string; + readonly logLevel: 'quiet' | 'normal' | 'verbose' | 'debug'; + readonly color: boolean; + readonly terminalWidth: number; +} + +interface IReporterEventScope { + readonly commandName?: string; +} + +function readDescriptorFd(env: Record, name: string): number | undefined { + const raw: string | undefined = env[name]; + if (raw === undefined || !/^\d+$/.test(raw)) { + return undefined; + } + const parsed: number = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; +} + +function isReporterPipe(fd: number): boolean { + try { + const stats: fs.Stats = fs.fstatSync(fd); + // Node disables isFIFO() on Windows even when fstat reports a named pipe. + // eslint-disable-next-line no-bitwise -- Compare the file type without permission bits. + return (stats.mode & fs.constants.S_IFMT) === fs.constants.S_IFIFO || stats.isSocket(); + } catch { + return false; + } +} + +function encodeRecord(value: unknown): string { + const json: string = JSON.stringify(value); + if (Buffer.byteLength(json, 'utf8') > MAX_RECORD_BYTES) { + throw new Error(`The reporter record exceeds the ${MAX_RECORD_BYTES}-byte protocol limit.`); + } + return `${json}\n`; +} + +function chunkUtf8Text(text: string): string[] { + const chunks: string[] = []; + let offset: number = 0; + while (offset < text.length) { + let end: number = offset; + let byteLength: number = 0; + while (end < text.length) { + const codePoint: number = text.codePointAt(end)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (end > offset && byteLength + codePointBytes > MAX_OUTPUT_CHUNK_BYTES) { + break; + } + byteLength += codePointBytes; + end += codeUnits; + } + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} + +function parseAck(text: string): IReporterChildContext | undefined { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + const ack: Record = value as Record; + const version: Record | undefined = + typeof ack.protocolVersion === 'object' && ack.protocolVersion !== null + ? (ack.protocolVersion as Record) + : undefined; + const acceptedCapabilities: unknown = ack.acceptedCapabilities; + const rejectedRequiredFeatures: unknown = ack.rejectedRequiredFeatures; + if ( + ack.kind !== 'helloAck' || + version?.major !== PROTOCOL_VERSION.major || + !Array.isArray(acceptedCapabilities) || + !acceptedCapabilities.every((item: unknown) => typeof item === 'string') || + !acceptedCapabilities.includes('heft-child-events-v1') || + !Array.isArray(rejectedRequiredFeatures) || + rejectedRequiredFeatures.length > 0 + ) { + return undefined; + } + + if (!acceptedCapabilities.includes('reporter-context-v1')) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + const context: unknown = ack.context; + if (context === undefined) { + return { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 80 + }; + } + if (typeof context !== 'object' || context === null || Array.isArray(context)) { + return undefined; + } + const record: Record = context as Record; + if ( + typeof record.reporter !== 'string' || + !REPORTER_NAMES.has(record.reporter) || + typeof record.logLevel !== 'string' || + !LOG_LEVELS.has(record.logLevel) || + typeof record.color !== 'boolean' || + typeof record.terminalWidth !== 'number' || + !Number.isSafeInteger(record.terminalWidth) || + record.terminalWidth < 1 + ) { + return undefined; + } + const logLevel: IReporterChildContext['logLevel'] = + record.logLevel === 'quiet' || + record.logLevel === 'normal' || + record.logLevel === 'verbose' || + record.logLevel === 'debug' + ? record.logLevel + : 'normal'; + return { + reporter: record.reporter, + logLevel, + color: record.color, + terminalWidth: record.terminalWidth + }; +} + +/** + * Bridges Heft terminal and diagnostic output onto an inherited Rush reporter channel. + * + * @internal + */ +export class HeftChildReporter implements ITerminalProvider { + public readonly supportsColor: boolean; + public readonly eolCharacter: string = EOL; + public readonly parentReporterName: string; + public readonly terminalWidth: number; + public verboseEnabled: boolean; + public debugEnabled: boolean; + + private readonly _descriptorFd: number; + private readonly _sourceVersion: string; + private readonly _sessionId: string; + private _commandName: string | undefined; + private _sequence: number = 1; + private _nextEventId: number = 1; + + private constructor(descriptorFd: number, sourceVersion: string, context: IReporterChildContext) { + this._descriptorFd = descriptorFd; + this._sourceVersion = sourceVersion; + this._sessionId = crypto.randomUUID(); + this.parentReporterName = context.reporter; + this.terminalWidth = context.terminalWidth; + this.supportsColor = context.color; + this.verboseEnabled = context.logLevel === 'verbose' || context.logLevel === 'debug'; + this.debugEnabled = context.logLevel === 'debug'; + } + + public static tryInitialize( + env: Record = process.env + ): HeftChildReporter | undefined { + const descriptorFd: number | undefined = readDescriptorFd(env, CHILD_FD_ENV_VAR); + const ackDescriptorFd: number | undefined = readDescriptorFd(env, CHILD_ACK_FD_ENV_VAR); + delete env[CHILD_FD_ENV_VAR]; + delete env[CHILD_ACK_FD_ENV_VAR]; + if ( + descriptorFd === undefined || + ackDescriptorFd === undefined || + descriptorFd === ackDescriptorFd || + !isReporterPipe(descriptorFd) || + !isReporterPipe(ackDescriptorFd) + ) { + return undefined; + } + + const packageJson: IPackageJson | undefined = PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); + const version: string = packageJson?.version ?? 'unknown'; + let reporter: HeftChildReporter | undefined; + try { + fs.writeSync( + descriptorFd, + encodeRecord({ + kind: 'hello', + protocolVersion: PROTOCOL_VERSION, + producerVersion: `${Constants.heftPackageName} ${version}`, + capabilities: CAPABILITIES, + requiredFeatures: [] + }) + ); + + const chunks: Buffer[] = []; + let totalBytes: number = 0; + const buffer: Buffer = Buffer.allocUnsafe(4096); + for (;;) { + const byteCount: number = fs.readSync(ackDescriptorFd, buffer, 0, buffer.length, null); + if (byteCount === 0) { + break; + } + totalBytes += byteCount; + if (totalBytes > MAX_RECORD_BYTES + 1) { + break; + } + chunks.push(Buffer.from(buffer.subarray(0, byteCount))); + const text: string = Buffer.concat(chunks).toString('utf8'); + const newlineIndex: number = text.indexOf('\n'); + if (newlineIndex >= 0) { + const context: IReporterChildContext | undefined = parseAck(text.slice(0, newlineIndex)); + reporter = context ? new HeftChildReporter(descriptorFd, version, context) : undefined; + break; + } + } + } catch (error) { + if (typeof (error as NodeJS.ErrnoException).code !== 'string') { + throw error; + } + } + try { + fs.closeSync(ackDescriptorFd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EBADF') { + throw error; + } + } + return reporter; + } + + public setCommandName(commandName: string): void { + this._commandName = commandName; + } + + public write(data: string, severity: TerminalProviderSeverity): void { + if (severity === TerminalProviderSeverity.verbose && !this.verboseEnabled) { + return; + } + if (severity === TerminalProviderSeverity.debug && !this.debugEnabled) { + return; + } + const stream: 'stdout' | 'stderr' = + severity === TerminalProviderSeverity.warning || severity === TerminalProviderSeverity.error + ? 'stderr' + : 'stdout'; + for (const chunk of chunkUtf8Text(data)) { + this._emit('externalOutput', 'local-sensitive', { stream, text: chunk }); + } + } + + public emitDiagnostic(loggerName: string, error: Error, severity: 'warning' | 'error'): void { + const source: + | { + readonly kind: 'file'; + readonly file: string; + readonly line: number | undefined; + readonly column: number | undefined; + readonly toolName: string; + } + | { readonly kind: 'tool'; readonly toolName: string } = + error instanceof FileError + ? { + kind: 'file', + file: error.absolutePath, + line: error.line, + column: error.column, + toolName: loggerName + } + : { kind: 'tool', toolName: loggerName }; + const diagnostic: Record = { + diagnosticId: crypto.randomUUID(), + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity, + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: loggerName, privacy: 'public' }, + code: { value: error.name, privacy: 'public' }, + message: { value: error.message, privacy: 'local-sensitive' } + }, + source + }; + + try { + this._emit('diagnosticEmitted', 'local-sensitive', diagnostic); + } catch (emitError) { + if (!(emitError instanceof Error) || !emitError.message.includes('protocol limit')) { + throw emitError; + } + this.write(`[${loggerName}] ${severity}: ${error.message}${EOL}`, TerminalProviderSeverity.error); + } + } + + private _emit( + type: 'diagnosticEmitted' | 'externalOutput', + privacy: 'local-sensitive', + payload: unknown + ): void { + const scope: IReporterEventScope | undefined = + this._commandName === undefined ? undefined : { commandName: this._commandName }; + fs.writeSync( + this._descriptorFd, + encodeRecord({ + protocolVersion: PROTOCOL_VERSION, + eventId: `child_${this._nextEventId++}`, + sessionId: this._sessionId, + sequence: this._sequence++, + timestamp: new Date().toISOString(), + source: { + packageName: Constants.heftPackageName, + packageVersion: this._sourceVersion + }, + scope, + privacy, + required: type === 'diagnosticEmitted', + type, + payload + }) + ); + } +} diff --git a/apps/heft/src/pluginFramework/logging/LoggingManager.ts b/apps/heft/src/pluginFramework/logging/LoggingManager.ts index c598d7a5ec..6ad0609fcd 100644 --- a/apps/heft/src/pluginFramework/logging/LoggingManager.ts +++ b/apps/heft/src/pluginFramework/logging/LoggingManager.ts @@ -8,9 +8,11 @@ import { } from '@rushstack/node-core-library'; import type { ITerminalProvider } from '@rushstack/terminal'; +import type { HeftChildReporter } from './HeftChildReporter'; import { ScopedLogger } from './ScopedLogger'; export interface ILoggingManagerOptions { terminalProvider: ITerminalProvider; + childReporter?: HeftChildReporter; } export class LoggingManager { @@ -54,7 +56,10 @@ export class LoggingManager { terminalProvider: this.#options.terminalProvider, getShouldPrintStacks: () => this.#shouldPrintStacks, errorHasBeenEmittedCallback: () => (this.#hasAnyErrors = true), - warningHasBeenEmittedCallback: () => (this.#hasAnyWarnings = true) + warningHasBeenEmittedCallback: () => (this.#hasAnyWarnings = true), + structuredDiagnosticCallback: this.#options.childReporter + ? (error, severity) => this.#options.childReporter!.emitDiagnostic(loggerName, error, severity) + : undefined }); this.#scopedLoggers.set(loggerName, scopedLogger); return scopedLogger; diff --git a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts index 50ec990a18..524940b5a1 100644 --- a/apps/heft/src/pluginFramework/logging/ScopedLogger.ts +++ b/apps/heft/src/pluginFramework/logging/ScopedLogger.ts @@ -54,6 +54,7 @@ export interface IScopedLoggerOptions { getShouldPrintStacks: () => boolean; errorHasBeenEmittedCallback: () => void; warningHasBeenEmittedCallback: () => void; + structuredDiagnosticCallback?: (error: Error, severity: 'warning' | 'error') => void; } export class ScopedLogger implements IScopedLogger { @@ -107,7 +108,11 @@ export class ScopedLogger implements IScopedLogger { public emitError(error: Error): void { this.#options.errorHasBeenEmittedCallback(); this.#errors.push(error); - this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + if (this.#options.structuredDiagnosticCallback) { + this.#options.structuredDiagnosticCallback(error, 'error'); + } else { + this.terminal.writeErrorLine(`Error: ${LoggingManager.getErrorMessage(error)}`); + } if (this.#shouldPrintStacks && error.stack) { this.terminal.writeErrorLine(error.stack); } @@ -119,7 +124,11 @@ export class ScopedLogger implements IScopedLogger { public emitWarning(warning: Error): void { this.#options.warningHasBeenEmittedCallback(); this.#warnings.push(warning); - this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + if (this.#options.structuredDiagnosticCallback) { + this.#options.structuredDiagnosticCallback(warning, 'warning'); + } else { + this.terminal.writeWarningLine(`Warning: ${LoggingManager.getErrorMessage(warning)}`); + } if (this.#shouldPrintStacks && warning.stack) { this.terminal.writeWarningLine(warning.stack); } diff --git a/apps/rush-cli-client/.npmignore b/apps/rush-cli-client/.npmignore new file mode 100644 index 0000000000..f7a40e1021 --- /dev/null +++ b/apps/rush-cli-client/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md new file mode 100644 index 0000000000..3b9e50ab5b --- /dev/null +++ b/apps/rush-cli-client/README.md @@ -0,0 +1,373 @@ +# @rushstack/rush-cli-client + +Separate `rush-client` and `rushx-client` binaries, opt-in until cutover. Existing +`rush`, `rushx`, and their reporter entrypoints are unchanged. + +Routing precedence: + +1. `--no-daemon` before `--`, help, never-daemonize commands, and Rushx with any TTY stdio stay in-process. +2. CI stays in-process unless `RUSH_DAEMON=1` explicitly opts in, even if config enables the daemon. +3. `RUSH_DAEMON` overrides `rush.json`'s `daemon.enabled`; the default is false. +4. Auto-start is considered only after selecting daemon execution. + +`--no-wait` fails immediately when daemon admission is unavailable. +`--wait-timeout SECONDS` (or `--wait-timeout=SECONDS`) overrides the configured queue +timeout; finite nonnegative decimal seconds up to 2147483.647 are accepted and +rounded down to milliseconds. These controls are mutually exclusive and are +consumed before forwarding, never appended to a project script. Arguments after +`--` remain literal script arguments. + +Admission controls also apply to experimental graph requests, but not +`start|stop|restart|status|logs`. They affect daemon admission only; native fallback +retains native command behavior. Waiting positions are shown on interactive stderr, +and admission failures report their typed reason and a nonzero exit code. + +Explicit reporter/output/log-level controls retain the native frontend reporter path. +The current daemon client renders the legacy operation stream; it does not silently +reinterpret requests for JSON, AI, file, or other reporter formats. + +Positively identified built-in `install` and `update` follow the same opt-in routing +precedence as workspace builds and require protocol **0.10** +(`DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR`). They are not submitted to older peers. +Other package mutation, publishing, setup, management, and administrative commands +remain native rather than being forwarded as execution requests. Daemon management +subcommands use their separate control path. Rushx script names are not interpreted +as Rush built-ins. Arguments after `--` are preserved. +Request cwd, environment, argv, width and color are captured before connecting. +The protocol currently expresses request color as a boolean; subscriptions carry +the corresponding color level. There is no SIGWINCH forwarding. + +The standalone host now binds native `build`/`rebuild` requests to a reusable +all-project graph. Native Rush parsing, project selection, graph plugins, and +incremental/cache semantics are reused rather than spawning another Rush CLI. +The client renders operation headers, collated text, and activity events; global +command byte streams remain byte-preserving. A `rushx build` script never claims +to be a workspace build. + +Rushx requests carry `invocationKind: "rushx"` (protocol 0.8), independently of custom +command origin. Native parsing recognizes `-q`, `-d`, and `--ignore-hooks` before the +command; subsequent flags and `--` belong to the script, apart from this client's +explicit admission/escape controls. Older peers fall back before receiving the request +or consuming input. + +The standalone Rushx client conservatively keeps every script with TTY stdin, stdout, or stderr on +the native path, before connecting, auto-starting, or consuming input. Arbitrary +scripts may require raw mode or a controlling terminal, and a pipe is not a PTY. +There is no attempt to discover this requirement by running and retrying a script. +Fully non-TTY invocations retain daemon forwarding, binary byte parity, and EOF handling. +An embedded client that knows its script is pipe-safe can still submit a Rushx +envelope directly; it must declare any controlling-terminal requirement. + +The default daemon installs `RushDaemonRequestResolver(existingRushResolver)` to enable real +package-script execution alongside native workspace builds. It reuses native Rushx parsing, escaping, +banner/diagnostics, lifecycle PATH and INIT_CWD preparation, dotenv precedence, and +pnpm injected-dependency synchronization. Only the actual script shell is spawned; +there is no Rush CLI child or synthetic warm graph. Native configuration discovery +is captured by the client and emitted only with daemon output, avoiding duplicate +discovery messages on fallback. + +On Windows, Rushx retains native invocation-path spelling in script cwd, lifecycle variables and +pnpm-sync output, including 8.3 aliases and junctions. Daemon identity and confinement remain physical; +an alias does not create another workspace identity or permit execution outside the workspace. +Alias retargeting while queued fails before execution. Relative `RUSH_TEMP_FOLDER` initialization +uses safe in-process fallback. + +The composite is exported for embedded hosts and wired into the standalone daemon. +No default or cutover flag is flipped. Active Rushx hooks, encrypted dotenv +vaults, changed process-global Rush configuration variables, stale workspace configuration, +and native help require pre-execution fallback. Ignored/recursive hooks retain native +behavior, including skipping post hooks after failure. PTY requirements remain in-process. +For forwarded Rushx, TTY output color/width overrides are applied after native +lifecycle environment preparation using the existing terminal policy helper. +Non-TTY requests retain their explicit environment values, including intentional +`FORCE_COLOR`/`COLUMNS` settings; no ambient daemon environment is merged in. + +Compatible requests reuse the same native graph. Source changes refresh inputs; +changed configuration or command shape replaces the session and graph in the same +process. Environment, installed dependencies, implementation content, or selected +Rush version changes require a process restart rather than patching the existing +engine. Direct, inherited, and rig-based project configuration uses private native +loaders and is rechecked before execution. External plugins, `.env`, phased +watch/install options, and unsupported event-hook scripts still use typed +pre-execution fallback; this does not exclude the built-in `install` and `update` +commands described above. The native Rush lock is held for preparation and each +coalesced iteration, not while idle; native commands and `--no-daemon` can run +after a completed request without stopping the daemon. + +Native workspace dispatch copies the request envelope and normalizes only the +engine-owned `_RUSH_LIB_PATH` to this daemon's real engine. Foreign client SDK +paths therefore neither select the wrong SDK nor cause a false restart. All other +environment inputs remain unchanged and participate in normal lifecycle checks. + +Protocol 0.10 permits a bounded retry only when a pre-execution command result +explicitly carries `retryAfterRestart: true`. `executeWithDaemonRestartAsync` +waits for old ownership release and a validated successor, then resubmits an eligible +request **at most once**. Command input/output or cancellation prevents retry, +even with the typed flag. Unknown rejections and connection loss never authorize replay. +A started `install` or `update` is never repeated, including after a nonzero exit; +only an unstarted request can receive +the typed retry authorization. Accepted queued requests drain their typed restart +results before the old connection closes. + +Piped input uses protocol 0.7's negotiated stdin admission and EOF. The client does +not read input until the command attaches an input destination, and sends bounded +chunks only as the daemon grants write credits. EOF follows all preceding writes; +binary Ctrl+C bytes in a pipe are data, not cancellation signals. Older peers fall +back before `requestStart` or input consumption, and pre-execution command fallback +preserves the complete pipe for the native entrypoint. +The existing Rush entrypoints resolve project scripts from cwd. Fallback loads the +existing `@microsoft/rush` version-selecting entrypoint in the client process, +preserving its startup checks, output and reporter integration instead of +inventing a cached module path. Before auto-start or explicit start/restart, the client +selects an available daemon whose installed engine is exactly the requested Rush +version, including a native `RUSH_PREVIEW_VERSION` override. It can install a published +daemon release declaring that exact engine dependency into a node-specific Rush cache; +it never overrides dependencies or relabels the bundled engine. Foreign installations +are probed in isolation, and startup rechecks actual runtime version, protocol, and +default request-launch APIs before binding. Incompatible or unavailable launchers use +native fallback for ordinary invocations and fail explicitly for management commands. +Connect-only calls never install packages. Older Rush versions may also reject the new `daemon` config block; use +environment-only opt-in until a supporting Rush release is selected. + +## Configuration + +Every setting uses **environment > config > default**. Boolean overrides accept +only `0`/`1`; numeric overrides accept finite unsigned decimal numbers. Unknown +keys and unknown `RUSH_DAEMON*` variables fail validation. + +| `daemon` key | Environment override | Default | Runtime status | +| --- | --- | --- | --- | +| `enabled` | `RUSH_DAEMON` | false | Client routing | +| `autoStart` | `RUSH_DAEMON_AUTO_START` | true | Only after opt-in | +| `idleTimeoutSeconds` | `RUSH_DAEMON_IDLE_TIMEOUT_SECONDS` | 900 | Host idle shutdown after request/output/cleanup drain | +| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Sent through existing admission contract | +| `watch` | `RUSH_DAEMON_WATCH` | false | Persistent host observation of requested warm projects; false keeps root/config guards only. Never schedules builds | +| `usePersistentIpcRunners` | `RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS` | false | Enables explicit per-operation `daemonIpc` Node launchers for unsharded incremental daemon builds | +| `warmIdleTimeoutSeconds` | `RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS` | 300 | Idle runner, project-watcher and retained-result eviction | +| `warmMemoryBudgetMB` | `RUSH_DAEMON_WARM_MEMORY_BUDGET_MB` | 512 | Best-effort sampled RSS budget in MiB, not a hard ceiling | +| `warmSetMaxProjects` | `RUSH_DAEMON_WARM_SET_MAX_PROJECTS` | 20 | Best-effort retained-project limit; never trims requested execution | +| `autoWarmByTelemetry` | `RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY` | false | Measured retention ranking with conservative LRU fallback; no speculative scripts | + +For genuine persistent Node execution, enable `usePersistentIpcRunners` and add +`operationSettings[].daemonIpc: { entryPoint, args? }` in the project's `config/rush-project.json`. +`entryPoint` is explicitly project-root-relative, including for inherited/rig settings, and must reside in a +dedicated implementation subdirectory. Node is spawned directly on Linux and Windows; args are literal tokens, +followed by non-ignored native custom parameters. Arbitrary shell strings are not reinterpreted. +The complete implementation directory is fingerprinted (256 entries, 16 levels, 8 MiB maximum); imports outside +it other than Node built-ins are unsupported. Ordinary input/output files must live outside that directory. +Code or descriptor changes replace the old generation, while ordinary input changes reuse the child. +The tool must implement the native IPC protocol and report real RSS; no extra runs manufacture telemetry. +IPC is non-cacheable. Rebuild, NoOp/missing-script behavior, preassigned shards and non-opted-in/native paths +stay unchanged, and existing watch-only IPC declarations do not activate this mode. +Ordinary `daemon status` reports raw measured `workspace.warmSet.projectRanks` when available. + +Timeouts must be positive and at most 2147483.647 seconds; queue timeout additionally +accepts zero and is rounded down to milliseconds. Memory budget must be positive +and no larger than JavaScript's maximum safe integer. Project count must be a +positive safe integer. The session automatically owns these warm policies for its real +graph and watchers. Executing/prepared work and protected resources are not evicted. +Missing child-memory measurements stay explicitly unknown; unavoidable active/base +memory pressure is reported rather than hidden. No warm-set setting changes build correctness. +Project observation previously ran regardless of `watch`. Its existing default `false` +now disables host project observation; set it to `true` to retain observation between +requests. Every explicit native request still refreshes inputs and effective configuration. +Changing this flag neither discards warm results/runners nor starts scripts; safe idle +maintenance applies watcher changes and reports deferred or failed cleanup. + +## Management + +`rush-client daemon start` explicitly requests startup, independently of +`daemon.enabled`, `autoStart`, or CI execution routing. It conflicts with +`--no-daemon`. It is idempotent: an existing compatible daemon is reused, not +reconfigured. Startup uses the same detached, locked launcher as automatic +startup and selects an attested launcher rather than guessing a path for another Rush version. +With an explicit matching launcher, a daemon implementation-version mismatch triggers +ownership-checked replacement under the start mutex before executing any command. +It does not replace a peer lacking safe shutdown support. Foreign package installation +is a client preparation step; host self-restart selects only bundled or already cached +compatible installations, never installing while the old workspace is being cleaned up. + +`rush-client daemon status` only connects and checks hello/pong. It never starts +a process, reclaims files, or treats a PID file as evidence of readiness. Both +commands print one JSON object with `state: "ready"`, `socketPath`, and the actual +pong fields (`uptimeMs`, available versions, optional `pid` and +`residentMemoryBytes`, and an optional `workspace` snapshot). Exit code 0 means protocol +readiness, not build support. An unreachable/incompatible endpoint, invalid +arguments, or startup failure returns exit code 1 with a diagnostic. + +The optional workspace snapshot reports the provider generation/token, graph existence, +and available warm accounting without initializing a graph. Missing fields are unknown, +not proof of zero memory or successful reload. Status can inspect a protocol-compatible +daemon with a different implementation version; start requires the bundled version to match. + +| `workspace` field | Meaning | +| --- | --- | +| `generation`, `generationToken` | Current provider generation and installed session identity | +| `lastReloadTier` | Lifecycle-owned `0` initial/reuse, `1` successful in-process reload, or `2` requested restart; older peers may omit it | +| `graphInitialized` | A graph exists; this does not attest build success | +| `warmSet.configuration` | Effective `watch` and four warm-resource settings; older peers may omit `watch` | +| `warmSet.maintenanceState`, `warmSet.maintenanceFailure` | Running, quiescing, stopped or failed maintenance; stopping it does not itself free resources | +| `warmSet.retainedProjectNames`, `warmSet.protectedProjectNames`, `warmSet.watchedProjectNames` | Actual retained/protected projects and resident project observation | +| `warmSet.daemonResidentMemoryBytes`, `warmSet.measuredRunnerMemoryBytes`, `warmSet.unmeasuredRunnerCount` | Daemon RSS, last-completion child RSS samples, and explicitly unmeasured resident runners; descendants are not included | +| `warmSet.overMemoryBudget`, `warmSet.overProjectLimit`, `warmSet.cleanupFailures`, `warmSet.deferredReason` | Outstanding footprint pressure, cleanup failures and maintenance deferral | + +An absent `warmSet` means no controller is attached, not that the workspace consumes +no memory. Status reads `lastReloadTier` from the lifecycle (zero for a host without one); +it does not infer a tier from PID/generation changes or initiate a reload. Tier `2` +attests a restart request, not completion of successor startup or success of a command. + +`rush-client daemon stop` requires protocol >= 0.6 and waits for `shutdownAck` +followed by EOF. It reports `state: "shutdownAccepted"` with exit code 0; this +does not assert successful workspace disposal. An absent/unreachable daemon, +unsupported protocol, missing acknowledgement, or timeout returns exit code 1. +It does not auto-start anything. + +`rush-client daemon restart` first verifies that the selected Rush version has a +launcher and captures the original lock's PID/start timestamp, checking that it +matches pong's positive PID and the selected endpoint, then performs acknowledged +shutdown. It waits for original ownership release or a demonstrably dead owner +before calling the existing locked starter. A live/reused owner fails closed at +the startup deadline; no PID is killed and no live ownership record is deleted. +A newly +started/reused successor must pass hello/ping before reporting `state: "ready"`. +An absent daemon must be started explicitly with `daemon start`. + +Restart is explicit even when automatic startup or CI execution routing is +disabled, but conflicts with `--no-daemon`. The two-phase host retains ownership +until workspace disposal succeeds, so embedded hosts can restart a workspace +without exiting their process. Failed cleanup retains the live lock and causes a +bounded restart failure, even if the socket has already disappeared. A changed +owner is reconnected and validated, not overwritten. + +`rush-client daemon logs` prints a snapshot of the selected workspace's launcher +log, whether the daemon is running or stopped. It never connects or auto-starts. +The stable path comes from `getDaemonLogFilePath(paths)`: `.log`. +Detached child stdout and stderr are appended to this file across restarts; the +parent closes its descriptor after spawning. On POSIX the launcher enforces mode +`0600` and rejects linked destinations; Windows uses the existing per-user +transport directory permissions. + +Default reading is bounded to the size observed when the log is opened, with chunked, +backpressured output. Empty snapshots succeed without output; missing/unreadable logs +or invalid destinations fail with a diagnostic and exit code 1. A launcher log +may not exist for a daemon started outside this client. + +`rush-client daemon logs --follow` also streams bytes appended after EOF, using +at most 64 KiB per read and waiting for output backpressure before reading more. +It never connects or starts a daemon, including when following an empty log. +SIGINT/SIGTERM cancel following with exit code 130 after closing the log descriptor; +pending display bytes may be discarded rather than keeping the client alive +indefinitely behind a stalled output pipe. Read/write failures remain explicit errors. Truncation, +replacement, or removal fails explicitly instead of silently following the wrong +file; reopen the command after rotating a log. No automatic rotation policy is added. + +On Windows, following redirected stdout uses an invocation-owned, output-only Node +process. Windows pipe writes can block the CLI event loop, and moving a blocked +write to its filesystem thread pool alone can still prevent shutdown. The CLI +instead sends one acknowledged chunk of at most 64 KiB to the isolated writer. +Cancellation stops and joins that writer before the CLI exits; a lost parent +also terminates the writer, so blocked display output is not orphaned. The worker +does not run user `NODE_OPTIONS` preload hooks or any Rush command. This is not +daemon startup. Default snapshots, TTY output, and the non-Windows output path +retain their existing stdio behavior. + +This is the **text launcher stdout/stderr log**, including startup errors—not +WS5 structured observability or a subscription to request-scoped events. + +## Experimental graph reference client + +Set `RUSH_DAEMON_EXPERIMENTAL=1` and use an explicitly started daemon: + +```sh +export RUSH_DAEMON_EXPERIMENTAL=1 +rush-client daemon start +rush-client daemon graph show +rush-client build --to my-project +rush-client daemon graph scope-out --project my-project +rush-client daemon graph scope-in --operation 'my-project (compile)' +rush-client daemon graph invalidate --project my-project +rush-client daemon graph pause +rush-client daemon graph status +rush-client daemon graph resume +rush-client daemon graph watch +``` + +All graph commands connect only; they never auto-start, initialize the graph, or +fall back to native Rush. Cold `show` and `status` report `initialized: false` +without an `operations` field. Other verbs fail explicitly until a supported, +explicit build request has initialized the graph. The gate is checked both by the +CLI and against the server request's environment. Older or unsupported servers, +invalid arguments, and unknown selectors fail, never invoke a shell. + +`show` and `status` both emit a complete point-in-time metadata snapshot, including +operation IDs, exact project/phase names, native enabled states, observed statuses, +dependency IDs, manual-mode/scheduled flags, and a path-free invalidation summary. +An operation without an observed execution status reports `null`. An idle operation +whose actual completed result was evicted reports `READY` for request-time revalidation, +not historical success; inspection does not schedule work. Snapshots contain no +environment, runner, log, or terminal objects. + +Protocol 0.9 snapshots include an opaque `workspaceGeneration` token. Every mutation +echoes a token, checked under exclusive admission before touching the graph. The +token changes on soft reload and process replacement, preventing stale operation +references from affecting a new generation. Use `--generation TOKEN` with a token +from an earlier snapshot to preserve that reference; the client never refreshes an +explicit token. Without this option, the client privately reads current status +before submitting the mutation. A reload between those requests fails closed. +Mutations reject older peers before submission; read-only inspection remains compatible. + +Graph requests use the same resolved queue options as ordinary execution: +`--no-wait`/`--wait-timeout` override environment, config, and the 30-second default. +An implicit generation lookup and its mutation share one positive admission +deadline; time spent connecting and reading the token is not reset before the +mutation. If the budget expires, no later query is sent. Zero requests immediate +server admission, as does `--no-wait`, without imposing a zero-length connection +timeout. Cancellation covers generation preflight too. Once a mutation is admitted, +its execution is not timed out by the admission budget. + +`scope-in`, `scope-out`, and `invalidate` require one or more repeated +`--project NAME` or `--operation ID` pairs. Names and IDs match exactly; there are +no globs or implicit all-project selections. Every selector is validated before +any mutation. Scope-in enables transitive dependencies. Scope-out includes +transitive consumers and uses native safe-disable, which also prunes dependencies +no longer needed by enabled operations. Invalidation marks selected native results +stale without scheduling work. Mutations use exclusive workspace admission and +never change an active iteration. Scope changes/invalidation reject an already +prepared iteration instead of modifying stale execution records. + +Pause/resume change native `pauseNextIteration`: manual mode gates automatically +scheduled iterations, **not explicit build requests**. Resume does not create new +work. If an engine owner has already prepared an automatic iteration, resume +acquires the native execution lease, discards the old unstarted plan, reconciles +inputs, and prepares its replacement before releasing it. Admission and the native +lease remain held until native idle, including +if the resume client disconnects. The reference client's watch command is not a +build scheduler; the default lazy engine still rebuilds only on explicit requests. +Native build requests apply their own selections, so a graph scope is not a +persistent override of later build arguments. + +Graph stdout is NDJSON only. Snapshots use the existing `extension` event envelope +with `payload.name: "rushd.graph-snapshot"` and +`payload.data: { requestId, snapshot }`. The existing `requestResult` is emitted as +the terminal record; queue positions and request rejections also retain their +control-message shapes. Local failures use `{ kind: "graphError", message }`. +No human renderer, ANSI styling, or graph-specific transport is involved. + +`watch` emits an initial snapshot followed by relevant graph state, invalidation, +and idle updates. It holds no scheduler lease, so other clients can build. +Slow consumers receive coalesced latest snapshots rather than every intermediate +transition or an unbounded event history. SIGINT/SIGTERM cancel the subscription +and wait for the authoritative aborted result (exit 130). Disconnect removes +subscriber resources without cancelling another client's build. Graph hooks are +installed once per graph, not once per connection. + +## Remaining epic constraints + +Origin issues #5894 and #5896 still require reconciliation with the canonical +reporter contract and built-in reporters. The current wire consumers use +`IDaemonEventEnvelope`/`DaemonEventType`; `ClientOperationRenderer` feeds +`DaemonRendererHost`, whose default is `LegacyCollatedRenderer`. These inherited +contracts are not replaced by the WS4 terminal, graph-admission, or log-follow +fixes. Explicit reporter controls continue to select the native reporter path. +Reporter reconciliation remains a separate epic decision, not an assertion that +the WS5 default flip or the full daemon transparency matrix has passed. diff --git a/apps/rush-cli-client/bin/rush-client b/apps/rush-cli-client/bin/rush-client new file mode 100755 index 0000000000..eef2fc2706 --- /dev/null +++ b/apps/rush-cli-client/bin/rush-client @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib-commonjs/start.js'); diff --git a/apps/rush-cli-client/bin/rushx-client b/apps/rush-cli-client/bin/rushx-client new file mode 100755 index 0000000000..8820423f29 --- /dev/null +++ b/apps/rush-cli-client/bin/rushx-client @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib-commonjs/startRushX.js'); diff --git a/apps/rush-cli-client/config/jest.config.json b/apps/rush-cli-client/config/jest.config.json new file mode 100644 index 0000000000..d1749681d9 --- /dev/null +++ b/apps/rush-cli-client/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/apps/rush-cli-client/config/rig.json b/apps/rush-cli-client/config/rig.json new file mode 100644 index 0000000000..596af2f5a4 --- /dev/null +++ b/apps/rush-cli-client/config/rig.json @@ -0,0 +1,3 @@ +{ + "rigPackageName": "local-node-rig" +} diff --git a/apps/rush-cli-client/eslint.config.js b/apps/rush-cli-client/eslint.config.js new file mode 100644 index 0000000000..07a548c512 --- /dev/null +++ b/apps/rush-cli-client/eslint.config.js @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { parserOptions: { tsconfigRootDir: __dirname } } + } +]; diff --git a/apps/rush-cli-client/package.json b/apps/rush-cli-client/package.json new file mode 100644 index 0000000000..5f7f719803 --- /dev/null +++ b/apps/rush-cli-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@rushstack/rush-cli-client", + "version": "0.1.0", + "description": "Opt-in Rush and rushx daemon clients", + "bin": { + "rush-client": "./bin/rush-client", + "rushx-client": "./bin/rushx-client" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "apps/rush-cli-client" + }, + "engines": { + "node": ">=20.9.0" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@microsoft/rush": "workspace:*", + "@microsoft/rush-lib": "workspace:*", + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-client-core": "workspace:*", + "@rushstack/rush-daemon": "workspace:*", + "@rushstack/rush-daemon-transport": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/rush-terminal-renderer": "workspace:*", + "@rushstack/terminal": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + } +} diff --git a/apps/rush-cli-client/src/ClientAdmissionControls.ts b/apps/rush-cli-client/src/ClientAdmissionControls.ts new file mode 100644 index 0000000000..fdef84bb44 --- /dev/null +++ b/apps/rush-cli-client/src/ClientAdmissionControls.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS, + validateDaemonRequestAdmissionOptions, + type IDaemonRequestAdmissionOptions +} from '@rushstack/rush-daemon-protocol'; + +export interface IClientAdmissionControls { + readonly argv: ReadonlyArray; + readonly admission: IDaemonRequestAdmissionOptions | undefined; +} + +/** Consumes only daemon admission controls before the explicit script-argument separator. */ +export function parseClientAdmissionControls(argv: ReadonlyArray): IClientAdmissionControls { + const remaining: string[] = []; + let noWait: boolean = false; + let waitTimeoutMs: number | undefined; + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg === '--') { + remaining.push(...argv.slice(index)); + break; + } + if (arg === '--no-wait') { + noWait = true; + } else if (arg.startsWith('--no-wait=')) { + throw new Error('--no-wait does not accept a value.'); + } else if (arg === '--wait-timeout' || arg.startsWith('--wait-timeout=')) { + if (waitTimeoutMs !== undefined) throw new Error('--wait-timeout may be specified only once.'); + const value: string | undefined = arg === '--wait-timeout' + ? argv[++index] + : arg.slice('--wait-timeout='.length); + if (value === undefined || !/^\d+(?:\.\d+)?$/.test(value)) { + throw new Error('--wait-timeout requires a nonnegative decimal number of seconds.'); + } + const seconds: number = Number(value); + if (seconds > MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS / 1000) { + throw new RangeError('--wait-timeout exceeds the supported timer range.'); + } + waitTimeoutMs = Math.floor(seconds * 1000); + validateDaemonRequestAdmissionOptions({ waitTimeoutMs }); + } else { + remaining.push(arg); + } + } + if (noWait && waitTimeoutMs !== undefined) { + throw new Error('--no-wait and --wait-timeout cannot be combined.'); + } + const admission: IDaemonRequestAdmissionOptions | undefined = noWait + ? { noWait: true } + : waitTimeoutMs === undefined ? undefined : { waitTimeoutMs }; + return { argv: remaining, admission }; +} diff --git a/apps/rush-cli-client/src/ClientOperationRenderer.ts b/apps/rush-cli-client/src/ClientOperationRenderer.ts new file mode 100644 index 0000000000..5a682185c8 --- /dev/null +++ b/apps/rush-cli-client/src/ClientOperationRenderer.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonVerbosity, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; +import { + DaemonRendererHost, + type DaemonRenderStream, + type IDaemonRendererTerminal +} from '@rushstack/rush-terminal-renderer'; + +interface IRenderedChunk { + readonly text: string; + readonly stream: DaemonRenderStream; +} + +interface IClientOperationRendererOptions { + readonly requestId: string; + readonly terminal: Pick; + readonly colorLevel: number; + readonly verbosity: DaemonVerbosity; + readonly writeAsync: (bytes: Uint8Array, stream: DaemonRenderStream) => Promise; +} + +/** Adapts the synchronous terminal renderer to the wire client's asynchronous output contract. */ +export class ClientOperationRenderer { + readonly #options: IClientOperationRendererOptions; + readonly #host: DaemonRendererHost; + #chunks: IRenderedChunk[] = []; + + public constructor(options: IClientOperationRendererOptions) { + this.#options = options; + this.#host = new DaemonRendererHost({ + colorLevel: options.colorLevel, + verbosity: options.verbosity, + terminal: { + get columns() { return options.terminal.columns; }, + get isTTY() { return options.terminal.isTTY; }, + write: (text, stream) => { this.#chunks.push({ text, stream }); } + } + }); + } + + public initializeAsync(): Promise { + return this.#host.initializeAsync(); + } + + public async writeEventAsync(event: IDaemonEventEnvelope): Promise { + try { + this.#host.handleEvent(event); + } finally { + await this.#flushAsync(); + } + } + + public async writeLogAsync( + bytes: Uint8Array, + operationId: string, + stream: DaemonRenderStream + ): Promise { + if (operationId === this.#options.requestId) { + // Global-command streams are raw bytes, not collated operation text. + await this.#options.writeAsync(bytes, stream); + return; + } + try { + this.#host.handleLogChunk(operationId, stream, bytes); + } finally { + await this.#flushAsync(); + } + } + + public async closeAsync(): Promise { + try { + await this.#host.closeAsync(); + } finally { + await this.#flushAsync(); + } + } + + async #flushAsync(): Promise { + const chunks: IRenderedChunk[] = this.#chunks; + this.#chunks = []; + for (const chunk of chunks) { + await this.#options.writeAsync(Buffer.from(chunk.text), chunk.stream); + } + } +} diff --git a/apps/rush-cli-client/src/DaemonLogOutput.ts b/apps/rush-cli-client/src/DaemonLogOutput.ts new file mode 100644 index 0000000000..39013854f4 --- /dev/null +++ b/apps/rush-cli-client/src/DaemonLogOutput.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as path from 'node:path'; + +import { MAX_LOG_OUTPUT_BYTES, type LogOutputRequest } from './DaemonLogOutputProtocol'; + +interface IPendingWrite { + readonly byteLength: number; + readonly resolve: () => void; + readonly reject: (reason: unknown) => void; +} + +/** + * Keeps blocking Windows stdout writes out of the CLI's event loop and thread pool. + * One acknowledged chunk is in flight; every exit joins the output-only child. + */ +export class DaemonLogOutput { + readonly #signal: AbortSignal | undefined; + readonly #outputFd: number; + readonly #failureController: AbortController = new AbortController(); + #child: ChildProcess | undefined; + #ready: Promise | undefined; + #resolveReady: (() => void) | undefined; + #rejectReady: ((reason: unknown) => void) | undefined; + #pending: IPendingWrite | undefined; + #failure: Error | undefined; + #closed: Promise = Promise.resolve(); + #closePromise: Promise | undefined; + #stopRequested: boolean = false; + #cancelled: boolean = false; + + public constructor(signal?: AbortSignal, outputFd: number = 1) { + this.#signal = signal; + this.#outputFd = outputFd; + signal?.addEventListener('abort', this.#onAbort, { once: true }); + } + + public get failureSignal(): AbortSignal { + return this.#failureController.signal; + } + + public get workerPid(): number | undefined { + return this.#child?.pid; + } + + public async writeAsync(bytes: Uint8Array): Promise { + if (bytes.byteLength > MAX_LOG_OUTPUT_BYTES) + throw new RangeError('Daemon log output chunk is too large.'); + this.#signal?.throwIfAborted(); + this.failureSignal.throwIfAborted(); + if (this.#closePromise) throw new Error('Daemon log output is closed.'); + await this.#startAsync(); + this.#signal?.throwIfAborted(); + this.failureSignal.throwIfAborted(); + if (this.#pending) throw new Error('Daemon log output already has a pending write.'); + await new Promise((resolve, reject) => { + this.#pending = { byteLength: bytes.byteLength, resolve, reject }; + this.#send({ kind: 'write', bytes }); + }); + } + + public closeAsync(): Promise { + this.#closePromise ??= this.#closeOnceAsync(); + return this.#closePromise; + } + + async #closeOnceAsync(): Promise { + try { + if (this.#child && !this.#stopRequested) { + this.#stopRequested = true; + if (this.#signal?.aborted || this.#failure || this.#pending) { + this.#cancelled = true; + this.#child.kill('SIGKILL'); + } else if (this.#child.connected) { + this.#send({ kind: 'end' }); + } + } + await this.#closed; + this.failureSignal.throwIfAborted(); + } finally { + this.#signal?.removeEventListener('abort', this.#onAbort); + } + } + + #startAsync(): Promise { + if (this.#ready) return this.#ready; + this.#ready = new Promise((resolve, reject) => { + this.#resolveReady = resolve; + this.#rejectReady = reject; + }); + const child: ChildProcess = spawn(process.execPath, [path.join(__dirname, 'DaemonLogOutputWorker.js')], { + stdio: ['ignore', 'ignore', 'ignore', this.#outputFd, 'ipc'], + serialization: 'advanced', + windowsHide: true, + // This private I/O worker must not run user preload hooks or arbitrary user work. + env: { ...process.env, NODE_OPTIONS: undefined } + }); + this.#child = child; + this.#closed = new Promise((resolve) => { + child.once('close', (code, signal) => { + if (!this.#stopRequested || (!this.#cancelled && code !== 0)) { + this.#fail(new Error(`Daemon log output worker exited unexpectedly (${code ?? signal}).`)); + } + this.#pending?.reject( + this.#failure ?? this.#signal?.reason ?? new Error('Daemon log output closed.') + ); + this.#pending = undefined; + resolve(); + }); + }); + child.once('error', (error) => this.#fail(error)); + child.once('spawn', () => { + if (this.#signal?.aborted) this.#onAbort(); + }); + child.on('message', (message: unknown) => this.#onMessage(message)); + return this.#ready; + } + + #send(message: LogOutputRequest): void { + this.#child!.send(message, (error: Error | null) => { + if (error && !this.#cancelled) this.#fail(error); + }); + } + + #onMessage(message: unknown): void { + if (this.#cancelled) return; + if (typeof message === 'object' && message !== null && 'kind' in message) { + if (message.kind === 'ready' && this.#resolveReady) { + this.#resolveReady(); + this.#resolveReady = undefined; + return; + } + const pending: IPendingWrite | undefined = this.#pending; + if ( + message.kind === 'written' && + 'byteLength' in message && + pending && + pending.byteLength === message.byteLength + ) { + pending.resolve(); + this.#pending = undefined; + return; + } + if (message.kind === 'error' && 'message' in message && typeof message.message === 'string') { + const error: NodeJS.ErrnoException = new Error(`Daemon log output failed: ${message.message}`); + if ('code' in message && typeof message.code === 'string') error.code = message.code; + this.#fail(error); + return; + } + } + this.#fail(new Error('Invalid daemon log output acknowledgement.')); + } + + #fail(error: Error): void { + this.#failure ??= error; + this.#failureController.abort(this.#failure); + this.#rejectReady?.(this.#failure); + this.#pending?.reject(this.#failure); + this.#pending = undefined; + if (this.#child && !this.#stopRequested) { + this.#stopRequested = true; + this.#cancelled = true; + this.#child.kill('SIGKILL'); + } + } + + readonly #onAbort = (): void => { + this.#cancelled = true; + this.#rejectReady?.(this.#signal?.reason); + this.#pending?.reject(this.#signal?.reason); + this.#pending = undefined; + if (this.#child) { + this.#stopRequested = true; + this.#child.kill('SIGKILL'); + } + }; +} diff --git a/apps/rush-cli-client/src/DaemonLogOutputProtocol.ts b/apps/rush-cli-client/src/DaemonLogOutputProtocol.ts new file mode 100644 index 0000000000..1778d3d1a7 --- /dev/null +++ b/apps/rush-cli-client/src/DaemonLogOutputProtocol.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export const MAX_LOG_OUTPUT_BYTES: number = 64 * 1024; +export const LOG_OUTPUT_FD: number = 3; + +export type LogOutputRequest = + | { readonly kind: 'write'; readonly bytes: Uint8Array } + | { readonly kind: 'end' }; + +export type LogOutputResponse = + | { readonly kind: 'ready' } + | { readonly kind: 'written'; readonly byteLength: number } + | { readonly kind: 'error'; readonly message: string; readonly code?: string }; diff --git a/apps/rush-cli-client/src/DaemonLogOutputWorker.ts b/apps/rush-cli-client/src/DaemonLogOutputWorker.ts new file mode 100644 index 0000000000..dfe9015115 --- /dev/null +++ b/apps/rush-cli-client/src/DaemonLogOutputWorker.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { write } from 'node:fs'; + +import { LOG_OUTPUT_FD, MAX_LOG_OUTPUT_BYTES, type LogOutputResponse } from './DaemonLogOutputProtocol'; + +let writing: boolean = false; +let finishing: boolean = false; + +function terminate(): void { + // This process owns only a duplicate output descriptor. A blocked filesystem write cannot be + // cancelled by JavaScript; terminating this isolated writer also handles an abruptly lost parent. + process.kill(process.pid, 'SIGKILL'); +} + +function send(message: LogOutputResponse, completed?: () => void): void { + if (!process.send || !process.connected) { + terminate(); + return; + } + process.send(message, (error: Error | null) => { + if (error) terminate(); + else completed?.(); + }); +} + +function fail(error: Error & { code?: string }): void { + if (finishing) return; + finishing = true; + process.exitCode = 1; + send({ kind: 'error', message: error.message, code: error.code }, () => { + if (writing) terminate(); + else process.disconnect!(); + }); +} + +async function writeAllAsync(bytes: Uint8Array): Promise { + let offset: number = 0; + while (offset < bytes.byteLength) { + const count: number = await new Promise((resolve, reject) => { + write(LOG_OUTPUT_FD, bytes, offset, bytes.byteLength - offset, null, (error, written) => { + if (error) reject(error); + else resolve(written); + }); + }); + if (count <= 0) throw new Error('Daemon log output made no write progress.'); + offset += count; + } +} + +process.once('disconnect', () => { + if (!finishing || writing) terminate(); +}); + +process.on('message', (message: unknown) => { + if (finishing) return; + if (typeof message !== 'object' || message === null || !('kind' in message)) { + fail(new Error('Invalid daemon log output request.')); + return; + } + if (message.kind === 'end' && !writing) { + finishing = true; + process.exitCode = 0; + process.disconnect!(); + return; + } + if ( + message.kind !== 'write' || + writing || + !('bytes' in message) || + !(message.bytes instanceof Uint8Array) || + message.bytes.byteLength > MAX_LOG_OUTPUT_BYTES + ) { + fail(new Error('Daemon log output requires one bounded write at a time.')); + return; + } + const bytes: Uint8Array = message.bytes; + writing = true; + void writeAllAsync(bytes).then( + () => { + writing = false; + if (!finishing) send({ kind: 'written', byteLength: bytes.byteLength }); + }, + (error: Error) => { + writing = false; + fail(error); + } + ); +}); + +send({ kind: 'ready' }); diff --git a/apps/rush-cli-client/src/GraphGenerationControls.ts b/apps/rush-cli-client/src/GraphGenerationControls.ts new file mode 100644 index 0000000000..8b315453f8 --- /dev/null +++ b/apps/rush-cli-client/src/GraphGenerationControls.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + captureDaemonRequest, + type DaemonClient, + type DaemonClientOutcome, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import { + DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR, + isDaemonControlRecord, + RUSHD_GRAPH_SNAPSHOT, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import type { GraphRequestContext } from './GraphRequestContext'; + +export interface IGraphGenerationControls { + readonly argv: ReadonlyArray; + readonly mutation: boolean; + readonly generation?: string; +} + +export function parseGraphGenerationControls(argv: ReadonlyArray): IGraphGenerationControls { + const mutation: boolean = ['scope-in', 'scope-out', 'invalidate', 'pause', 'resume'].includes(argv[1]); + const remaining: string[] = []; + let generation: string | undefined; + for (let i: number = 0; i < argv.length; i++) { + const arg: string = argv[i]; + if (arg === '--generation' || arg.startsWith('--generation=')) { + if (!mutation || generation !== undefined) { + throw new Error('--generation is accepted once, on a graph mutation only.'); + } + generation = arg === '--generation' ? argv[++i] : arg.slice('--generation='.length); + if (!generation || generation.trim() !== generation || generation.startsWith('--')) { + throw new Error('--generation requires the opaque token from a graph snapshot.'); + } + } else { + remaining.push(arg); + } + } + return { argv: remaining, mutation, generation }; +} + +/** Reads a current token without printing a second snapshot or mutating anything. */ +export async function readGraphGenerationAsync( + connection: IConnectOrStartDaemonOptions, + environment: Readonly, + context: GraphRequestContext +): Promise { + const client: DaemonClient = await context.connectAsync({ socketPath: connection.paths.socketPath }); + try { + if (client.protocolVersion.minor < DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR) { + throw new Error('Graph mutations require generation-aware protocol 0.9 or newer.'); + } + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: ['daemon', 'graph', 'status'], + commandName: 'daemon', + commandOrigin: 'built-in', + cwd: process.cwd(), + environment, + terminal: { isTTY: false, supportsColor: false }, + admission: context.admission + }); + let generation: string | undefined; + const outcome: DaemonClientOutcome = await client.executeAsync({ + request, + abortSignal: context.signal, + onEventAsync: async (event) => { + const payload: unknown = event.payload; + if ( + event.type !== 'extension' || + !isDaemonControlRecord(payload) || + payload.name !== RUSHD_GRAPH_SNAPSHOT || + !isDaemonControlRecord(payload.data) || + payload.data.requestId !== request.requestId || + !isDaemonControlRecord(payload.data.snapshot) || + typeof payload.data.snapshot.initialized !== 'boolean' + ) + throw new Error('Invalid graph-generation snapshot.'); + const token: unknown = payload.data.snapshot.workspaceGeneration; + if (typeof token !== 'string' || token.length === 0) + throw new Error('Missing graph generation token.'); + generation = token; + } + }); + context.signal.throwIfAborted(); + if (outcome.kind === 'result' && outcome.result.admissionErrorCode) { + throw new Error(`Graph generation preflight admission failed (${outcome.result.admissionErrorCode}).`); + } + if (outcome.kind !== 'result' || outcome.result.exitCode !== 0 || generation === undefined) { + throw new Error('Could not obtain a current graph generation; no mutation was attempted.'); + } + return generation; + } finally { + await client.closeAsync(); + } +} diff --git a/apps/rush-cli-client/src/GraphRequestContext.ts b/apps/rush-cli-client/src/GraphRequestContext.ts new file mode 100644 index 0000000000..6dc196e858 --- /dev/null +++ b/apps/rush-cli-client/src/GraphRequestContext.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonClient, type IDaemonClientConnectOptions } from '@rushstack/rush-client-core'; +import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; + +export class GraphRequestCancelledError extends Error { + public constructor() { + super('Graph request cancelled.'); + } +} + +/** Owns cancellation and one admission deadline across generation lookup and mutation. */ +export class GraphRequestContext implements Disposable { + readonly #abort: AbortController = new AbortController(); + readonly #admission: IDaemonRequestAdmissionOptions; + readonly #deadline: number | undefined; + + public constructor(admission: IDaemonRequestAdmissionOptions) { + this.#admission = Object.freeze({ ...admission }); + // Zero means immediate server admission, not a timeout on connecting to the server. + this.#deadline = admission.waitTimeoutMs ? Date.now() + admission.waitTimeoutMs : undefined; + process.on('SIGINT', this.#onSignal); + process.on('SIGTERM', this.#onSignal); + } + + public get signal(): AbortSignal { + return this.#abort.signal; + } + + public get admission(): IDaemonRequestAdmissionOptions { + const remaining: number | undefined = this.#remainingTimeoutMs(); + return remaining === undefined ? this.#admission : { ...this.#admission, waitTimeoutMs: remaining }; + } + + public async connectAsync(options: IDaemonClientConnectOptions): Promise { + const remaining: number | undefined = this.admission.waitTimeoutMs; + let client: DaemonClient; + try { + client = await DaemonClient.connectAsync({ + ...options, + timeoutMs: remaining ? Math.min(remaining, options.timeoutMs ?? 5000) : options.timeoutMs + }); + } catch (error) { + this.signal.throwIfAborted(); + throw error; + } + try { + this.#remainingTimeoutMs(); + return client; + } catch (error) { + await client.closeAsync(); + throw error; + } + } + + public [Symbol.dispose](): void { + process.removeListener('SIGINT', this.#onSignal); + process.removeListener('SIGTERM', this.#onSignal); + } + + #remainingTimeoutMs(): number | undefined { + this.signal.throwIfAborted(); + if (this.#deadline === undefined) return undefined; + const remaining: number = this.#deadline - Date.now(); + if (remaining <= 0) { + throw new Error('Graph admission deadline expired; no further graph request was sent.'); + } + return remaining; + } + + readonly #onSignal = (): void => this.#abort.abort(new GraphRequestCancelledError()); +} diff --git a/apps/rush-cli-client/src/daemonCommands.ts b/apps/rush-cli-client/src/daemonCommands.ts new file mode 100644 index 0000000000..d2c3e592b1 --- /dev/null +++ b/apps/rush-cli-client/src/daemonCommands.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + DaemonClient, + connectOrStartDaemonAsync, + requestDaemonShutdownAsync, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import type { IDaemonLockfile } from '@rushstack/rush-daemon-transport'; +import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; + +import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; +import { printDaemonLogAsync } from './daemonLogs'; +import { executeDaemonGraphCommandAsync } from './daemonGraph'; +import { writeStreamAsync } from './writeStreamAsync'; + +export interface IDaemonCommandOptions { + readonly argv: ReadonlyArray; + readonly environment: Readonly; + readonly rushJsonPath?: string; + readonly rushVersion: string; + readonly admission?: IDaemonRequestAdmissionOptions; +} + +export async function executeDaemonCommandAsync(options: IDaemonCommandOptions): Promise { + const command: string | undefined = options.argv[0]; + if (options.admission && command !== 'graph') { + throw new Error( + 'Daemon admission controls apply to command execution or graph requests, not lifecycle commands.' + ); + } + if (command === 'graph') { + await executeDaemonGraphCommandAsync(options); + return; + } + if ( + (options.argv.length !== 1 && + !(command === 'logs' && options.argv.length === 2 && options.argv[1] === '--follow')) || + (command !== 'start' && + command !== 'status' && + command !== 'stop' && + command !== 'restart' && + command !== 'logs') + ) { + throw new Error('Usage: rush-client daemon start|status|stop|restart|logs [--follow]'); + } + if (!options.rushJsonPath) throw new Error('Daemon management requires a repository containing rush.json.'); + const mayStart: boolean = command === 'start' || command === 'restart'; + const connectionOptions: IConnectOrStartDaemonOptions = await getDaemonConnectionOptionsAsync( + path.dirname(options.rushJsonPath), + options.rushVersion, + options.environment, + mayStart + ); + if (command === 'logs') { + if (options.argv[1] !== '--follow') { + await printDaemonLogAsync(connectionOptions.paths); + return; + } + const abort: AbortController = new AbortController(); + const onSignal = (): void => abort.abort(new Error('Daemon log following cancelled.')); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + try { + await printDaemonLogAsync(connectionOptions.paths, { follow: true, abortSignal: abort.signal }); + } finally { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + } + // Node's process.stdout cannot be destroyed like an ordinary Writable. The log file is closed first. + if (abort.signal.aborted) process.exit(130); + return; + } + // Status observes the selected endpoint, including a compatible daemon from a different client version. + // It never starts a process or trusts a PID file as evidence of readiness. + const client: DaemonClient = + command === 'start' + ? await connectOrStartDaemonAsync(connectionOptions) + : await DaemonClient.connectAsync({ socketPath: connectionOptions.paths.socketPath }); + try { + if (command === 'stop') { + await client.shutdownAsync(); + await writeStatusAsync({ + state: 'shutdownAccepted', + socketPath: connectionOptions.paths.socketPath + }); + return; + } + const readyClient: DaemonClient = + command === 'restart' ? await restartDaemonAsync(client, connectionOptions) : client; + try { + await writeStatusAsync({ + state: 'ready', + socketPath: connectionOptions.paths.socketPath, + ...(await readyClient.status) + }); + } finally { + if (readyClient !== client) await readyClient.closeAsync(); + } + } finally { + await client.closeAsync(); + } +} + +async function restartDaemonAsync( + client: DaemonClient, + options: IConnectOrStartDaemonOptions +): Promise { + const previousDaemon: Pick = await requestDaemonShutdownAsync( + client, + options.paths + ); + return await connectOrStartDaemonAsync({ ...options, previousDaemon }); +} + +function writeStatusAsync(status: object): Promise { + return writeStreamAsync(process.stdout, Buffer.from(`${JSON.stringify(status)}\n`)); +} diff --git a/apps/rush-cli-client/src/daemonConnectionOptions.ts b/apps/rush-cli-client/src/daemonConnectionOptions.ts new file mode 100644 index 0000000000..ebefef0de1 --- /dev/null +++ b/apps/rush-cli-client/src/daemonConnectionOptions.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +import { Rush } from '@microsoft/rush-lib'; +import { JsonFile } from '@rushstack/node-core-library'; +import type { IConnectOrStartDaemonOptions } from '@rushstack/rush-client-core'; +import { computeDaemonWorkspaceKey, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport'; +import { readDaemonInstallationMetadata } from '@rushstack/rush-daemon/lib/DaemonInstallation'; +import { + DaemonLauncherUnavailableError, + getSelectedDaemonStartCommand, + selectDaemonLauncherAsync, + type IVersionSelectedDaemonLaunch +} from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +export function getDaemonConnectionOptions( + repoRoot: string, + rushVersion: string, + environment: Readonly, + autoStart: boolean +): IConnectOrStartDaemonOptions { + const canonicalRepoRoot: string = fs.realpathSync.native(repoRoot); + const daemonPackagePath: string = require.resolve('@rushstack/rush-daemon/package.json'); + const daemonPackage: { version: string; bin: { rushd: string } } = JsonFile.load(daemonPackagePath); + if (autoStart && readDaemonInstallationMetadata(daemonPackagePath).rushVersion !== rushVersion) { + throw new DaemonLauncherUnavailableError( + rushVersion, + 'The synchronous launcher only supports its installed engine; use asynchronous version selection.' + ); + } + return { + paths: resolveDaemonPathsFromProcess(computeDaemonWorkspaceKey({ canonicalRepoRoot, rushVersion })), + expectedDaemonVersion: daemonPackage.version, + startCommand: autoStart + ? getSelectedDaemonStartCommand(daemonPackagePath, { + repoRoot: canonicalRepoRoot, + rushVersion, + environment + }) + : undefined + }; +} + +/** Resolves an attested launcher before startup; connect-only invocations never install packages. */ +export async function getDaemonConnectionOptionsAsync( + repoRoot: string, + rushVersion: string, + environment: Readonly, + autoStart: boolean +): Promise { + const options: IConnectOrStartDaemonOptions = getDaemonConnectionOptions( + repoRoot, + rushVersion, + environment, + false + ); + if (!autoStart) return { paths: options.paths }; + // The bundled runtime is already loaded here; its bootstrap re-attests before binding. + if (rushVersion === Rush.version) return getDaemonConnectionOptions(repoRoot, rushVersion, environment, true); + const launch: IVersionSelectedDaemonLaunch = await selectDaemonLauncherAsync({ + repoRoot: fs.realpathSync.native(repoRoot), + rushVersion, + environment + }); + return { ...options, expectedDaemonVersion: launch.daemonVersion, startCommand: launch.startCommand }; +} diff --git a/apps/rush-cli-client/src/daemonGraph.ts b/apps/rush-cli-client/src/daemonGraph.ts new file mode 100644 index 0000000000..4ff1947272 --- /dev/null +++ b/apps/rush-cli-client/src/daemonGraph.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + captureDaemonRequest, + type DaemonClient, + type DaemonClientOutcome, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import { + DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR, + isDaemonControlRecord, + RUSHD_GRAPH_SNAPSHOT, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonCommandOptions } from './daemonCommands'; +import { getDaemonConnectionOptions } from './daemonConnectionOptions'; +import { writeStreamAsync } from './writeStreamAsync'; +import { + parseGraphGenerationControls, + readGraphGenerationAsync, + type IGraphGenerationControls +} from './GraphGenerationControls'; +import { GraphRequestCancelledError, GraphRequestContext } from './GraphRequestContext'; + +/** The reference graph client has no renderer, native graph imports, or in-process fallback. */ +export async function executeDaemonGraphCommandAsync(options: IDaemonCommandOptions): Promise { + try { + await runGraphAsync(options); + } catch (error) { + process.exitCode = error instanceof GraphRequestCancelledError ? 130 : 1; + await writeJsonAsync({ + kind: 'graphError', + message: error instanceof Error ? error.message : String(error) + }); + } +} + +async function runGraphAsync(options: IDaemonCommandOptions): Promise { + if (options.environment.RUSH_DAEMON_EXPERIMENTAL !== '1') { + throw new Error('Graph commands require RUSH_DAEMON_EXPERIMENTAL=1.'); + } + if (!options.rushJsonPath) throw new Error('Graph commands require a repository containing rush.json.'); + const connection: IConnectOrStartDaemonOptions = getDaemonConnectionOptions( + path.dirname(options.rushJsonPath), + options.rushVersion, + options.environment, + false + ); + const controls: IGraphGenerationControls = parseGraphGenerationControls(options.argv); + if (!options.admission) throw new Error('Graph admission options must be resolved before dispatch.'); + const context: GraphRequestContext = new GraphRequestContext(options.admission); + try { + const expectedWorkspaceGeneration: string | undefined = controls.mutation + ? (controls.generation ?? (await readGraphGenerationAsync(connection, options.environment, context))) + : undefined; + // Graph inspection connects only; starting a daemon is a separate management command. + const client: DaemonClient = await context.connectAsync({ + socketPath: connection.paths.socketPath, + capabilities: { isTTY: false, colorLevel: 0 } + }); + try { + if (controls.mutation && client.protocolVersion.minor < DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR) { + throw new Error('Graph mutations require generation-aware protocol 0.9 or newer.'); + } + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: ['daemon', ...controls.argv], + commandName: 'daemon', + commandOrigin: 'built-in', + cwd: process.cwd(), + environment: options.environment, + admission: context.admission, + expectedWorkspaceGeneration, + terminal: { isTTY: false, supportsColor: false, acceptsStdin: false } + }); + await executeGraphRequestAsync(client, request, context.signal); + } finally { + await client.closeAsync(); + } + } finally { + context[Symbol.dispose](); + } +} + +async function executeGraphRequestAsync( + client: DaemonClient, + request: IDaemonRequestEnvelope, + abortSignal: AbortSignal +): Promise { + const { expectedWorkspaceGeneration } = request; + let sawSnapshot: boolean = false; + const outcome: DaemonClientOutcome = await client.executeAsync({ + request, + abortSignal, + onEventAsync: async (event) => { + const payload: unknown = event.payload; + if ( + event.type !== 'extension' || + !isDaemonControlRecord(payload) || + payload.name !== RUSHD_GRAPH_SNAPSHOT || + !isDaemonControlRecord(payload.data) || + payload.data.requestId !== request.requestId || + !isDaemonControlRecord(payload.data.snapshot) || + typeof payload.data.snapshot.initialized !== 'boolean' + ) { + throw new Error('The daemon sent an invalid graph snapshot event.'); + } + if ( + expectedWorkspaceGeneration !== undefined && + payload.data.snapshot.workspaceGeneration !== expectedWorkspaceGeneration + ) + throw new Error('The graph mutation response belongs to a different workspace generation.'); + sawSnapshot = true; + await writeJsonAsync(event); + }, + onQueuePositionAsync: (position) => + writeJsonAsync({ kind: 'queuePosition', payload: { requestId: request.requestId, position } }) + }); + if (outcome.kind === 'fallback') { + throw new Error( + `The daemon does not support graph requests: ${outcome.message ?? outcome.reason}. No native fallback was attempted.` + ); + } + if (outcome.kind === 'rejected') { + process.exitCode = 1; + await writeJsonAsync({ kind: 'requestRejected', payload: outcome.rejection }); + return; + } + if (outcome.result.outcome === 'success' && !sawSnapshot) { + throw new Error( + 'The daemon completed without a graph snapshot; graph support is required. No native fallback was attempted.' + ); + } + process.exitCode = outcome.result.exitCode; + await writeJsonAsync({ kind: 'requestResult', payload: outcome.result }); +} + +function writeJsonAsync(value: object): Promise { + return writeStreamAsync(process.stdout, Buffer.from(`${JSON.stringify(value)}\n`)); +} diff --git a/apps/rush-cli-client/src/daemonLogs.ts b/apps/rush-cli-client/src/daemonLogs.ts new file mode 100644 index 0000000000..49a403005c --- /dev/null +++ b/apps/rush-cli-client/src/daemonLogs.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { constants, type Stats } from 'node:fs'; +import { lstat, open, type FileHandle } from 'node:fs/promises'; +import type { Writable } from 'node:stream'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { getDaemonLogFilePath } from '@rushstack/rush-client-core'; +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { writeStreamAsync } from './writeStreamAsync'; +import { DaemonLogOutput } from './DaemonLogOutput'; +import { MAX_LOG_OUTPUT_BYTES } from './DaemonLogOutputProtocol'; + +const FOLLOW_POLL_INTERVAL_MS: number = 100; + +export interface IDaemonLogOptions { + readonly follow?: boolean; + readonly abortSignal?: AbortSignal; + readonly output?: Writable; +} + +/** Reads a snapshot, optionally following appends, without connecting to or starting the daemon. */ +export async function printDaemonLogAsync( + paths: IDaemonPaths, + options: IDaemonLogOptions = {} +): Promise { + if (options.abortSignal?.aborted) return; + const logFilePath: string = getDaemonLogFilePath(paths); + let file: FileHandle; + let ownedOutput: DaemonLogOutput | undefined; + try { + // These distinct native flags have non-overlapping values. + file = await open( + logFilePath, + constants.O_RDONLY + (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW + constants.O_NONBLOCK) + ); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') { + throw new Error( + `No launcher log exists at ${logFilePath}. Logs are created when this client starts a daemon.`, + { cause: error } + ); + } + throw error; + } + try { + const stats: Stats = await file.stat(); + if (!stats.isFile() || stats.nlink !== 1) { + throw new Error(`Launcher log must be a regular, unshared file: ${logFilePath}`); + } + if (!Number.isSafeInteger(stats.size) || stats.size < 0) { + throw new Error(`Launcher log size cannot be represented safely: ${logFilePath}`); + } + if (options.follow && !options.output && process.platform === 'win32' && !process.stdout.isTTY) { + ownedOutput = new DaemonLogOutput(options.abortSignal); + } + const signal: AbortSignal | undefined = ownedOutput + ? AbortSignal.any([ownedOutput.failureSignal, ...(options.abortSignal ? [options.abortSignal] : [])]) + : options.abortSignal; + const buffer: Buffer = Buffer.alloc(MAX_LOG_OUTPUT_BYTES); + let position: number = 0; + let size: number = stats.size; + while (!signal?.aborted) { + if (position === size) { + if (!options.follow) break; + await waitForAppendAsync(signal); + if (signal?.aborted) break; + const current: Stats = await file.stat(); + const named: Stats = await lstat(logFilePath); + if (current.nlink !== 1 || !named.isFile() || named.dev !== stats.dev || named.ino !== stats.ino) { + throw new Error( + `Launcher log was replaced or unlinked while following ${logFilePath}; reopen the log.` + ); + } + if (!Number.isSafeInteger(current.size) || current.size < position) { + throw new Error( + `Launcher log was truncated or has an invalid size while following ${logFilePath}.` + ); + } + size = current.size; + continue; + } + const { bytesRead } = await file.read(buffer, 0, Math.min(buffer.length, size - position), position); + if (bytesRead === 0) + throw new Error(`Launcher log was truncated while reading ${logFilePath}; retry the command.`); + if (ownedOutput) await ownedOutput.writeAsync(buffer.subarray(0, bytesRead)); + else await writeLogChunkAsync(options, buffer.subarray(0, bytesRead)); + position += bytesRead; + } + ownedOutput?.failureSignal.throwIfAborted(); + } catch (error) { + if (!options.abortSignal?.aborted || error !== options.abortSignal.reason) throw error; + } finally { + try { + await file.close(); + } finally { + await ownedOutput?.closeAsync(); + } + } +} + +async function waitForAppendAsync(signal: AbortSignal | undefined): Promise { + try { + await delayAsync(FOLLOW_POLL_INTERVAL_MS, undefined, { signal }); + } catch (error) { + if ( + !signal?.aborted || + typeof error !== 'object' || + error === null || + !('name' in error) || + error.name !== 'AbortError' || + !('cause' in error) || + error.cause !== signal.reason + ) + throw error; + } +} + +async function writeLogChunkAsync(options: IDaemonLogOptions, bytes: Uint8Array): Promise { + const output: Writable = options.output ?? process.stdout; + const signal: AbortSignal | undefined = options.abortSignal; + signal?.throwIfAborted(); + let onAbort: (() => void) | undefined; + try { + await new Promise((resolve, reject) => { + onAbort = () => { + reject(signal!.reason); + output.destroy(signal!.reason); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + void writeStreamAsync(output, bytes).then(resolve, reject); + }); + } finally { + if (onAbort) signal?.removeEventListener('abort', onAbort); + } +} diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts new file mode 100644 index 0000000000..b739b9fadf --- /dev/null +++ b/apps/rush-cli-client/src/launchClient.ts @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + Rush, + RushConfiguration, + daemonEnvironmentVariables, + resolveDaemonConfiguration, + type IDaemonConfigurationJson +} from '@microsoft/rush-lib'; +import { JsonFile } from '@rushstack/node-core-library'; +import { + DaemonClientError, + captureDaemonRequest, + connectOrStartDaemonAsync, + executeWithDaemonRestartAsync, + type DaemonClient, + type DaemonClientOutcome, + type IConnectOrStartDaemonOptions +} from '@rushstack/rush-client-core'; +import type { DaemonVerbosity, IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; +import { ConsoleTerminalProvider } from '@rushstack/terminal'; +import { MinimalRushConfiguration } from '@microsoft/rush/lib/MinimalRushConfiguration'; +import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +import { executeDaemonCommandAsync } from './daemonCommands'; +import { ClientOperationRenderer } from './ClientOperationRenderer'; +import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; +import { selectClientRoute, type IClientRoute } from './routing'; +import { writeStreamAsync } from './writeStreamAsync'; + +interface IWorkspaceJson { + readonly rushVersion: string; + readonly daemon?: IDaemonConfigurationJson; +} + +export async function launchClientAsync(rushx: boolean): Promise { + const cwd: string = process.cwd(); + const environment: Readonly = Object.freeze({ ...process.env }); + const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ startingFolder: cwd }); + const workspace: IWorkspaceJson | undefined = rushJsonPath ? JsonFile.load(rushJsonPath) : undefined; + const config: Readonly> = resolveDaemonConfiguration( + workspace?.daemon, + environment + ); + const route: IClientRoute = selectClientRoute({ + argv: process.argv.slice(2), + environment, + enabled: config.enabled, + rushx, + hasTerminal: !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY) + }); + const selectedVersion: string = environment.RUSH_PREVIEW_VERSION ?? workspace?.rushVersion ?? Rush.version; + if (!rushx && route.commandName === 'daemon') { + if ((route.argv[1] === 'start' || route.argv[1] === 'restart') && process.argv.includes('--no-daemon')) { + throw new Error(`--no-daemon cannot be combined with daemon ${route.argv[1]}.`); + } + await executeDaemonCommandAsync({ + argv: route.argv.slice(1), + environment, + rushJsonPath, + rushVersion: selectedVersion, + admission: + route.argv[1] === 'graph' + ? (route.admission ?? { waitTimeoutMs: Math.floor(config.queueTimeoutSeconds * 1000) }) + : route.admission + }); + return; + } + if (!route.daemon || !rushJsonPath || route.commandName === undefined) { + launchInProcess(route.argv, rushx, selectedVersion); + return; + } + const terminal: ConsoleTerminalProvider = new ConsoleTerminalProvider(); + const verbosity: DaemonVerbosity = + route.argv.includes('--verbose') || route.argv.includes('-v') ? 'verbose' : 'quiet'; + const request: IDaemonRequestEnvelope = captureDaemonRequest({ + argv: route.argv, + commandName: route.commandName, + // The native resolver additionally validates the parsed action; rushx scripts never claim this origin. + commandOrigin: + !rushx && ['build', 'rebuild', 'install', 'update'].includes(route.commandName) ? 'built-in' : 'custom', + invocationKind: rushx ? 'rushx' : 'rush', + cwd, + environment, + terminal: { + isTTY: !!process.stdout.isTTY, + supportsColor: terminal.supportsColor, + columns: process.stdout.columns, + acceptsStdin: true + }, + admission: route.admission ?? { waitTimeoutMs: Math.floor(config.queueTimeoutSeconds * 1000) } + }); + let connection: IConnectOrStartDaemonOptions; + let client: DaemonClient; + try { + connection = { + ...(await getDaemonConnectionOptionsAsync( + path.dirname(rushJsonPath), + selectedVersion, + request.environment, + config.autoStart + )), + capabilities: { + isTTY: request.terminal.isTTY, + columns: request.terminal.columns, + colorLevel: terminal.supportsColor ? 1 : 0, + verbosity + } + }; + client = await connectOrStartDaemonAsync(connection); + } catch (error) { + if (!(error instanceof DaemonClientError) && !(error instanceof DaemonLauncherUnavailableError)) + throw error; + process.stderr.write(`rush-client: ${error.message} Using in-process Rush.\n`); + launchInProcess(route.argv, rushx, selectedVersion); + return; + } + const abort: AbortController = new AbortController(); + const onSignal = (): void => abort.abort(); + process.on('SIGINT', onSignal); + process.on('SIGTERM', onSignal); + const renderer: ClientOperationRenderer = new ClientOperationRenderer({ + requestId: request.requestId, + colorLevel: terminal.supportsColor ? 1 : 0, + verbosity, + terminal: { + get columns() { + return process.stdout.columns ?? 80; + }, + get isTTY() { + return !!process.stdout.isTTY; + } + }, + writeAsync: (bytes, stream) => + writeStreamAsync(stream === 'stderr' ? process.stderr : process.stdout, bytes) + }); + let outcome: DaemonClientOutcome; + const discoveryLines: string[] = []; + const writeDiscoveryAsync = async (): Promise => { + if (discoveryLines.length > 0) { + await writeStreamAsync(process.stdout, Buffer.from(discoveryLines.splice(0).join('\n') + '\n')); + } + }; + try { + if (rushx) MinimalRushConfiguration.loadFromDefaultLocation((line) => discoveryLines.push(line)); + await renderer.initializeAsync(); + outcome = await executeWithDaemonRestartAsync(client, connection, { + request, + abortSignal: abort.signal, + onStdoutAsync: async (bytes, operationId) => { + await writeDiscoveryAsync(); + await renderer.writeLogAsync(bytes, operationId, 'stdout'); + }, + onStderrAsync: async (bytes, operationId) => { + await writeDiscoveryAsync(); + await renderer.writeLogAsync(bytes, operationId, 'stderr'); + }, + onEventAsync: (event) => renderer.writeEventAsync(event), + onQueuePositionAsync: process.stderr.isTTY + ? (position) => + writeStreamAsync( + process.stderr, + Buffer.from(`rush-client: waiting for daemon admission (position ${position}).\n`) + ) + : undefined, + stdin: process.stdin, + requiresStdinEnd: !process.stdin.isTTY, + cancelOnCtrlC: !!process.stdin.isTTY, + initialRawMode: !!process.stdin.isRaw, + setRawMode: process.stdin.isTTY + ? (enabled) => { + process.stdin.setRawMode(enabled); + } + : undefined + }); + } finally { + process.removeListener('SIGINT', onSignal); + process.removeListener('SIGTERM', onSignal); + try { + await renderer.closeAsync(); + } finally { + await client.closeAsync(); + } + } + if (outcome.kind === 'result') { + process.exitCode = outcome.result.exitCode; + if (outcome.result.admissionErrorCode) { + await writeStreamAsync( + process.stderr, + Buffer.from(`rush-client: daemon admission failed (${outcome.result.admissionErrorCode}).\n`) + ); + } + } else if (outcome.kind === 'rejected') { + throw new Error(`Daemon rejected the request (${outcome.rejection.code}): ${outcome.rejection.message}`); + } else if (abort.signal.aborted) { + process.exitCode = 130; + } else { + process.stderr.write(`rush-client: ${outcome.message ?? outcome.reason}; using in-process Rush.\n`); + launchInProcess(route.argv, rushx, selectedVersion); + } +} + +function launchInProcess(argv: ReadonlyArray, rushx: boolean, selectedVersion: string): void { + const executable: string = rushx ? 'rushx' : 'rush'; + const rushFolder: string = path.dirname(require.resolve('@microsoft/rush/package.json')); + process.argv = [process.execPath, path.join(rushFolder, 'bin', executable), ...argv]; + if (selectedVersion !== Rush.version) { + // Old Rush releases reject new RUSH_* names. Only strip this launcher's own inputs; + // the request snapshot was captured earlier and is never mutated. + for (const name of [...Object.values(daemonEnvironmentVariables), 'RUSH_DAEMON_EXPERIMENTAL']) { + delete process.env[name]; + } + } + require('@microsoft/rush/lib/start'); +} diff --git a/apps/rush-cli-client/src/routing.ts b/apps/rush-cli-client/src/routing.ts new file mode 100644 index 0000000000..310960249b --- /dev/null +++ b/apps/rush-cli-client/src/routing.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; +import { RushXCommand, type IRushXCommandLineArguments } from '@microsoft/rush-lib'; + +import { parseClientAdmissionControls, type IClientAdmissionControls } from './ClientAdmissionControls'; + +const neverDaemonize: ReadonlySet = new Set([ + 'add', + 'change', + 'check', + 'deploy', + 'init', + 'init-autoinstaller', + 'init-deploy', + 'link', + 'publish', + 'purge', + 'remove', + 'scan', + 'setup', + 'unlink', + 'update-autoinstaller', + 'version', + 'help', + 'daemon' +]); + +export interface IClientRouteOptions { + readonly argv: ReadonlyArray; + readonly environment: Readonly>; + readonly enabled: boolean; + readonly rushx: boolean; + readonly hasTerminal?: boolean; +} + +export interface IClientRoute { + readonly argv: ReadonlyArray; + readonly daemon: boolean; + readonly commandName: string | undefined; + readonly admission: IDaemonRequestAdmissionOptions | undefined; +} + +/** Routing never parses action parameters or relabels a custom command as a built-in. */ +export function selectClientRoute(options: IClientRouteOptions): IClientRoute { + const controls: IClientAdmissionControls = parseClientAdmissionControls(options.argv); + const separator: number = controls.argv.indexOf('--'); + const prefix: ReadonlyArray = separator < 0 ? controls.argv : controls.argv.slice(0, separator); + const noDaemon: boolean = prefix.includes('--no-daemon'); + const argv: ReadonlyArray = [ + ...prefix.filter((arg) => arg !== '--no-daemon'), + ...(separator < 0 ? [] : controls.argv.slice(separator)) + ]; + const rushxArguments: IRushXCommandLineArguments | undefined = options.rushx + ? RushXCommand.parseArguments(argv, options.environment) + : undefined; + const commandName: string | undefined = rushxArguments ? rushxArguments.commandName || undefined : argv[0]; + const reporterControls: boolean = + options.environment.RUSH_LOG_LEVEL !== undefined || + (options.environment.RUSH_REPORTER !== undefined && options.environment.RUSH_REPORTER !== 'legacy') || + (!options.rushx && + prefix.some((arg) => + ['--reporter', '--output', '--log-level'].some((name) => arg === name || arg.startsWith(`${name}=`)) + )); + const ci: boolean = ['CI', 'TF_BUILD', 'GITHUB_ACTIONS', 'JENKINS_URL', 'TEAMCITY_VERSION'].some((key) => { + const value: string | undefined = options.environment[key]; + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; + }); + const daemon: boolean = + options.enabled && + !reporterControls && + !noDaemon && + !(options.rushx && options.hasTerminal) && + !!commandName && + !commandName.startsWith('-') && + (options.rushx || !neverDaemonize.has(commandName)) && + !(rushxArguments ? rushxArguments.help : prefix.includes('--help') || prefix.includes('-h')) && + (!ci || options.environment.RUSH_DAEMON === '1'); + return { argv, commandName, daemon, admission: controls.admission }; +} diff --git a/apps/rush-cli-client/src/start.ts b/apps/rush-cli-client/src/start.ts new file mode 100644 index 0000000000..d940f3f82a --- /dev/null +++ b/apps/rush-cli-client/src/start.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { launchClientAsync } from './launchClient'; + +launchClientAsync(false).catch((error: Error) => { + process.stderr.write(`rush-client: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/apps/rush-cli-client/src/startRushX.ts b/apps/rush-cli-client/src/startRushX.ts new file mode 100644 index 0000000000..9c0d3be3ff --- /dev/null +++ b/apps/rush-cli-client/src/startRushX.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { launchClientAsync } from './launchClient'; + +launchClientAsync(true).catch((error: Error) => { + process.stderr.write(`rushx-client: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/apps/rush-cli-client/src/test/CliSignalTestProcess.ts b/apps/rush-cli-client/src/test/CliSignalTestProcess.ts new file mode 100644 index 0000000000..230759efca --- /dev/null +++ b/apps/rush-cli-client/src/test/CliSignalTestProcess.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +// Windows kill('SIGINT') terminates a process without running its signal handlers. +process.once('message', (message: unknown) => { + if (message !== 'SIGINT' || !process.emit('SIGINT')) { + throw new Error('Expected SIGINT with an installed CLI signal handler.'); + } + process.disconnect!(); +}); +process.argv[1] = path.resolve(__dirname, '../../bin/rush-client'); +require(process.argv[1]); diff --git a/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts new file mode 100644 index 0000000000..0919a754a9 --- /dev/null +++ b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseClientAdmissionControls } from '../ClientAdmissionControls'; +import { selectClientRoute } from '../routing'; + +describe(parseClientAdmissionControls.name, () => { + it.each([ + { argv: ['build'], remaining: ['build'], admission: undefined }, + { argv: ['build', '--no-wait'], remaining: ['build'], admission: { noWait: true } }, + { argv: ['--wait-timeout', '1.25', 'build'], remaining: ['build'], admission: { waitTimeoutMs: 1250 } }, + { argv: ['build', '--wait-timeout=0'], remaining: ['build'], admission: { waitTimeoutMs: 0 } }, + { + argv: ['build', '--wait-timeout=2147483.647'], remaining: ['build'], + admission: { waitTimeoutMs: 2147483647 } + }, + { + argv: ['script', '--', '--no-wait', '--wait-timeout', '2'], + remaining: ['script', '--', '--no-wait', '--wait-timeout', '2'], admission: undefined + } + ])('parses $argv', ({ argv, remaining, admission }) => { + expect(parseClientAdmissionControls(argv)).toEqual({ argv: remaining, admission }); + }); + + it.each([ + ['build', '--wait-timeout'], + ['build', '--wait-timeout='], + ['build', '--wait-timeout=-1'], + ['build', '--wait-timeout=Infinity'], + ['build', '--wait-timeout=1e3'], + ['build', '--wait-timeout=2147483.648'], + ['build', '--wait-timeout=2147483.6471'], + ['build', '--wait-timeout=1', '--wait-timeout=2'], + ['build', '--no-wait', '--wait-timeout=0'], + ['build', '--no-wait=false'] + ])('rejects invalid admission arguments %j', (...argv) => { + expect(() => parseClientAdmissionControls(argv)).toThrow(); + }); + + it('removes daemon-only controls before native fallback while retaining script arguments', () => { + expect(selectClientRoute({ + argv: ['build', '--no-daemon', '--no-wait', '--', '--wait-timeout=2'], + enabled: true, environment: {}, rushx: false + })).toEqual({ + argv: ['build', '--', '--wait-timeout=2'], + commandName: 'build', daemon: false, admission: { noWait: true } + }); + }); +}); diff --git a/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts b/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts new file mode 100644 index 0000000000..f9d7a77bc4 --- /dev/null +++ b/apps/rush-cli-client/src/test/ClientOperationRenderer.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { EOL } from 'node:os'; + +import { + DAEMON_PROTOCOL_VERSION, + RUSHD_OPERATION_STREAM_CLOSED, + type DaemonEventType, + type IDaemonEventEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { ClientOperationRenderer } from '../ClientOperationRenderer'; + +function event(type: DaemonEventType, payload: unknown): IDaemonEventEnvelope { + return { + eventId: 'event', sessionId: 'session', sequence: 1, timestamp: new Date().toISOString(), + protocolVersion: DAEMON_PROTOCOL_VERSION, + source: { packageName: 'test', packageVersion: '1.0.0' }, + privacy: 'public', required: true, type, payload + }; +} + +describe(ClientOperationRenderer.name, () => { + it('renders operation chrome and global stderr activity without adding a second newline', async () => { + const output: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' }; + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async (bytes, stream) => { output[stream] += Buffer.from(bytes).toString(); } + }); + await renderer.initializeAsync(); + await renderer.writeEventAsync(event('operationRegistered', { operationId: 'project', silent: false })); + await renderer.writeLogAsync(Buffer.from('built\n'), 'project', 'stdout'); + await renderer.writeEventAsync(event('extension', { + name: RUSHD_OPERATION_STREAM_CLOSED, data: { operationId: 'project' } + })); + await renderer.writeEventAsync(event('activityChanged', { text: 'warning\n', stream: 'stderr' })); + await renderer.closeAsync(); + expect(output.stdout).toContain('==[ project ]'); + expect(output.stdout).toContain(`built${EOL}`); + expect(output.stderr).toBe(`warning${EOL}`); + }); + + it('preserves global command bytes without collation or text decoding', async () => { + const output: Buffer[] = []; + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async (bytes) => { output.push(Buffer.from(bytes)); } + }); + const bytes: Buffer = Buffer.from([0, 255, 3, 13]); + await renderer.initializeAsync(); + await renderer.writeLogAsync(bytes, 'request', 'stdout'); + await renderer.closeAsync(); + expect(Buffer.concat(output)).toEqual(bytes); + }); + + it('propagates a backpressured output failure instead of reporting success', async () => { + const failure: Error = new Error('output failed'); + const renderer = new ClientOperationRenderer({ + requestId: 'request', colorLevel: 0, verbosity: 'normal', + terminal: { columns: 80, isTTY: false }, + writeAsync: async () => { throw failure; } + }); + await renderer.initializeAsync(); + await expect(renderer.writeEventAsync(event('activityChanged', { + text: 'activity', stream: 'stdout' + }))).rejects.toBe(failure); + await renderer.closeAsync(); + }); +}); diff --git a/apps/rush-cli-client/src/test/DaemonLogOutput.test.ts b/apps/rush-cli-client/src/test/DaemonLogOutput.test.ts new file mode 100644 index 0000000000..69ee88f0d9 --- /dev/null +++ b/apps/rush-cli-client/src/test/DaemonLogOutput.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { + captureTestProcessIdentity, + isTestProcessRunning, + waitForTestProcessExitAsync +} from '@rushstack/rush-daemon/lib/test/TestProcessExit'; + +import { DaemonLogOutput } from '../DaemonLogOutput'; +import { MAX_LOG_OUTPUT_BYTES } from '../DaemonLogOutputProtocol'; + +describe(DaemonLogOutput.name, () => { + let folder: string; + let filename: string; + let fd: number; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-log-output-')); + filename = path.join(folder, 'bytes.log'); + fd = fs.openSync(filename, 'w'); + }); + + afterEach(() => { + fs.closeSync(fd); + fs.rmSync(folder, { recursive: true }); + }); + + it('acknowledges bounded binary writes and joins its output-only process', async () => { + const output = new DaemonLogOutput(undefined, fd); + const bytes: Buffer = Buffer.alloc(MAX_LOG_OUTPUT_BYTES); + for (let index: number = 0; index < bytes.length; index++) bytes[index] = index % 256; + let pid: number | undefined; + try { + await output.writeAsync(bytes); + pid = output.workerPid; + await output.writeAsync(Buffer.from([0, 255, 3])); + } finally { + await output.closeAsync(); + } + expect(fs.readFileSync(filename)).toEqual(Buffer.concat([bytes, Buffer.from([0, 255, 3])])); + expect(pid).toEqual(expect.any(Number)); + expect(() => process.kill(pid!, 0)).toThrow(); + }); + + it('rejects oversized writes before starting a process', async () => { + const output = new DaemonLogOutput(undefined, fd); + try { + await expect(output.writeAsync(Buffer.alloc(MAX_LOG_OUTPUT_BYTES + 1))).rejects.toThrow('too large'); + expect(output.workerPid).toBeUndefined(); + } finally { + await output.closeAsync(); + } + }); + + it('rejects an already cancelled write without consuming output resources', async () => { + const abort = new AbortController(); + const reason: Error = new Error('Cancelled output.'); + abort.abort(reason); + const output = new DaemonLogOutput(abort.signal, fd); + try { + await expect(output.writeAsync(Buffer.from('unwritten'))).rejects.toBe(reason); + expect(output.workerPid).toBeUndefined(); + } finally { + await output.closeAsync(); + } + expect(fs.readFileSync(filename)).toHaveLength(0); + }); + + it('propagates a real descriptor write failure and joins the failed helper', async () => { + const readFd: number = fs.openSync(filename, 'r'); + const output = new DaemonLogOutput(undefined, readFd); + try { + await expect(output.writeAsync(Buffer.from('cannot write'))).rejects.toThrow('output failed'); + await expect(output.closeAsync()).rejects.toThrow('output failed'); + expect(output.failureSignal.aborted).toBe(true); + expect(() => process.kill(output.workerPid!, 0)).toThrow(); + } finally { + await output.closeAsync().catch((error: unknown) => { + if (error !== output.failureSignal.reason) throw error; + }); + fs.closeSync(readFd); + } + }); + + it('does not orphan a blocked output worker when its owning process disappears', async () => { + const owner = spawn(process.execPath, [path.join(__dirname, 'DaemonLogOutputTestProcess.js')], { + cwd: folder, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }); + const closed = once(owner, 'close'); + const worker = once(owner, 'message'); + const readable = once(owner.stdout!, 'readable'); + owner.stderr!.resume(); + let workerPid: number | undefined; + let forcedCleanup: boolean = false; + let deadline: ReturnType | undefined; + try { + const [message]: unknown[] = await Promise.race([ + worker, + closed.then(() => { + throw new Error('Output owner exited before worker startup.'); + }) + ]); + if ( + typeof message !== 'object' || + message === null || + !('workerPid' in message) || + typeof message.workerPid !== 'number' + ) + throw new Error('Output owner did not identify its worker.'); + workerPid = message.workerPid; + expect(workerPid).toEqual(expect.any(Number)); + await Promise.race([ + readable, + closed.then(() => { + throw new Error('Output owner exited before filling the pipe.'); + }) + ]); + await delayAsync(150); + const workerIdentity = captureTestProcessIdentity(workerPid); + expect(isTestProcessRunning(workerIdentity)).toBe(true); + const exitDeadline: number = Date.now() + 3000; + deadline = setTimeout(() => { + forcedCleanup = true; + try { + if (isTestProcessRunning(workerIdentity)) process.kill(workerPid!, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + } + owner.stdout!.destroy(); + }, 3000); + owner.kill('SIGKILL'); + await closed; + const remaining: number = exitDeadline - Date.now(); + expect(remaining).toBeGreaterThanOrEqual(0); + await waitForTestProcessExitAsync(workerIdentity, remaining); + expect(Date.now()).toBeLessThanOrEqual(exitDeadline); + expect(forcedCleanup).toBe(false); + } finally { + clearTimeout(deadline); + if (owner.exitCode === null && owner.signalCode === null) owner.kill('SIGKILL'); + owner.stdout!.destroy(); + await closed; + } + }, 10000); +}); diff --git a/apps/rush-cli-client/src/test/DaemonLogOutputTestProcess.ts b/apps/rush-cli-client/src/test/DaemonLogOutputTestProcess.ts new file mode 100644 index 0000000000..df2fee154d --- /dev/null +++ b/apps/rush-cli-client/src/test/DaemonLogOutputTestProcess.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonLogOutput } from '../DaemonLogOutput'; +import { MAX_LOG_OUTPUT_BYTES } from '../DaemonLogOutputProtocol'; + +if (process.platform === 'win32' && process.stdout.isTTY) { + throw new Error('The output owner fixture requires redirected stdout.'); +} + +const output = new DaemonLogOutput(); +void (async () => { + try { + for (let index: number = 0; index < 160; index++) { + const writing: Promise = output.writeAsync(Buffer.alloc(MAX_LOG_OUTPUT_BYTES)); + if (index === 0) process.send!({ workerPid: output.workerPid }); + await writing; + } + } finally { + await output.closeAsync(); + } +})().catch((error: unknown) => { + process.stderr.write(String(error)); + process.exitCode = 1; +}); diff --git a/apps/rush-cli-client/src/test/DaemonLogSignalTestProcess.ts b/apps/rush-cli-client/src/test/DaemonLogSignalTestProcess.ts new file mode 100644 index 0000000000..ec8f9d2fc6 --- /dev/null +++ b/apps/rush-cli-client/src/test/DaemonLogSignalTestProcess.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +process.once('message', (message: unknown) => { + if (message !== 'SIGINT' || !process.emit('SIGINT')) { + throw new Error('Expected SIGINT with an installed log-follow signal handler.'); + } + fs.writeFileSync(path.join(process.cwd(), 'log-follow-signal.json'), '{"handlerDelivered":true}'); + process.disconnect!(); +}); +process.argv[1] = path.resolve(__dirname, '../../bin/rush-client'); +require(process.argv[1]); diff --git a/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts b/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts new file mode 100644 index 0000000000..edad2c1a7d --- /dev/null +++ b/apps/rush-cli-client/src/test/GraphGenerationControls.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { parseGraphGenerationControls } from '../GraphGenerationControls'; + +describe(parseGraphGenerationControls.name, () => { + it.each(['scope-in', 'scope-out', 'invalidate', 'pause', 'resume'])( + 'preserves explicit generation and selectors for %s', + (verb) => { + expect(parseGraphGenerationControls(['graph', verb, '--generation', 'old-token', '--project', 'a'])) + .toEqual({ argv: ['graph', verb, '--project', 'a'], mutation: true, generation: 'old-token' }); + expect(parseGraphGenerationControls(['graph', verb, '--generation=old-token'])) + .toEqual({ argv: ['graph', verb], mutation: true, generation: 'old-token' }); + expect(parseGraphGenerationControls(['graph', verb])) + .toEqual({ argv: ['graph', verb], mutation: true, generation: undefined }); + } + ); + + it.each([ + ['graph', 'show', '--generation', 'token'], + ['graph', 'status', '--generation=token'], + ['graph', 'watch', '--generation=token'], + ['graph', 'pause', '--generation'], + ['graph', 'pause', '--generation='], + ['graph', 'pause', '--generation', ' token'], + ['graph', 'pause', '--generation', '--project'], + ['graph', 'pause', '--generation=one', '--generation=two'] + ])('rejects malformed generation controls %j', (...argv) => { + expect(() => parseGraphGenerationControls(argv)).toThrow('--generation'); + }); +}); diff --git a/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts b/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts new file mode 100644 index 0000000000..f4ec40eb3a --- /dev/null +++ b/apps/rush-cli-client/src/test/GraphGenerationWire.test.ts @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { + RequestExclusivityClass, + RequestScheduler, + RequestSchedulerError, + RequestSchedulerErrorCode, + type IRequestLease +} from '@rushstack/rush-daemon'; +import { + createDaemonHelloAck, + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + decodeDaemonControlMessage, + encodeDaemonControlMessage, + encodeDaemonEventFrame, + RUSHD_GRAPH_SNAPSHOT, + type DaemonControlMessage, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +describe('generation-aware graph reference client', () => { + it.each([ + 'current', + 'explicit', + 'stale-explicit', + 'stale-preflight', + 'mismatched-response', + 'missing-token', + 'old-auto', + 'old-explicit', + 'downgraded-peer' + ])( + 'fences %s without native fallback or mutation replay', + async (mode) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-generation-')); + const peers: Set = new Set(); + const requests: IDaemonRequestEnvelope[] = []; + let listener: DaemonFrameListener | undefined; + let connections: number = 0; + try { + fs.writeFileSync( + path.join(folder, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + projects: [] + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, process.env, false); + listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection) => { + peers.add(connection); + connection.onClosed(() => { + peers.delete(connection); + }); + const old: boolean = + mode.startsWith('old-') || (mode === 'downgraded-peer' && ++connections === 2); + const protocolVersion = old ? { major: 0, minor: 8 } : DAEMON_PROTOCOL_VERSION; + const send = (message: DaemonControlMessage): Promise => + connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await send(createDaemonHelloAck(protocolVersion, 'generation-peer')); + } else if (message.kind === 'ping') { + await send({ kind: 'pong', payload: { uptimeMs: 1 } }); + } else if (message.kind === 'requestStart') { + const request: IDaemonRequestEnvelope = message.payload; + requests.push(request); + const mutation: boolean = request.argv[2] === 'pause'; + if (mutation && mode.startsWith('stale-')) { + await send({ + kind: 'requestRejected', + payload: { + requestId: request.requestId, + code: 'invalidRequest', + message: 'Stale graph generation.' + } + }); + return; + } + await connection.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion, + eventId: request.requestId, + sessionId: 'generation-peer', + sequence: 1, + timestamp: new Date().toISOString(), + type: 'extension', + privacy: 'local-sensitive', + required: true, + source: { packageName: '@rushstack/rush-daemon', packageVersion: '0.0.0' }, + payload: { + name: RUSHD_GRAPH_SNAPSHOT, + data: { + requestId: request.requestId, + snapshot: { + initialized: true, + workspaceGeneration: + mode === 'missing-token' + ? undefined + : mutation && mode === 'mismatched-response' + ? 'replacement-token' + : 'current-token' + } + } + } + }) + }); + await send({ + kind: 'requestResult', + payload: { requestId: request.requestId, exitCode: 0, outcome: 'success', aborted: false } + }); + } + }); + } + }); + const explicit: boolean = ['explicit', 'stale-explicit', 'old-explicit'].includes(mode); + const token: string = mode === 'stale-explicit' ? 'old-token' : 'current-token'; + const child = spawn( + process.execPath, + [ + path.resolve(__dirname, '../../bin/rush-client'), + 'daemon', + 'graph', + 'pause', + ...(explicit ? ['--generation', token] : []) + ], + { + cwd: folder, + env: { ...process.env, RUSH_DAEMON_EXPERIMENTAL: '1' }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { + stdout += bytes.toString(); + }); + child.stderr.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + expect((await once(child, 'close'))[0]).toBe(['current', 'explicit'].includes(mode) ? 0 : 1); + expect(stderr).toBe(''); + expect(stdout).not.toMatch(/using in-process|Usage: rush /); + const records = stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const mutations = requests.filter((request) => request.argv[2] === 'pause'); + if (mode.startsWith('old-') || mode === 'missing-token' || mode === 'downgraded-peer') { + expect(mutations).toHaveLength(0); + expect(records).toEqual([{ kind: 'graphError', message: expect.any(String) }]); + } else { + expect(mutations).toHaveLength(1); + expect(mutations[0]).toMatchObject({ + argv: ['daemon', 'graph', 'pause'], + expectedWorkspaceGeneration: token + }); + expect(requests).toHaveLength(explicit ? 1 : 2); + if (mode.startsWith('stale-')) { + expect(records).toEqual([ + { kind: 'requestRejected', payload: expect.objectContaining({ code: 'invalidRequest' }) } + ]); + } else if (mode === 'mismatched-response') { + expect(records).toEqual([ + { kind: 'graphError', message: expect.stringContaining('different workspace generation') } + ]); + } else { + expect(records).toHaveLength(2); + expect(records[0].payload.data.requestId).toBe(mutations[0].requestId); + expect(records[1]).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + } + } + } finally { + await Promise.all(Array.from(peers, (peer) => peer.closeAsync())); + await listener?.closeAsync(); + fs.rmSync(folder, { recursive: true, force: true }); + } + }, + 15000 + ); + + it.each([ + 'no-wait', + 'timeout', + 'config-timeout', + 'environment-timeout', + 'default', + 'remaining-budget', + 'expired-preflight', + 'explicit', + 'cancel-preflight' + ])( + 'preserves graph admission and cancellation across %s', + async (mode) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-graph-admission-')); + const peers: Set = new Set(); + const requests: IDaemonRequestEnvelope[] = []; + const cancellations: Map = new Map(); + const pending: Promise[] = []; + const scheduler: RequestScheduler = new RequestScheduler(); + const blocked: boolean = [ + 'no-wait', + 'timeout', + 'config-timeout', + 'environment-timeout', + 'cancel-preflight' + ].includes(mode); + const blocker = blocked + ? await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive }) + : undefined; + let listener: DaemonFrameListener | undefined; + let child: ChildProcess | undefined; + let closed: Promise | undefined; + let deadline: ReturnType | undefined; + try { + fs.writeFileSync( + path.join(folder, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + projects: [], + daemon: + mode === 'config-timeout' || mode === 'environment-timeout' + ? { queueTimeoutSeconds: 0.5 } + : undefined + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, process.env, false); + listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection) => { + peers.add(connection); + const owned: AbortController[] = []; + connection.onClosed(() => { + peers.delete(connection); + for (const abort of owned) abort.abort(); + }); + const send = (message: DaemonControlMessage): Promise => + connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await send(createDaemonHelloAck(DAEMON_PROTOCOL_VERSION, 'admission-peer')); + } else if (message.kind === 'ping') { + await send({ kind: 'pong', payload: { uptimeMs: 1 } }); + } else if (message.kind === 'requestCancel') { + cancellations.get(message.payload.requestId)!.abort(); + } else if (message.kind === 'requestStart') { + const request: IDaemonRequestEnvelope = message.payload; + requests.push(request); + const abort: AbortController = new AbortController(); + owned.push(abort); + cancellations.set(request.requestId, abort); + const respondAsync = async (): Promise => { + let lease: IRequestLease; + try { + lease = await scheduler.acquireAsync({ + ...request.admission, + exclusivityClass: RequestExclusivityClass.SharedRead, + abortSignal: abort.signal + }); + } catch (error) { + if (!(error instanceof RequestSchedulerError)) throw error; + const aborted: boolean = error.code === RequestSchedulerErrorCode.Aborted; + await send({ + kind: 'requestResult', + payload: { + requestId: request.requestId, + aborted, + exitCode: aborted ? 130 : 1, + outcome: aborted ? 'aborted' : 'failure', + admissionErrorCode: aborted + ? 'aborted' + : error.code === RequestSchedulerErrorCode.NoWait + ? 'no-wait' + : 'wait-timeout' + } + }); + return; + } + try { + if ( + request.argv[2] === 'status' && + (mode === 'remaining-budget' || mode === 'expired-preflight') + ) { + await delayAsync(200); + } + await connection.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion: DAEMON_PROTOCOL_VERSION, + eventId: request.requestId, + sessionId: 'admission-peer', + sequence: 1, + timestamp: new Date().toISOString(), + type: 'extension', + privacy: 'local-sensitive', + required: true, + source: { packageName: '@rushstack/rush-daemon', packageVersion: '0.0.0' }, + payload: { + name: RUSHD_GRAPH_SNAPSHOT, + data: { + requestId: request.requestId, + snapshot: { + initialized: false, + workspaceGeneration: 'generation', + invalidations: { + sequence: 0, + changedPathCount: 0, + hasUnknownChanges: false, + isWatcherHealthy: true + } + } + } + } + }) + }); + await send({ + kind: 'requestResult', + payload: { + requestId: request.requestId, + exitCode: 0, + outcome: 'success', + aborted: false + } + }); + } finally { + lease.release(); + } + }; + pending.push(respondAsync().catch((error: Error) => connection.abort(error))); + if (mode === 'cancel-preflight') { + if (process.platform === 'win32') child!.send!('SIGINT'); + else child!.kill('SIGINT'); + } + } + }); + } + }); + const extra: string[] = + mode === 'no-wait' + ? ['--no-wait'] + : mode === 'expired-preflight' + ? ['--wait-timeout', '0.1'] + : ['timeout', 'remaining-budget', 'explicit'].includes(mode) + ? ['--wait-timeout', '0.5'] + : []; + if (mode === 'explicit') extra.push('--generation', 'generation'); + const windowsSignal: boolean = mode === 'cancel-preflight' && process.platform === 'win32'; + child = spawn( + process.execPath, + [ + windowsSignal + ? path.join(__dirname, 'CliSignalTestProcess.js') + : path.resolve(__dirname, '../../bin/rush-client'), + 'daemon', + 'graph', + 'pause', + ...extra + ], + { + cwd: folder, + env: { + ...process.env, + RUSH_DAEMON_EXPERIMENTAL: '1', + RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS: mode === 'environment-timeout' ? '0.25' : undefined + }, + stdio: windowsSignal ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'] + } + ); + closed = once(child, 'close'); + deadline = setTimeout(() => child!.kill('SIGKILL'), 10000); + let stdout: string = ''; + let stderr: string = ''; + child.stdout!.on('data', (bytes: Buffer) => { + stdout += bytes.toString(); + }); + child.stderr!.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + const [code] = await closed; + expect(stderr).toBe(''); + expect(requests[0].argv[2]).toBe(mode === 'explicit' ? 'pause' : 'status'); + if (mode === 'cancel-preflight') { + expect(code).toBe(130); + expect(cancellations.get(requests[0].requestId)!.signal.aborted).toBe(true); + } else if (blocked || mode === 'expired-preflight') { + expect(code).toBe(1); + expect(stdout).toContain(mode === 'expired-preflight' ? 'deadline expired' : 'admission failed'); + } else { + expect(code).toBe(0); + } + if (blocked || mode === 'expired-preflight' || mode === 'explicit') expect(requests).toHaveLength(1); + if (mode === 'no-wait') { + expect(requests[0].admission).toEqual({ noWait: true }); + } else { + const maximum: number = + mode === 'environment-timeout' + ? 250 + : mode === 'default' || mode === 'cancel-preflight' + ? 30000 + : mode === 'expired-preflight' + ? 100 + : 500; + expect(requests[0].admission!.waitTimeoutMs).toBeGreaterThan(0); + expect(requests[0].admission!.waitTimeoutMs).toBeLessThanOrEqual(maximum); + } + if (mode === 'remaining-budget') { + expect(requests).toHaveLength(2); + expect(requests[1].admission!.waitTimeoutMs).toBeLessThanOrEqual( + requests[0].admission!.waitTimeoutMs! - 150 + ); + } + } finally { + clearTimeout(deadline); + if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + for (const abort of cancellations.values()) abort.abort(); + blocker?.release(); + await Promise.all(pending); + await Promise.all(Array.from(peers, (peer) => peer.closeAsync())); + await listener?.closeAsync(); + fs.rmSync(folder, { recursive: true, force: true }); + } + }, + 15000 + ); +}); diff --git a/apps/rush-cli-client/src/test/GraphRequestContext.test.ts b/apps/rush-cli-client/src/test/GraphRequestContext.test.ts new file mode 100644 index 0000000000..ae677d5cbc --- /dev/null +++ b/apps/rush-cli-client/src/test/GraphRequestContext.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { GraphRequestContext } from '../GraphRequestContext'; + +describe(GraphRequestContext.name, () => { + it('preserves one deadline rather than resetting it for a second request', () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1000); + const context = new GraphRequestContext({ waitTimeoutMs: 100 }); + try { + now.mockReturnValue(1060); + expect(context.admission).toEqual({ waitTimeoutMs: 40 }); + now.mockReturnValue(1100); + expect(() => context.admission).toThrow('deadline expired'); + } finally { + context[Symbol.dispose](); + now.mockRestore(); + } + }); + + it.each([{ noWait: true }, { waitTimeoutMs: 0 }])('preserves immediate admission %j', (admission) => { + const context = new GraphRequestContext(admission); + try { + expect(context.admission).toEqual(admission); + } finally { + context[Symbol.dispose](); + } + }); + + it('releases both signal handlers on disposal', () => { + const before = ['SIGINT', 'SIGTERM'].map((signal) => process.listenerCount(signal)); + const context = new GraphRequestContext({ waitTimeoutMs: 100 }); + context[Symbol.dispose](); + expect(['SIGINT', 'SIGTERM'].map((signal) => process.listenerCount(signal))).toEqual(before); + }); +}); diff --git a/apps/rush-cli-client/src/test/IpcFixtureFile.test.ts b/apps/rush-cli-client/src/test/IpcFixtureFile.test.ts new file mode 100644 index 0000000000..da3764235a --- /dev/null +++ b/apps/rush-cli-client/src/test/IpcFixtureFile.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readIpcFixtureEvents, readPressureGateAsync, writeIpcFixtureFile } from './IpcFixtureFile'; + +const filesystem: typeof fs = jest.requireActual('node:fs'); + +describe('IPC fixture file publication', () => { + let directory: string; + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-publication-')); + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it.each([ + ['allocate.json', '{"additionalMemoryBytes":123}'], + ['allocate.json.adjust', '{"additionalMemoryBytes":456}'], + ['allocate.json.release', '{}'], + ['cancel.json', '{"cancelled":true}'], + ['cancel.json.adjust', '{"cancelled":true}'], + ['cancel.json.release', '{"cancelled":true}'], + ['run-input.json', '{"value":"next-request"}'] + ])('hides incomplete %s from a separate reader until its closed payload is published', (name, contents) => { + const filename: string = path.join(directory, name); + const write: typeof fs.writeFileSync = fs.writeFileSync; + const rename: typeof fs.renameSync = fs.renameSync; + let descriptor: number | undefined; + let reader: SpawnSyncReturns | undefined; + jest.spyOn(filesystem, 'writeFileSync').mockImplementationOnce((target, data, options) => { + if (typeof target === 'number') { + descriptor = target; + fs.writeSync(target, '{'); + } else { + write(target, '{', options); + } + reader = spawnSync(process.execPath, [ + '-e', + "const fs=require('node:fs');const file=process.argv[1];" + + "console.log(fs.existsSync(file)?JSON.stringify(JSON.parse(fs.readFileSync(file,'utf8'))):'pending');", + filename + ], { encoding: 'utf8' }); + if (typeof target === 'number') { + fs.ftruncateSync(target, 0); + fs.writeSync(target, String(data), 0, 'utf8'); + } else { + write(target, data, options); + } + }); + jest.spyOn(filesystem, 'renameSync').mockImplementationOnce((from, to) => { + if (descriptor !== undefined) { + expect(() => fs.fstatSync(descriptor!)).toThrow(expect.objectContaining({ code: 'EBADF' })); + } + rename(from, to); + }); + + writeIpcFixtureFile(filename, contents); + + expect(reader?.error).toBeUndefined(); + expect(reader?.status).toBe(0); + expect(reader?.stdout.trim()).toBe('pending'); + expect(JSON.parse(fs.readFileSync(filename, 'utf8'))).toEqual(JSON.parse(contents)); + expect(fs.readdirSync(directory)).toEqual([name]); + }); + + it('keeps a previous complete input visible until its replacement is complete', () => { + const filename: string = path.join(directory, 'input.json'); + writeIpcFixtureFile(filename, '{"value":"old"}'); + const write: typeof fs.writeFileSync = fs.writeFileSync; + let observed: unknown; + jest.spyOn(filesystem, 'writeFileSync').mockImplementationOnce((target, contents, options) => { + observed = JSON.parse(fs.readFileSync(filename, 'utf8')); + write(target, contents, options); + }); + writeIpcFixtureFile(filename, '{"value":"new"}'); + expect(observed).toEqual({ value: 'old' }); + expect(JSON.parse(fs.readFileSync(filename, 'utf8'))).toEqual({ value: 'new' }); + }); + + it.each(['write', 'rename'])('cleans only its own staging file after a %s failure', (failurePoint) => { + const sentinel: string = path.join(directory, '.other-writer.tmp'); + fs.writeFileSync(sentinel, 'owned by another publisher'); + const filename: string = path.join(directory, 'gate.json'); + const failure: Error = new Error('publication failed'); + if (failurePoint === 'write') { + jest.spyOn(filesystem, 'writeFileSync').mockImplementationOnce(() => { throw failure; }); + } else { + jest.spyOn(filesystem, 'renameSync').mockImplementationOnce(() => { throw failure; }); + } + expect(() => writeIpcFixtureFile(filename, '{}')).toThrow(failure); + expect(fs.readdirSync(directory)).toEqual(['.other-writer.tmp']); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('owned by another publisher'); + }); + + it.each(['pressure-ready', 'pressure-allocated', 'pressure-adjusted'])( + 'does not publish a partial %s frame or substitute a previous request sample', + (kind) => { + const filename: string = path.join(directory, 'events.jsonl'); + const previous = { kind, pressureGate: 'previous-request', residentMemoryBytes: 1 }; + const current = { kind, pressureGate: 'current-request', residentMemoryBytes: 2 }; + const contents: string = JSON.stringify(current); + fs.writeFileSync(filename, `${JSON.stringify(previous)}\n${contents.slice(0, 10)}`); + expect(readIpcFixtureEvents(filename)).toEqual([previous]); + fs.appendFileSync(filename, `${contents.slice(10)}\n`); + expect(readIpcFixtureEvents(filename)).toEqual([previous, current]); + } + ); + + it('surfaces malformed complete event frames instead of ignoring or retrying them', () => { + const filename: string = path.join(directory, 'events.jsonl'); + fs.writeFileSync(filename, '{"kind":}\n'); + expect(() => readIpcFixtureEvents(filename)).toThrow(SyntaxError); + }); + + it('surfaces malformed published gate JSON and cancellation immediately', async () => { + const filename: string = path.join(directory, 'gate.json'); + writeIpcFixtureFile(filename, '{'); + await expect(readPressureGateAsync(filename, performance.now() + 10000)).rejects.toThrow(SyntaxError); + writeIpcFixtureFile(filename, '{"cancelled":true}'); + await expect(readPressureGateAsync(filename, performance.now() + 10000)).rejects.toThrow( + 'Pressure fixture setup was cancelled.' + ); + }); + + it('does not let an old completed phase release a new request', async () => { + writeIpcFixtureFile(path.join(directory, 'old-request.release'), '{}'); + const filename: string = path.join(directory, 'new-request.release'); + let settled: boolean = false; + const reading: Promise = readPressureGateAsync(filename, performance.now() + 10000) + .finally(() => { settled = true; }); + const rejected: Promise = expect(reading).rejects.toThrow('Pressure fixture setup was cancelled.'); + try { + expect(settled).toBe(false); + } finally { + writeIpcFixtureFile(filename, '{"cancelled":true}'); + await rejected; + } + }); +}); diff --git a/apps/rush-cli-client/src/test/IpcFixtureFile.ts b/apps/rush-cli-client/src/test/IpcFixtureFile.ts new file mode 100644 index 0000000000..be3afa177c --- /dev/null +++ b/apps/rush-cli-client/src/test/IpcFixtureFile.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +export function writeIpcFixtureFile(filename: string, contents: string): void { + const temporary: string = path.join( + path.dirname(filename), `.${path.basename(filename)}-${process.pid}-${randomUUID()}.tmp` + ); + const descriptor: number = fs.openSync(temporary, 'wx', 0o600); + try { + try { + fs.writeFileSync(descriptor, contents, 'utf8'); + } finally { + fs.closeSync(descriptor); + } + fs.renameSync(temporary, filename); + } finally { + fs.rmSync(temporary, { force: true }); + } +} + +export function readIpcFixtureEvents(filename: string): T[] { + const contents: string = fs.readFileSync(filename, 'utf8'); + // A concurrent append is not published until its terminating newline is visible. + return contents.slice(0, contents.lastIndexOf('\n') + 1) + .split('\n').filter(Boolean).map((line) => JSON.parse(line)); +} + +export async function readPressureGateAsync( + filename: string, + deadline: number +): Promise<{ additionalMemoryBytes?: number; cancelled?: boolean }> { + while (!fs.existsSync(filename)) { + if (performance.now() >= deadline) throw new Error('Pressure fixture gate was not released.'); + await delayAsync(10); + } + const gate: { additionalMemoryBytes?: number; cancelled?: boolean } = + JSON.parse(fs.readFileSync(filename, 'utf8')); + if (gate.cancelled) throw new Error('Pressure fixture setup was cancelled.'); + return gate; +} diff --git a/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts b/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts new file mode 100644 index 0000000000..79670f2790 --- /dev/null +++ b/apps/rush-cli-client/src/test/NativeBuildTestFixture.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createNativeBuildTestFixture } from './NativeBuildTestFixture'; + +describe('native build fixture lifetime', () => { + it('joins the whole old callback without rebinding it to a later fixture', async () => { + const old = createNativeBuildTestFixture(); + const next = createNativeBuildTestFixture(); + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + let continued: boolean = false; + let disposed: boolean = false; + const work = old.runAsync(async ({ folder, invokeAsync }) => { + await released; + expect(folder).toBe(old.folder); + expect(() => invokeAsync(['build'])).toThrow('already closing'); + fs.writeFileSync(path.join(folder, 'late-callback.txt'), 'old fixture only'); + continued = true; + }); + const closing = old.closeAsync().then(() => { + disposed = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(disposed).toBe(false); + expect(fs.existsSync(old.folder)).toBe(true); + release(); + await work; + await closing; + expect(continued).toBe(true); + expect(fs.existsSync(old.folder)).toBe(false); + expect(fs.existsSync(path.join(next.folder, 'late-callback.txt'))).toBe(false); + expect(fs.readFileSync(path.join(next.folder, 'a/input.txt'), 'utf8')).toBe('one'); + } finally { + release(); + try { + await closing; + } finally { + await next.closeAsync(); + } + } + }, 15000); + + it.each([false, true])( + 'terminates an owned watch before removal (registered during cleanup: %s)', + async (late) => { + const fixture = createNativeBuildTestFixture(); + const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + cwd: fixture.folder, + stdio: 'ignore' + }); + const closed = once(child, 'close'); + if (!late) fixture.trackWatch(child, closed); + let continued: boolean = false; + const work = fixture.runAsync(async () => { + if (late) expect(() => fixture.trackWatch(child, closed)).toThrow('already closing'); + await closed; + continued = true; + }); + await fixture.closeAsync(); + await work; + expect(continued).toBe(true); + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + expect(fs.existsSync(fixture.folder)).toBe(false); + }, + 15000 + ); + + it('leaves callback failures observable while cleanup joins the rejected work', async () => { + const fixture = createNativeBuildTestFixture(); + const failure = new Error('native fixture callback failure'); + const work = fixture.runAsync(async () => { + throw failure; + }); + try { + await expect(work).rejects.toBe(failure); + } finally { + await fixture.closeAsync(); + } + expect(fs.existsSync(fixture.folder)).toBe(false); + }, 15000); +}); diff --git a/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts new file mode 100644 index 0000000000..d21fc58673 --- /dev/null +++ b/apps/rush-cli-client/src/test/NativeBuildTestFixture.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { removeTestFolderAsync } from '@rushstack/rush-daemon/lib/test/TestProcessExit'; +import { RUSHD_GRAPH_SNAPSHOT, type IDaemonGraphSnapshotPayload } from '@rushstack/rush-daemon-protocol'; +import { + computeDaemonWorkspaceKey, + resolveDaemonPaths, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +export interface INativeBuildResult { + readonly code: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +export interface INativeBuildTestFixture { + readonly folder: string; + readonly environment: NodeJS.ProcessEnv; + readonly paths: IDaemonPaths; + invokeAsync(argv: ReadonlyArray, rushx?: boolean): Promise; + snapshotAsync(...args: string[]): Promise; + runAsync(work: (fixture: INativeBuildTestFixture) => Promise): Promise; + trackWatch(child: ChildProcess, closed: Promise): void; + closeAsync(): Promise; +} + +export function createNativeBuildTestFixture(): INativeBuildTestFixture { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-native-')); + const environment: NodeJS.ProcessEnv = { + ...process.env, + RUSH_DAEMON: '1', + RUSH_REPORTER: 'legacy', + CI: 'false', + TF_BUILD: 'false', + GITHUB_ACTIONS: 'false', + XDG_RUNTIME_DIR: folder + }; + const invocationClosures: Promise[] = []; + const callbacks: Promise[] = []; + const watchers: Set = new Set(); + let acceptingInvocations: boolean = true; + const write = (name: string, text: string): void => { + const filename: string = path.join(folder, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, text); + }; + write( + 'rush.json', + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + daemon: { enabled: true, autoStart: true, idleTimeoutSeconds: 30 }, + projectFolderMinDepth: 1, + projects: ['a', 'b'].map((name) => ({ packageName: name, projectFolder: name })) + }) + ); + write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\nruns.txt\nrushd*/\n'); + write('common/temp/last-link.flag', '{}'); + write('common/config/rush/npm-shrinkwrap.json', '{"lockfileVersion":3,"packages":{}}'); + write( + 'common/config/rush/command-line.json', + JSON.stringify({ + phases: [{ name: '_phase:compile', dependencies: { upstream: ['_phase:compile'] } }], + commands: [ + { + commandKind: 'phased', + name: 'build', + phases: ['_phase:compile'], + incremental: true, + enableParallelism: true + } + ] + }) + ); + for (const name of ['a', 'b']) { + write( + `${name}/package.json`, + JSON.stringify({ + name, + version: '1.0.0', + scripts: { '_phase:compile': 'node build.cjs', build: 'node -e "console.log(\'rushx-only\')"' }, + dependencies: name === 'b' ? { a: '1.0.0' } : {} + }) + ); + write(`${name}/input.txt`, 'one'); + write( + `${name}/build.cjs`, + "const fs=require('node:fs');const name=require('./package.json').name;" + + "const value=fs.readFileSync('input.txt','utf8');" + + "fs.appendFileSync('../runs.txt',name+':'+value+'\\n');console.log('built-'+name+'-'+value);" + ); + } + execFileSync('git', ['init', '--quiet'], { cwd: folder }); + execFileSync('git', ['config', '--local', 'core.autocrlf', 'false'], { cwd: folder }); + execFileSync('git', ['add', '.'], { cwd: folder }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Client Test', + '-c', + 'user.email=client@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ], + { cwd: folder } + ); + const paths: IDaemonPaths = resolveDaemonPaths( + { platform: process.platform, env: environment, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + computeDaemonWorkspaceKey({ + canonicalRepoRoot: fs.realpathSync.native(folder), + rushVersion: Rush.version + }) + ); + + function invokeAsync(argv: ReadonlyArray, rushx: boolean = false): Promise { + if (!acceptingInvocations) throw new Error('The native build fixture is already closing.'); + return spawnClientAsync(argv, rushx); + } + + async function spawnClientAsync( + argv: ReadonlyArray, + rushx: boolean = false + ): Promise { + const entry: string = path.resolve(__dirname, rushx ? '../../bin/rushx-client' : '../../bin/rush-client'); + const child = spawn(process.execPath, [entry, ...argv], { + cwd: rushx ? path.join(folder, 'b') : folder, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (text: string) => { + stdout += text; + }); + child.stderr.on('data', (text: string) => { + stderr += text; + }); + const closed: Promise = once(child, 'close'); + invocationClosures.push(closed); + const [code] = await closed; + return { code: typeof code === 'number' ? code : undefined, stdout, stderr }; + } + + async function snapshotAsync(...args: string[]): Promise { + const result = await invokeAsync(['daemon', 'graph', ...args]); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).not.toContain('\u001b'); + const records = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ type: 'extension', payload: { name: RUSHD_GRAPH_SNAPSHOT } }); + expect(records[1]).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + return records[0].payload.data.snapshot; + } + + const fixture: INativeBuildTestFixture = { + folder, + environment, + paths, + invokeAsync, + snapshotAsync, + runAsync: (work) => { + if (!acceptingInvocations) throw new Error('The native build fixture is already closing.'); + const running: Promise = Promise.resolve().then(() => work(fixture)); + callbacks.push(running); + return running; + }, + trackWatch: (child, closed) => { + watchers.add(child); + child.once('close', () => watchers.delete(child)); + invocationClosures.push(closed); + if (!acceptingInvocations) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + throw new Error('The native build fixture is already closing.'); + } + }, + closeAsync: async () => { + acceptingInvocations = false; + for (const child of watchers) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + } + // Jest owns callback failures. Drain complete continuations, not only their current child process. + await Promise.allSettled(callbacks); + await Promise.allSettled(invocationClosures); + if (fs.existsSync(paths.lockfilePath)) { + const stopped: INativeBuildResult = await spawnClientAsync(['daemon', 'stop']); + expect(stopped.code).toBe(0); + } + const deadline: number = Date.now() + 5000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(20); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + await removeTestFolderAsync(folder); + } + }; + return fixture; +} diff --git a/apps/rush-cli-client/src/test/PersistentIpcPressure.test.ts b/apps/rush-cli-client/src/test/PersistentIpcPressure.test.ts new file mode 100644 index 0000000000..4ab9472133 --- /dev/null +++ b/apps/rush-cli-client/src/test/PersistentIpcPressure.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { getPressureAllocationBytes, type IPressureMemorySample } from './PersistentIpcPressure'; + +describe('measured IPC pressure allocation', () => { + const original: IPressureMemorySample = { + budgetBytes: 512 * 1024 * 1024, + daemonBytes: 118980608, + pressureRunnerBytes: 74039296, + otherRunnerBytes: 124960768 + }; + + it('sizes the initial allocation from the actual daemon and child samples', () => { + expect(getPressureAllocationBytes(original)).toBe(281370624); + }); + + it('reestablishes the pressure interval after the captured real daemon working-set drop', () => { + const afterTrim: IPressureMemorySample = { + ...original, + daemonBytes: 15708160, + pressureRunnerBytes: 356220928 + }; + const beforeAdjustment: number = afterTrim.daemonBytes + afterTrim.pressureRunnerBytes; + expect(beforeAdjustment + afterTrim.otherRunnerBytes).toBeLessThan(afterTrim.budgetBytes); + + const adjustment: number = getPressureAllocationBytes(afterTrim); + expect(adjustment).toBe(102461440); + const retainedBytes: number = beforeAdjustment + adjustment; + expect(retainedBytes + afterTrim.otherRunnerBytes).toBeGreaterThan(afterTrim.budgetBytes); + expect(retainedBytes).toBeLessThan(afterTrim.budgetBytes); + }); + + it('does not add memory when the measured allocation already reaches the target', () => { + expect(getPressureAllocationBytes({ + ...original, + pressureRunnerBytes: 356220928 + })).toBe(0); + }); + + it('rejects missing measurements and a pressure runner that already exceeds the budget', () => { + expect(() => getPressureAllocationBytes({ ...original, otherRunnerBytes: 0 })).toThrow( + 'Invalid measured pressure sample' + ); + expect(() => getPressureAllocationBytes({ ...original, daemonBytes: Number.NaN })).toThrow( + 'Invalid measured pressure sample' + ); + expect(() => getPressureAllocationBytes({ + ...original, + pressureRunnerBytes: original.budgetBytes + })).toThrow('pressure runner alone cannot fit'); + }); +}); diff --git a/apps/rush-cli-client/src/test/PersistentIpcPressure.ts b/apps/rush-cli-client/src/test/PersistentIpcPressure.ts new file mode 100644 index 0000000000..797ae381a8 --- /dev/null +++ b/apps/rush-cli-client/src/test/PersistentIpcPressure.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +export interface IPressureMemorySample { + readonly budgetBytes: number; + readonly daemonBytes: number; + readonly pressureRunnerBytes: number; + readonly otherRunnerBytes: number; +} + +export function getPressureAllocationBytes(sample: IPressureMemorySample): number { + const { budgetBytes, daemonBytes, pressureRunnerBytes, otherRunnerBytes } = sample; + if ( + !Object.values(sample).every((value) => Number.isSafeInteger(value) && value >= 0) || + budgetBytes === 0 || pressureRunnerBytes === 0 || otherRunnerBytes === 0 + ) { + throw new Error(`Invalid measured pressure sample: ${JSON.stringify(sample)}`); + } + const retainedBytes: number = daemonBytes + pressureRunnerBytes; + if (retainedBytes >= budgetBytes) { + throw new Error(`The pressure runner alone cannot fit the budget: ${JSON.stringify(sample)}`); + } + // Target the middle of the interval in which both children exceed the budget but one fits. + return Math.max(0, Math.floor(budgetBytes - retainedBytes - otherRunnerBytes / 2)); +} diff --git a/apps/rush-cli-client/src/test/PersistentIpcTestFixture.ts b/apps/rush-cli-client/src/test/PersistentIpcTestFixture.ts new file mode 100644 index 0000000000..902f3c076c --- /dev/null +++ b/apps/rush-cli-client/src/test/PersistentIpcTestFixture.ts @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import type { IDaemonPongMessage } from '@rushstack/rush-daemon-protocol'; + +import { createNativeBuildTestFixture, type INativeBuildTestFixture, type INativeBuildResult } from './NativeBuildTestFixture'; +import { readIpcFixtureEvents, writeIpcFixtureFile } from './IpcFixtureFile'; + +export interface IIpcEvent { + readonly kind: + | 'ready' | 'started' | 'pressure-ready' | 'pressure-allocated' | 'pressure-adjusted' | 'complete' | 'closed'; + readonly project: string; + readonly pid: number; + readonly iteration?: number; + readonly residentMemoryBytes?: number; + readonly pressureGate?: string; + readonly durationMs?: number; + readonly implementation?: string; + readonly args?: string[]; +} + +export interface IPersistentIpcTestFixture extends INativeBuildTestFixture { + write(relative: string, text: string): void; + input(project: string, values: Record): void; + events(): IIpcEvent[]; + statusAsync(): Promise; + buildAsync(...selection: string[]): Promise; +} + +export function createPersistentIpcTestFixture(options: { + telemetry?: boolean; + enabled?: boolean; + budgetMB?: number; + configurationKind?: 'direct' | 'inherited' | 'rig'; +} = {}): IPersistentIpcTestFixture { + const base: INativeBuildTestFixture = createNativeBuildTestFixture(); + const write = (relative: string, text: string): void => { + const filename = path.join(base.folder, relative); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + writeIpcFixtureFile(filename, text); + }; + for (const name of ['RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS', 'RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY', + 'RUSH_DAEMON_WARM_MEMORY_BUDGET_MB', 'RUSH_DAEMON_WARM_SET_MAX_PROJECTS']) delete base.environment[name]; + const config = JSON.parse(fs.readFileSync(path.join(base.folder, 'rush.json'), 'utf8')); + config.telemetryEnabled = true; + config.daemon = { + ...config.daemon, + usePersistentIpcRunners: options.enabled ?? true, + watch: true, + autoWarmByTelemetry: options.telemetry ?? true, + warmMemoryBudgetMB: options.budgetMB ?? 100_000, + warmSetMaxProjects: 20, + warmIdleTimeoutSeconds: 300 + }; + write('rush.json', JSON.stringify(config)); + write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\n**/node_modules/\nruns.txt\nrushd*/\n'); + write('common/config/rush/command-line.json', JSON.stringify({ + phases: [{ name: '_phase:compile' }, { name: '_phase:empty', missingScriptBehavior: 'silent' }], + commands: [ + { commandKind: 'phased', name: 'build', phases: ['_phase:compile', '_phase:empty'], + incremental: true, enableParallelism: true }, + { commandKind: 'phased', name: 'rebuild', phases: ['_phase:compile', '_phase:empty'], + incremental: false, enableParallelism: true } + ], + parameters: [ + { parameterKind: 'string', longName: '--label', argumentName: 'LABEL', description: 'Raw label', + associatedCommands: ['build', 'rebuild'], associatedPhases: ['_phase:compile'] }, + { parameterKind: 'string', longName: '--ignored', argumentName: 'VALUE', description: 'Excluded value', + associatedCommands: ['build', 'rebuild'], associatedPhases: ['_phase:compile'] } + ] + })); + for (const name of ['a', 'b']) { + const settings = { + operationSettings: [{ + operationName: '_phase:compile', + outputFolderNames: ['lib'], + parameterNamesToIgnore: ['--ignored'], + daemonIpc: { entryPoint: 'tools/ipc/entry.cjs', args: ['literal space', '"quoted"', '%NOT_EXPANDED%'] } + }] + }; + if (options.configurationKind === 'inherited') { + write(`${name}/config/rush-project.json`, '{"extends":"../../common/temp/shared-ipc.json"}'); + write('common/temp/shared-ipc.json', JSON.stringify(settings)); + } else if (options.configurationKind === 'rig') { + write(`${name}/config/rig.json`, '{"rigPackageName":"ipc-fixture-rig"}'); + write(`${name}/node_modules/ipc-fixture-rig/package.json`, '{"name":"ipc-fixture-rig","version":"1.0.0"}'); + write(`${name}/node_modules/ipc-fixture-rig/profiles/default/config/rush-project.json`, JSON.stringify(settings)); + } else { + write(`${name}/config/rush-project.json`, JSON.stringify(settings)); + } + write(`${name}/tools/ipc/implementation.cjs`, "exports.version = 'original';\n"); + write(`${name}/tools/ipc/entry.cjs`, createIpcToolSource()); + write(`${name}/run-input.json`, JSON.stringify({ value: 'one', memoryBytes: 16 * 1024 * 1024 })); + } + execFileSync('git', ['add', '.'], { cwd: base.folder }); + execFileSync('git', ['-c', 'user.name=IPC Test', '-c', 'user.email=ipc@example.invalid', 'commit', '--quiet', '-m', 'explicit IPC fixture'], + { cwd: base.folder }); + return { + ...base, + write, + input: (project, values) => write(`${project}/run-input.json`, JSON.stringify(values)), + events: () => { + const filename = path.join(base.folder, 'common/temp/ipc-events.jsonl'); + return fs.existsSync(filename) + ? readIpcFixtureEvents(filename) + : []; + }, + statusAsync: async () => { + const result = await base.invokeAsync(['daemon', 'status']); + if (result.code !== 0) throw new Error(`IPC daemon status failed: ${result.stderr}\n${result.stdout}`); + return JSON.parse(result.stdout); + }, + buildAsync: async (...selection) => { + const result = await base.invokeAsync(['build', ...selection, '--parallelism', '2', '--verbose']); + if (result.code !== 0 || /using in-process/i.test(result.stderr)) { + throw new Error(`Native IPC build failed (${result.code}): ${result.stderr}\n${result.stdout}`); + } + return result; + } + }; +} + +function createIpcToolSource(): string { + return ` +const fs = require('node:fs'); +const path = require('node:path'); +const { readPressureGateAsync } = require(${JSON.stringify(require.resolve('./IpcFixtureFile'))}); +const implementation = require('./implementation.cjs'); +const project = require(path.join(process.cwd(), 'package.json')).name; +const events = path.resolve('../common/temp/ipc-events.jsonl'); +const record = (value) => fs.appendFileSync(events, JSON.stringify({ project, pid: process.pid, ...value }) + '\\n'); +let iteration = 0; +let memory; +let additionalMemory; +let pressureAdjustment; +let pending = Promise.resolve(); +let closing = false; +record({ kind: 'ready', args: process.argv.slice(2), implementation: implementation.version }); +process.on('message', (message) => { + if (message.command === 'run' && !closing) { + pending = pending.then(async () => { + const startedAt = performance.now(); + const input = JSON.parse(fs.readFileSync('run-input.json', 'utf8')); + record({ kind: 'started', iteration: iteration + 1 }); + const memoryBytes = input.memoryBytes || 16 * 1024 * 1024; + if (!input.retainMemory || !memory || memory.length !== memoryBytes) memory = Buffer.alloc(memoryBytes, 1); + if (iteration === 0) await new Promise(resolve => setTimeout(resolve, input.coldDelayMs ?? (project === 'a' ? 800 : 50))); + if (input.delayMs) await new Promise(resolve => setTimeout(resolve, input.delayMs)); + iteration++; + fs.mkdirSync('lib', { recursive: true }); + fs.writeFileSync('lib/result.txt', input.value + ':' + implementation.version); + const output = 'ipc-' + project + '-' + input.value + '-' + implementation.version + '\\n'; + await new Promise(resolve => process.stdout.write(output, resolve)); + if (input.outputBytes) await new Promise(resolve => process.stdout.write( + 'PAYLOAD_BEGIN:' + String.fromCodePoint(0x1f642).repeat(input.outputBytes) + ':PAYLOAD_END\\n', resolve)); + if (input.warning) await new Promise(resolve => process.stderr.write('ipc-warning\\n', resolve)); + if (input.pressureGate) { + memory.fill(1); + record({ kind: 'pressure-ready', pressureGate: input.pressureGate, iteration, residentMemoryBytes: process.memoryUsage().rss }); + const deadline = performance.now() + 10000; + const gate = await readPressureGateAsync(input.pressureGate, deadline); + additionalMemory = Buffer.alloc(gate.additionalMemoryBytes, 1); + memory.fill(1); + additionalMemory.fill(1); + record({ kind: 'pressure-allocated', pressureGate: input.pressureGate, iteration, residentMemoryBytes: process.memoryUsage().rss }); + const adjustment = await readPressureGateAsync(input.pressureGate + '.adjust', deadline); + if (adjustment.additionalMemoryBytes > 0) pressureAdjustment = Buffer.alloc(adjustment.additionalMemoryBytes, 1); + memory.fill(1); + additionalMemory.fill(1); + if (pressureAdjustment) pressureAdjustment.fill(1); + record({ kind: 'pressure-adjusted', pressureGate: input.pressureGate, iteration, residentMemoryBytes: process.memoryUsage().rss }); + await readPressureGateAsync(input.pressureGate + '.release', deadline); + } + const residentMemoryBytes = process.memoryUsage().rss; + record({ kind: 'complete', iteration, residentMemoryBytes, durationMs: performance.now() - startedAt, implementation: implementation.version }); + process.send({ event: 'after-execute', status: input.failure ? 'FAILURE' : 'SUCCESS', residentMemoryBytes }); + }).catch(error => { console.error(error); process.exitCode = 1; process.disconnect(); }); + } else if (message.command === 'exit') { + closing = true; + pending.then(() => { record({ kind: 'closed' }); process.disconnect(); }); + } +}); +process.send({ event: 'sync', status: 'READY' }); +`; +} diff --git a/apps/rush-cli-client/src/test/PtyTestProcess.ts b/apps/rush-cli-client/src/test/PtyTestProcess.ts new file mode 100644 index 0000000000..dab8ce1cd1 --- /dev/null +++ b/apps/rush-cli-client/src/test/PtyTestProcess.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; + +export const canRunPtyTests: boolean = + process.platform === 'linux' && + spawnSync('python3', ['-c', 'import pty'], { stdio: 'ignore' }).status === 0; + +interface IPtyResult { + readonly exitCode: number; + readonly output: string; + readonly restored: boolean; +} + +const PYTHON_DRIVER: string = ` +import errno,fcntl,json,os,pty,select,signal,struct,subprocess,sys,termios,time +master,slave=pty.openpty() +fcntl.ioctl(slave,termios.TIOCSWINSZ,struct.pack('HHHH',24,123,0,0)) +def attach_terminal(): + os.setsid() + fcntl.ioctl(1,termios.TIOCSCTTY,0) +child=subprocess.Popen(sys.argv[2:]+['-q','tty'], + stdin=slave if sys.argv[1]=='tty' else subprocess.DEVNULL,stdout=slave,stderr=slave, + close_fds=True,preexec_fn=attach_terminal) +output=b'' +signaled=False +try: + deadline=time.monotonic()+15 + while time.monotonic() { + const child = spawn( + 'python3', + ['-c', PYTHON_DRIVER, stdinIsTTY ? 'tty' : 'pipe', process.execPath, entry], + { + cwd, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'] + } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { + stdout += bytes.toString(); + }); + child.stderr.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + const [code] = await once(child, 'close'); + if (code !== 0) throw new Error(`PTY driver failed (${code}): ${stderr}`); + return JSON.parse(stdout); +} diff --git a/apps/rush-cli-client/src/test/RushXDaemon.test.ts b/apps/rush-cli-client/src/test/RushXDaemon.test.ts new file mode 100644 index 0000000000..7df778e211 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemon.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { RushXDaemonTestFixture, type IRequestResult, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx through a real daemon', () => { + let fixture: RushXDaemonTestFixture; + let cwd: string; + let serverCwd: string; + let serverEnvironment: NodeJS.ProcessEnv; + + beforeEach(async () => { + fixture = new RushXDaemonTestFixture(); + await fixture.startAsync(); + cwd = path.join(fixture.folder, 'projects/a'); + serverCwd = process.cwd(); + serverEnvironment = { ...process.env }; + }); + + afterEach(async () => { + expect(process.cwd()).toBe(serverCwd); + expect(process.env).toEqual(serverEnvironment); + expect(fixture.session.operationGraph).toBeUndefined(); + await fixture[Symbol.asyncDispose](); + }); + + it('executes rushx build through the actual CLI and never enters the phased resolver', async () => { + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + const native: IScriptResult = await fixture.invokeAsync(true, ['build'], cwd); + const daemon: IScriptResult = await fixture.invokeAsync(false, ['build'], cwd); + expect(daemon).toEqual(native); + expect(daemon.exitCode).toBe(0); + expect(phased).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(cwd, 'runs.txt'), 'utf8')).toBe('ran\nran\n'); + }); + + it('isolates overlapping requests from different nested cwds, homes and environments', async () => { + fixture.write('second-home/.rush-user/.env', 'USER_VALUE=second-user\n'); + const requests = ['a', 'b'].map((name) => { + const project: string = path.join(fixture.folder, 'projects', name); + const home: string = path.join(fixture.folder, name === 'a' ? 'home' : 'second-home'); + const environment = fixture.environment({ CLIENT_MARKER: name, HOME: home, USERPROFILE: home }); + return { project, nested: path.join(project, 'subfolder'), environment }; + }); + const native = await Promise.all(requests.map(({ nested, environment }) => + fixture.invokeAsync(true, ['--quiet', 'build'], nested, environment) + )); + const daemon = await Promise.all(requests.map(({ nested, environment }) => + fixture.runAsync(fixture.request(['--quiet', 'build'], nested, environment)) + )); + daemon.forEach((result, index) => { + expect({ stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }).toEqual(native[index]); + expect(JSON.parse(result.stdout.toString())).toMatchObject({ + cwd: requests[index].project, invoked: requests[index].nested, + init: path.join(fixture.folder, 'common/temp'), + bin: path.join(requests[index].project, 'node_modules/.bin'), + marker: index === 0 ? 'a' : 'b', repo: 'from-repo', + user: index === 0 ? 'from-user' : 'second-user', + order: 'from-repo', client: 'from-client', recursive: '1' + }); + expect(JSON.parse(result.stdout.toString())).not.toHaveProperty('npmConfig'); + expect(JSON.parse(result.stdout.toString())).not.toHaveProperty('absent'); + }); + }); + + it('uses native parsing and escaping for arbitrary arguments, flags after the command and --', async () => { + const args: string[] = [ + '-q', '--debug', 'args', '--help', '--quiet', '--reporter=json', + 'two words', 'single\' quote', '"double quote"', '; echo bad', 'a & b', 'a\\ b', + '--', '--no-daemon', '--no-wait', '--wait-timeout', 'bogus' + ]; + const native: IScriptResult = await fixture.invokeAsync(true, args, cwd); + expect(await fixture.invokeAsync(false, args, cwd)).toEqual(native); + expect(JSON.parse(native.stdout.toString())).toEqual(args.slice(3)); + }); + + it.each(['', 'single\'quote', '"double"', '$(printf native)', 'a\\b'])( + 'preserves native escaping semantics even for shell-sensitive argument %s', async (arg) => { + const argv: string[] = ['-q', 'args', arg]; + expect(await fixture.invokeAsync(false, argv, cwd)).toEqual(await fixture.invokeAsync(true, argv, cwd)); + } + ); + + it.each([0, 257, 3 * 1024 * 1024])('preserves raw binary pipe bytes and EOF (%s bytes)', async (size) => { + const input: Buffer = Buffer.alloc(size); + for (let index: number = 0; index < size; index++) input[index] = index % 256; + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'pipe'], cwd, undefined, input); + expect(await fixture.invokeAsync(false, ['-q', 'pipe'], cwd, undefined, input)).toEqual(native); + expect(native.stdout).toEqual(input); + expect(native.stderr).toEqual(Buffer.from([255, 0, 3, 10])); + expect(native.exitCode).toBe(0); + }); + + it('preserves the exact nonzero exit code, command display and native error suffix', async () => { + const args: string[] = ['fail', 'two words', '--flag']; + const native: IScriptResult = await fixture.invokeAsync(true, args, cwd); + expect(await fixture.invokeAsync(false, args, cwd)).toEqual(native); + expect(native.exitCode).toBe(7); + expect(native.stderr.toString()).toContain('raw-err\x1b[31mError: Failed calling'); + }); + + it('retains unregistered-project warnings and native missing-script errors without fallback', async () => { + for (const [project, script] of [['unregistered', 'args'], ['a', 'missing']]) { + const folder: string = path.join(fixture.folder, 'projects', project); + const native: IScriptResult = await fixture.invokeAsync(true, [script], folder); + expect(await fixture.invokeAsync(false, [script], folder)).toEqual(native); + } + }); + + it('does not replace a script failure with pipe errors when the script exits without reading', async () => { + const input: Buffer = Buffer.alloc(3 * 1024 * 1024); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'early'], cwd, undefined, input); + expect(await fixture.invokeAsync(false, ['-q', 'early'], cwd, undefined, input)).toEqual(native); + expect(native.exitCode).toBe(23); + }); + + it('rejects symlink cwd escapes and forged Rushx origins before consuming input', async () => { + const escaped: string = path.join(fixture.folder, 'outside'); + fs.symlinkSync(path.dirname(fixture.folder), escaped, process.platform === 'win32' ? 'junction' : 'dir'); + for (const request of [ + fixture.request(['build'], escaped), + { ...fixture.request(['build'], cwd), commandOrigin: 'built-in' as const }, + { ...fixture.request(['daemon'], cwd), commandOrigin: 'built-in' as const } + ]) { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result: IRequestResult = await fixture.runAsync(request, undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'rejected', rejection: { code: 'invalidRequest' } }); + expect(input.read().toString()).toBe('untouched'); + } + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + }); + + it('leaves a controlling-terminal request in-process without emulating a PTY or reading stdin', async () => { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const request = fixture.request(['pipe'], cwd); + const result: IRequestResult = await fixture.runAsync({ + ...request, terminal: { ...request.terminal, terminalRequirement: 'controllingTerminal' } + }, undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'controllingTerminalRequired' }); + expect(input.read().toString()).toBe('untouched'); + }); +}); diff --git a/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts b/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts new file mode 100644 index 0000000000..9fbe153ac5 --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonAlias.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { RushXDaemonTestFixture, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx invocation namespaces', () => { + let fixture: RushXDaemonTestFixture; + let aliasContainer: string; + let aliasRoot: string; + let physicalRoot: string; + let serverCwd: string; + let serverEnvironment: NodeJS.ProcessEnv; + + beforeEach(async () => { + fixture = new RushXDaemonTestFixture(false, true); + physicalRoot = fs.realpathSync.native(fixture.folder); + aliasContainer = fs.mkdtempSync(path.join(os.tmpdir(), 'rushx-invocation-alias-')); + aliasRoot = path.join(aliasContainer, 'workspace'); + fs.symlinkSync(physicalRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + fixture.write('projects/a/script.cjs', ` +const path = require('node:path'); +console.log(JSON.stringify({ + cwd: process.cwd(), invoked: process.env.RUSH_INVOKED_FOLDER, + init: process.env.INIT_CWD, bin: process.env.PATH.split(path.delimiter)[0], + base: process.env.RUSHSTACK_FILE_ERROR_BASE_FOLDER, marker: process.env.CLIENT_MARKER +})); +`); + await fixture.startAsync(); + serverCwd = process.cwd(); + serverEnvironment = { ...process.env }; + }); + + afterEach(async () => { + try { + expect(process.cwd()).toBe(serverCwd); + expect(process.env).toEqual(serverEnvironment); + expect(fixture.session.metadata.repoRoot).toBe(physicalRoot); + expect(fixture.session.rushConfiguration.rushJsonFolder).toBe(physicalRoot); + expect(fixture.session.operationGraph).toBeUndefined(); + } finally { + await fixture[Symbol.asyncDispose](); + fs.rmSync(aliasContainer, { recursive: true, force: true }); + } + }); + + it('preserves native cwd, invocation, PATH and configuration namespaces across aliases on one host', async () => { + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + for (const root of [aliasRoot, physicalRoot]) { + const cwd: string = path.join(root, 'projects/a/subfolder'); + const environment = fixture.environment({ + RUSHSTACK_FILE_ERROR_BASE_FOLDER: undefined, + CLIENT_MARKER: root + }); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'build'], cwd, environment); + expect(await fixture.invokeAsync(false, ['-q', 'build'], cwd, environment)).toEqual(native); + expect(native.exitCode).toBe(0); + const namespaceRoot: string = process.platform === 'win32' ? root : physicalRoot; + expect(JSON.parse(native.stdout.toString())).toEqual({ + cwd: path.join(namespaceRoot, 'projects/a'), + invoked: path.join(namespaceRoot, 'projects/a/subfolder'), + init: path.join(namespaceRoot, 'common/temp'), + bin: path.join(namespaceRoot, 'projects/a/node_modules/.bin'), + base: namespaceRoot, + marker: root + }); + } + expect(phased).not.toHaveBeenCalled(); + }); + + it('preserves native registration warnings for a project alias instead of substituting physical membership', async () => { + const cwd: string = path.join(fixture.folder, 'project-alias'); + fs.symlinkSync(path.join(physicalRoot, 'projects/a'), cwd, process.platform === 'win32' ? 'junction' : 'dir'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'args'], cwd); + expect(await fixture.invokeAsync(false, ['-q', 'args'], cwd)).toEqual(native); + expect(native.exitCode).toBe(0); + expect(native.stdout.toString().includes('this project is not registered')).toBe(process.platform === 'win32'); + }); + + it('preserves exact alias-qualified native pnpm-sync diagnostics without rewriting displayed paths', async () => { + fixture.write('projects/a/node_modules/.pnpm-sync.json', '{"version":"incompatible-fixture-version"}'); + const cwd: string = path.join(aliasRoot, 'projects/a/subfolder'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-d', 'sync'], cwd); + expect(await fixture.invokeAsync(false, ['-d', 'sync'], cwd)).toEqual(native); + expect(native.exitCode).toBe(1); + const namespaceRoot: string = process.platform === 'win32' ? aliasRoot : physicalRoot; + expect(native.stdout.toString()).toContain( + `Starting operation for ${path.join(namespaceRoot, 'projects/a/node_modules/.pnpm-sync.json')}` + ); + }); + + it('rejects a workspace alias retargeted outside the physical workspace before reading stdin', async () => { + const outsideRoot: string = path.join(aliasContainer, 'outside'); + fs.mkdirSync(path.join(outsideRoot, 'projects/a'), { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'projects/a/package.json'), '{"name":"outside","version":"1.0.0"}'); + fs.unlinkSync(aliasRoot); + fs.symlinkSync(outsideRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir'); + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result = await fixture.runAsync( + fixture.request(['build'], path.join(aliasRoot, 'projects/a')), undefined, { stdin: input } + ); + expect(result.outcome).toMatchObject({ + kind: 'rejected', rejection: { code: 'invalidRequest', message: expect.stringContaining('outside the daemon workspace') } + }); + expect(input.read().toString()).toBe('untouched'); + expect(result.stdout.length).toBe(0); + }); + + it('pins physical request identity when an alias changes while waiting for admission', async () => { + fixture.write('retargeted/projects/a/package.json', JSON.stringify({ + name: 'retargeted', version: '1.0.0', scripts: { build: 'node script.cjs' } + })); + fixture.write('retargeted/projects/a/script.cjs', "require('node:fs').writeFileSync('executed', 'wrong project');"); + let started: () => void = () => {}; + let release: () => void = () => {}; + const holding: Promise = new Promise((resolve) => { started = resolve; }); + const released: Promise = new Promise((resolve) => { release = resolve; }); + const holdAsync = async (): Promise<{ exitCode: number }> => { + started(); + await released; + return { exitCode: 0 }; + }; + jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync').mockResolvedValue({ + kind: 'global', executor: holdAsync + }); + const holder = fixture.runAsync({ + ...fixture.request(['hold'], path.join(physicalRoot, 'projects/a')), invocationKind: 'rush' + }); + await holding; + const input: PassThrough = new PassThrough(); + input.end('not consumed before execution'); + try { + const result = await fixture.runAsync( + fixture.request(['-q', 'build'], path.join(aliasRoot, 'projects/a')), undefined, { + stdin: input, + onQueuePositionAsync: async () => { + fs.unlinkSync(aliasRoot); + fs.symlinkSync(path.join(physicalRoot, 'retargeted'), aliasRoot, + process.platform === 'win32' ? 'junction' : 'dir'); + release(); + } + } + ); + if (process.platform === 'win32') { + expect(result.outcome).toMatchObject({ + kind: 'result', result: { exitCode: 1, errorMessage: expect.stringContaining('invocation directory changed') } + }); + expect(input.read().toString()).toBe('not consumed before execution'); + } else { + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout.toString()).cwd).toBe(path.join(physicalRoot, 'projects/a')); + } + expect(fs.existsSync(path.join(physicalRoot, 'retargeted/projects/a/executed'))).toBe(false); + } finally { + release(); + await holder; + } + }); + + it('falls back before input for relative temporary-folder initialization instead of using server cwd', async () => { + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result = await fixture.runAsync(fixture.request( + ['build'], path.join(aliasRoot, 'projects/a'), fixture.environment({ RUSH_TEMP_FOLDER: 'relative-temp' }) + ), undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ + kind: 'fallback', reason: 'unsupported', message: expect.stringContaining('Relative RUSH_TEMP_FOLDER') + }); + expect(input.read().toString()).toBe('untouched'); + expect(result.stdout.length).toBe(0); + }); +}); diff --git a/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts b/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts new file mode 100644 index 0000000000..6fc6f42c1c --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonBoundaries.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { DaemonClient } from '@rushstack/rush-client-core'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import { RushXDaemonTestFixture, type IRequestResult, type IScriptResult } from './RushXDaemonTestFixture'; + +jest.setTimeout(30_000); + +describe('native Rushx execution boundaries', () => { + let fixture: RushXDaemonTestFixture; + + afterEach(async () => { + await fixture?.[Symbol.asyncDispose](); + }); + + async function startAsync(hooks: boolean = false, pnpmSync: boolean = false): Promise { + fixture = new RushXDaemonTestFixture(hooks, pnpmSync); + await fixture.startAsync(); + return path.join(fixture.folder, 'projects/a'); + } + + it('rejects active hooks before execution/input, then native CLI fallback runs both hooks and the pipe once', async () => { + const cwd: string = await startAsync(true); + const input: PassThrough = new PassThrough(); + input.end('untouched'); + const result: IRequestResult = await fixture.runAsync(fixture.request(['pipe'], cwd), undefined, { stdin: input }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(input.read().toString()).toBe('untouched'); + expect(fs.existsSync(path.join(fixture.folder, 'hooks.txt'))).toBe(false); + const fallback: IScriptResult = await fixture.invokeAsync(false, ['-q', 'pipe'], cwd, undefined, Buffer.from('piped')); + expect(fallback.exitCode).toBe(0); + expect(fallback.stdout.toString()).toContain('piped'); + expect(fallback.stderr.toString()).toContain('using in-process Rush'); + expect(fs.readFileSync(path.join(fixture.folder, 'hooks.txt'), 'utf8')).toBe('hook\nhook\n'); + }); + + it('reuses native ignored and recursive hook behavior, including failure skipping post hooks', async () => { + const cwd: string = await startAsync(true); + for (const script of ['args', 'fail']) { + const argv: string[] = ['--ignore-hooks', script]; + expect(await fixture.invokeAsync(false, argv, cwd)).toEqual(await fixture.invokeAsync(true, argv, cwd)); + } + const environment = fixture.environment({ _RUSH_RECURSIVE_RUSHX_CALL: '1' }); + expect(await fixture.invokeAsync(false, ['args'], cwd, environment)) + .toEqual(await fixture.invokeAsync(true, ['args'], cwd, environment)); + expect(fs.existsSync(path.join(fixture.folder, 'hooks.txt'))).toBe(false); + }); + + it('executes the governing nested package and its real node_modules/.bin executable', async () => { + const cwd: string = await startAsync(); + fixture.write('projects/a/subfolder/package.json', JSON.stringify({ + name: 'nested', version: '1.0.0', scripts: { bin: 'fixture-bin' } + })); + const bin: string = process.platform === 'win32' ? 'fixture-bin.cmd' : 'fixture-bin'; + fixture.write(`projects/a/subfolder/node_modules/.bin/${bin}`, + process.platform === 'win32' ? '@echo path-ok\r\n' : '#!/bin/sh\nprintf "path-ok\\n"\n'); + fs.chmodSync(path.join(cwd, 'subfolder/node_modules/.bin', bin), 0o755); + const nested: string = path.join(cwd, 'subfolder'); + const native: IScriptResult = await fixture.invokeAsync(true, ['-q', 'bin'], nested); + expect(await fixture.invokeAsync(false, ['-q', 'bin'], nested)).toEqual(native); + expect(native.exitCode).toBe(0); + expect(native.stdout.toString().trim()).toBe('path-ok'); + }); + + it('does real injected-dependency synchronization with native quiet/debug behavior', async () => { + const cwd: string = await startAsync(false, true); + fixture.write('projects/a/node_modules/.pnpm-sync.json', JSON.stringify({ + version: '0.3.4', + postbuildInjectedCopy: { sourceFolder: '..', targetFolders: [{ folderPath: '../../../injected/a' }] } + })); + fixture.write('injected/a/output.txt', 'old-content'); + for (const argv of [['-q', 'sync'], ['-d', 'sync']]) { + const native: IScriptResult = await fixture.invokeAsync(true, argv, cwd); + const daemon: IScriptResult = await fixture.invokeAsync(false, argv, cwd); + const normalize = (value: Buffer): string => value.toString().replace(/Synced (\d+ files?) in \d+ ms/g, 'Synced $1 in N ms'); + expect({ code: daemon.exitCode, out: normalize(daemon.stdout), err: normalize(daemon.stderr) }) + .toEqual({ code: native.exitCode, out: normalize(native.stdout), err: normalize(native.stderr) }); + expect(daemon.exitCode).toBe(0); + expect(fs.readFileSync(path.join(fixture.folder, 'injected/a/output.txt'), 'utf8')).toBe('new-content'); + expect(fs.statSync(path.join(fixture.folder, 'injected/a/output.txt')).ino) + .toBe(fs.statSync(path.join(cwd, 'output.txt')).ino); + } + }); + + it('reads changed scripts and dotenv files without retaining a fake warm script engine', async () => { + const cwd: string = await startAsync(); + expect((await fixture.runAsync(fixture.request(['-q', 'build'], cwd))).exitCode).toBe(0); + fixture.write('projects/a/script.cjs', 'console.log(process.env.REPO_VALUE);'); + fixture.write('.env', 'REPO_VALUE=changed\n'); + const result: IRequestResult = await fixture.runAsync(fixture.request(['-q', 'build'], cwd)); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString()).toBe('changed\n'); + expect(fixture.session.operationGraph).toBeUndefined(); + }); + + it.each(['hooks', 'experiments', 'dotenv-configuration', 'vault', 'invalid-quiet'])( + 'rejects unsupported %s before consuming stdin or executing', async (kind) => { + const cwd: string = await startAsync(); + const environment = fixture.environment(); + if (kind === 'hooks') { + const file: string = path.join(fixture.folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + fixture.write('rush.json', JSON.stringify({ ...config, eventHooks: { preRushx: ['node hook.cjs'] } })); + } else if (kind === 'experiments') { + fixture.write('common/config/rush/experiments.json', '{"usePnpmSyncForInjectedDependencies":true}'); + } else if (kind === 'dotenv-configuration') { + fixture.write('.env', `RUSH_TEMP_FOLDER=${fixture.folder}/alternate-temp\n`); + } else if (kind === 'vault') { + environment.DOTENV_KEY = 'unsupported-vault'; + } else { + environment.RUSH_QUIET_MODE = 'invalid'; + } + const stdin: PassThrough = new PassThrough(); + stdin.end('not-read'); + const result = await fixture.runAsync(fixture.request(['build'], cwd, environment), undefined, { stdin }); + expect(result.outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(stdin.read().toString()).toBe('not-read'); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + } + ); + + it('fails visibly without running or reading when hooks change during queue admission', async () => { + const cwd: string = await startAsync(); + let release: () => void = () => {}; + let started: () => void = () => {}; + const released: Promise = new Promise((resolve) => { release = resolve; }); + const holding: Promise = new Promise((resolve) => { started = resolve; }); + const holdAsync = async (): Promise<{ exitCode: number }> => { + started(); + await released; + return { exitCode: 0 }; + }; + jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync').mockResolvedValue({ + kind: 'global', executor: holdAsync + }); + const holder = fixture.runAsync({ ...fixture.request(['hold'], cwd), invocationKind: 'rush' }); + await holding; + const stdin: PassThrough = new PassThrough(); + stdin.end('not-consumed'); + try { + const result = await fixture.runAsync(fixture.request(['build'], cwd), undefined, { + stdin, + onQueuePositionAsync: async () => { + const file: string = path.join(fixture.folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(file, 'utf8')); + fixture.write('rush.json', JSON.stringify({ ...config, eventHooks: { preRushx: ['node hook.cjs'] } })); + release(); + } + }); + expect(result.outcome).toMatchObject({ kind: 'result', result: { exitCode: 1, outcome: 'failure' } }); + expect(result.stderr.toString()).toContain('Rush configuration changed'); + expect(stdin.read().toString()).toBe('not-consumed'); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + } finally { + release(); + await holder; + } + }); + + it('does not guess package scripts for legacy or custom workspace requests', async () => { + const cwd: string = await startAsync(); + const phased = jest.spyOn(fixture.phasedResolver, 'resolveRequestAsync'); + for (const invocationKind of [undefined, 'rush'] as const) { + const request: IDaemonRequestEnvelope = { ...fixture.request(['build'], cwd), invocationKind }; + expect((await fixture.runAsync(request)).outcome).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + } + expect(phased).toHaveBeenCalledTimes(2); + expect(fs.existsSync(path.join(cwd, 'runs.txt'))).toBe(false); + }); + + it('cancels the owned script and descendant before publishing the authoritative result', async () => { + const cwd: string = await startAsync(); + const controller: AbortController = new AbortController(); + let output: string = ''; + const result = await fixture.runAsync(fixture.request(['-q', 'tree'], cwd), undefined, { + abortSignal: controller.signal, + onStdoutAsync: async (bytes) => { + output += Buffer.from(bytes).toString(); + if (output.includes('PARENT:') && output.includes('DESCENDANT:')) controller.abort(); + } + }); + expect(result.outcome).toMatchObject({ kind: 'result', result: { exitCode: 1, aborted: true, outcome: 'aborted' } }); + assertChildrenStopped(output); + expect((await fixture.runAsync(fixture.request(['-q', 'args'], cwd))).exitCode).toBe(0); + }); + + it('cleans disconnected children without retrying the request', async () => { + const cwd: string = await startAsync(); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: fixture.host.paths.socketPath }); + let output: string = ''; + await expect(client.executeAsync({ + request: fixture.request(['-q', 'tree'], cwd), + onStdoutAsync: async (bytes) => { + output += Buffer.from(bytes).toString(); + if (output.includes('PARENT:') && output.includes('DESCENDANT:')) await client.closeAsync(); + }, + onStderrAsync: async () => {} + })).rejects.toThrow('not retried'); + await fixture.host.closeAsync(); + assertChildrenStopped(output); + }); +}); + +function assertChildrenStopped(output: string): void { + const pids: number[] = Array.from(output.matchAll(/(?:PARENT|DESCENDANT):(\d+)/g), (match) => Number(match[1])); + expect(pids).toHaveLength(2); + for (const pid of pids) { + try { + process.kill(pid, 0); + // Linux containers may retain a reparented zombie until init reaps it; it cannot execute or own streams. + if (process.platform === 'linux') { + expect(fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].startsWith('Z ')).toBe(true); + } else { + throw new Error(`Script descendant ${pid} is still running.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH' && (error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } +} diff --git a/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts b/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts new file mode 100644 index 0000000000..5d3457cf2e --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXDaemonTestFixture.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; + +import { Rush } from '@microsoft/rush-lib'; +import { + DaemonClient, + captureDaemonRequest, + type DaemonClientOutcome, + type IDaemonClientExecuteOptions +} from '@rushstack/rush-client-core'; +import { + ProductionDaemonRequestResolver, + RushDaemonHost, + RushDaemonRequestResolver, + WorkspaceSession +} from '@rushstack/rush-daemon'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +export interface IScriptResult { + readonly exitCode: number | undefined; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +export interface IRequestResult extends IScriptResult { + readonly outcome: DaemonClientOutcome; +} + +export class RushXDaemonTestFixture implements AsyncDisposable { + public readonly folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushx-daemon-')); + public readonly home: string = path.join(this.folder, 'home'); + public readonly phasedResolver: ProductionDaemonRequestResolver = new ProductionDaemonRequestResolver(); + public readonly errors: Error[] = []; + public host!: RushDaemonHost; + public session!: WorkspaceSession; + + public constructor(hooks: boolean = false, pnpmSync: boolean = false) { + this.write('rush.json', JSON.stringify({ + rushVersion: Rush.version, + suppressNodeLtsWarning: true, + pnpmVersion: '10.27.0', + projectFolderMinDepth: 2, + projectFolderMaxDepth: 2, + daemon: { enabled: true, autoStart: false }, + eventHooks: hooks ? { preRushx: ['node hook.cjs'], postRushx: ['node hook.cjs'] } : {}, + projects: ['a', 'b'].map((name) => ({ packageName: name, projectFolder: `projects/${name}` })) + })); + this.write('hook.cjs', "require('node:fs').appendFileSync('hooks.txt', 'hook\\n');"); + this.write('common/config/rush/experiments.json', JSON.stringify({ + usePnpmSyncForInjectedDependencies: pnpmSync + })); + this.write('home/.rush-user/.env', 'USER_VALUE=from-user\nORDER=from-user\n'); + this.write('.env', 'REPO_VALUE=from-repo\nORDER=from-repo\nCLIENT_VALUE=from-repo\n'); + for (const name of ['a', 'b', 'unregistered']) { + this.write(`projects/${name}/package.json`, JSON.stringify({ + name, version: '1.0.0', + scripts: { + build: 'node script.cjs', + args: 'node args.cjs', + pipe: 'node pipe.cjs', + fail: 'node fail.cjs', + early: 'node early.cjs', + tree: 'node tree.cjs', + sync: 'node sync.cjs', + daemon: 'node args.cjs' + } + })); + this.write(`projects/${name}/subfolder/keep.txt`, ''); + this.write(`projects/${name}/script.cjs`, ` +const fs = require('node:fs'); +const path = require('node:path'); +fs.appendFileSync('runs.txt', 'ran\\n'); +console.log(JSON.stringify({ + name: require('./package.json').name, cwd: process.cwd(), + invoked: process.env.RUSH_INVOKED_FOLDER, init: process.env.INIT_CWD, + bin: process.env.PATH.split(path.delimiter)[0], marker: process.env.CLIENT_MARKER, + repo: process.env.REPO_VALUE, user: process.env.USER_VALUE, + order: process.env.ORDER, client: process.env.CLIENT_VALUE, + recursive: process.env._RUSH_RECURSIVE_RUSHX_CALL, + npmConfig: process.env.NPM_CONFIG_TEST, + absent: process.env.RUSHD_PARENT_ONLY +})); +`); + this.write(`projects/${name}/args.cjs`, 'process.stdout.write(JSON.stringify(process.argv.slice(2)));'); + this.write(`projects/${name}/pipe.cjs`, ` +process.stderr.write(Buffer.from([255, 0, 3, 10])); +process.stdin.pipe(process.stdout); +`); + this.write(`projects/${name}/fail.cjs`, + "process.stdout.write('raw-out'); process.stderr.write('raw-err'); process.exitCode = 7;"); + this.write(`projects/${name}/early.cjs`, 'process.exitCode = 23;'); + this.write(`projects/${name}/sync.cjs`, "require('node:fs').writeFileSync('output.txt', 'new-content');"); + this.write(`projects/${name}/tree.cjs`, ` +const { spawn } = require('node:child_process'); +spawn(process.execPath, ['-e', "console.log('DESCENDANT:' + process.pid); setInterval(() => {}, 1000);"], + { stdio: ['ignore', 'inherit', 'inherit'] }); +console.log('PARENT:' + process.pid); +setInterval(() => {}, 1000); +`); + } + } + + public async startAsync(): Promise { + const { version }: { version: string } = require('@rushstack/rush-daemon/package.json'); + this.host = await RushDaemonHost.startAsync({ + repoRoot: this.folder, + rushVersion: Rush.version, + daemonVersion: version, + requestResolver: new RushDaemonRequestResolver(this.phasedResolver), + onError: (error) => { this.errors.push(error); }, + createWorkspaceSessionAsync: async (options) => { + this.session = await WorkspaceSession.createAsync(options); + return this.session; + } + }); + } + + public write(filename: string, content: string): void { + const fullPath: string = path.join(this.folder, filename); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + } + + public environment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, HOME: this.home, USERPROFILE: this.home, + CLIENT_MARKER: 'client', CLIENT_VALUE: 'from-client', + NPM_CONFIG_TEST: 'discard-me', INIT_CWD: 'discard-me', + RUSHD_PARENT_ONLY: undefined, FORCE_COLOR: '0', NO_COLOR: undefined, + _RUSH_RECURSIVE_RUSHX_CALL: undefined, + RUSH_DAEMON: '1', RUSH_DAEMON_AUTO_START: '0', + CI: 'false', TF_BUILD: 'false', GITHUB_ACTIONS: 'false', + ...overrides + }; + } + + public request(argv: string[], cwd: string, environment: NodeJS.ProcessEnv = this.environment()): IDaemonRequestEnvelope { + return captureDaemonRequest({ + argv, commandName: argv.find((arg) => !arg.startsWith('-'))!, + commandOrigin: 'custom', invocationKind: 'rushx', cwd, environment, + terminal: { isTTY: false, supportsColor: false, acceptsStdin: true } + }); + } + + public async runAsync( + request: IDaemonRequestEnvelope, + input: Buffer = Buffer.alloc(0), + overrides: Partial = {} + ): Promise { + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: this.host.paths.socketPath }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const outcome: DaemonClientOutcome = await client.executeAsync({ + request, stdin: Readable.from([input]), requiresStdinEnd: true, + onStdoutAsync: async (bytes) => { stdout.push(Buffer.from(bytes)); }, + onStderrAsync: async (bytes) => { stderr.push(Buffer.from(bytes)); }, + ...overrides + }); + return { + outcome, exitCode: outcome.kind === 'result' ? outcome.result.exitCode : undefined, + stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) + }; + } + + public invokeAsync( + native: boolean, + argv: string[], + cwd: string, + environment: NodeJS.ProcessEnv = this.environment(), + input: Buffer = Buffer.alloc(0) + ): Promise { + const entry: string = native + ? path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx') + : path.resolve(__dirname, '../../bin/rushx-client'); + const child: ChildProcess = spawn(process.execPath, [entry, ...argv], { + cwd, env: environment, stdio: 'pipe' + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout!.on('data', (bytes: Buffer) => stdout.push(bytes)); + child.stderr!.on('data', (bytes: Buffer) => stderr.push(bytes)); + child.stdin!.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE' && !(process.platform === 'win32' && error.code === 'EOF')) throw error; + }); + child.stdin!.end(input); + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => resolve({ + exitCode: exitCode ?? undefined, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) + })); + }); + } + + public async [Symbol.asyncDispose](): Promise { + await this.host?.closeAsync(); + fs.rmSync(this.folder, { recursive: true, force: true }); + } +} diff --git a/apps/rush-cli-client/src/test/RushXTerminal.test.ts b/apps/rush-cli-client/src/test/RushXTerminal.test.ts new file mode 100644 index 0000000000..746418a87f --- /dev/null +++ b/apps/rush-cli-client/src/test/RushXTerminal.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { RushXDaemonRequestResolver } from '@rushstack/rush-daemon'; + +import { RushXDaemonTestFixture } from './RushXDaemonTestFixture'; +import { canRunPtyTests, invokeRushxPtyAsync } from './PtyTestProcess'; + +describe('native Rushx terminal boundaries', () => { + let fixture: RushXDaemonTestFixture; + let cwd: string; + + beforeEach(async () => { + fixture = new RushXDaemonTestFixture(); + cwd = path.join(fixture.folder, 'projects/a'); + fixture.write( + 'projects/a/package.json', + JSON.stringify({ + name: 'a', + version: '1.0.0', + scripts: { terminal: 'node terminal.cjs', tty: 'node tty.cjs' } + }) + ); + fixture.write( + 'projects/a/terminal.cjs', + 'process.stdout.write(JSON.stringify({color:process.env.FORCE_COLOR,width:process.env.COLUMNS}));' + ); + fixture.write( + 'projects/a/tty.cjs', + ` +const fs=require('node:fs'); +fs.appendFileSync('tty-runs.txt','run\\n'); +fs.writeFileSync('tty.pid',String(process.pid)); +if (!process.stdout.isTTY) throw new Error('Expected native stdout terminal.'); +if (process.stdin.isTTY) process.stdin.setRawMode(true); +process.on('SIGINT',()=>{ + if (process.stdin.isTTY) process.stdin.setRawMode(false); + process.stdout.write('restored='+!!process.stdin.isRaw+'\\n'); + process.exit(130); +}); +process.stdout.write('raw-ready\\n'); +process.stdin.resume(); +setInterval(()=>{},1000); +` + ); + await fixture.startAsync(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await fixture[Symbol.asyncDispose](); + }); + + it('projects terminal color and width after lifecycle preparation without leaking between requests', async () => { + const originalEnvironment: NodeJS.ProcessEnv = { ...process.env }; + const environment = fixture.environment({ FORCE_COLOR: undefined, COLUMNS: undefined }); + const requests = [ + { isTTY: true, supportsColor: true, columns: 123 }, + { isTTY: true, supportsColor: false, columns: 71 }, + { isTTY: false, supportsColor: false } + ].map((terminal) => ({ + ...fixture.request(['-q', 'terminal'], cwd, environment), + terminal: { ...terminal, acceptsStdin: true } + })); + const results = await Promise.all(requests.map((request) => fixture.runAsync(request))); + expect(results.map((result) => result.exitCode)).toEqual([0, 0, 0]); + expect(results.map((result) => JSON.parse(result.stdout.toString()))).toEqual([ + { color: '1', width: '123' }, + { color: '0', width: '71' }, + {} + ]); + expect(process.env).toEqual(originalEnvironment); + const explicitEnvironment = fixture.environment({ FORCE_COLOR: '2', COLUMNS: '99' }); + expect(await fixture.invokeAsync(false, ['-q', 'terminal'], cwd, explicitEnvironment)).toEqual( + await fixture.invokeAsync(true, ['-q', 'terminal'], cwd, explicitEnvironment) + ); + }, 15000); + + (canRunPtyTests ? it : it.skip).each([true, false])( + 'preserves real PTY raw mode, SIGINT and single execution before daemon admission (TTY stdin: %s)', + async (stdinIsTTY) => { + const resolve = jest.spyOn(RushXDaemonRequestResolver.prototype, 'resolveRequestAsync'); + const environment = fixture.environment({ FORCE_COLOR: undefined, COLUMNS: undefined, TERM: 'xterm' }); + const native = await invokeRushxPtyAsync( + path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx'), + cwd, + environment, + stdinIsTTY + ); + expect(native).toMatchObject({ exitCode: 130, restored: true }); + expect(native.output).toContain('raw-ready\r\nrestored=false\r\n'); + expect(fs.readFileSync(path.join(cwd, 'tty-runs.txt'), 'utf8')).toBe('run\n'); + const client = await invokeRushxPtyAsync( + path.resolve(__dirname, '../../bin/rushx-client'), + cwd, + environment, + stdinIsTTY + ); + expect(client).toEqual(native); + expect(fs.readFileSync(path.join(cwd, 'tty-runs.txt'), 'utf8')).toBe('run\nrun\n'); + expect(resolve).not.toHaveBeenCalled(); + }, + 40000 + ); +}); diff --git a/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts new file mode 100644 index 0000000000..c4f2109759 --- /dev/null +++ b/apps/rush-cli-client/src/test/daemonConnectionSelection.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { computeDaemonWorkspaceKey, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions, getDaemonConnectionOptionsAsync } from '../daemonConnectionOptions'; + +describe('version-selected daemon connection options', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-connection-')); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: Rush.version })); + }); + afterEach(() => fs.rmSync(repoRoot, { recursive: true })); + + it('uses the same native filesystem identity as the daemon host', async () => { + const nativeRoot: string = await fs.promises.realpath(repoRoot); + const options = getDaemonConnectionOptions(repoRoot, Rush.version, process.env, true); + expect(options.paths).toEqual(resolveDaemonPathsFromProcess(computeDaemonWorkspaceKey({ + canonicalRepoRoot: nativeRoot, rushVersion: Rush.version + }))); + expect(options.startCommand?.cwd).toBe(nativeRoot); + }); + + it('creates an attested async launcher without changing current synchronous callers', async () => { + const synchronous = getDaemonConnectionOptions(repoRoot, Rush.version, process.env, true); + const asynchronous = await getDaemonConnectionOptionsAsync(repoRoot, Rush.version, process.env, true); + expect(asynchronous.paths).toEqual(synchronous.paths); + expect(asynchronous.expectedDaemonVersion).toBe(synchronous.expectedDaemonVersion); + expect(asynchronous.startCommand?.args).toEqual(synchronous.startCommand?.args); + }); + + it('does not claim a different requested engine in the synchronous default launcher', () => { + expect(() => getDaemonConnectionOptions(repoRoot, '5.178.1', {}, true)).toThrow( + 'asynchronous version selection' + ); + }); + + it('does not select or install a launcher for a connect-only invocation', async () => { + const options = await getDaemonConnectionOptionsAsync(repoRoot, '5.178.1', {}, false); + expect(options.startCommand).toBeUndefined(); + expect(options.expectedDaemonVersion).toBeUndefined(); + }); +}); diff --git a/apps/rush-cli-client/src/test/daemonGraph.test.ts b/apps/rush-cli-client/src/test/daemonGraph.test.ts new file mode 100644 index 0000000000..8d76b245a1 --- /dev/null +++ b/apps/rush-cli-client/src/test/daemonGraph.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { + createDaemonHelloAck, DAEMON_PROTOCOL_VERSION, DaemonFrameType, + decodeDaemonControlMessage, encodeDaemonControlMessage, encodeDaemonEventFrame, + RUSHD_GRAPH_SNAPSHOT, type DaemonControlMessage +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +describe('graph client fails closed over the public wire', () => { + it.each(['unsupported', 'missing-snapshot', 'invalid-snapshot', 'old-protocol'])( + 'rejects %s without invoking native Rush', + async (mode) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-graph-peer-')); + const peers: Set = new Set(); + let listener: DaemonFrameListener | undefined; + let requests: number = 0; + try { + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: Rush.version, npmVersion: '10.0.0', projects: [] + })); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, process.env, false); + const protocolVersion = mode === 'old-protocol' ? { major: 0, minor: 1 } : DAEMON_PROTOCOL_VERSION; + listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion, + onConnection: (connection) => { + peers.add(connection); + connection.onClosed(() => { peers.delete(connection); }); + const send = (message: DaemonControlMessage): Promise => connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(message) + }); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await send(createDaemonHelloAck(protocolVersion, 'old-graph-peer')); + } else if (message.kind === 'ping') { + await send({ kind: 'pong', payload: { uptimeMs: 1 } }); + } else if (message.kind === 'requestStart') { + requests++; + expect(message.payload).toMatchObject({ + commandOrigin: 'built-in', commandName: 'daemon', argv: ['daemon', 'graph', 'show'] + }); + const requestId: string = message.payload.requestId; + if (mode === 'unsupported') { + await send({ + kind: 'requestRejected', payload: { requestId, code: 'unsupported', message: 'No graph route.' } + }); + return; + } + if (mode === 'invalid-snapshot') { + await connection.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion, eventId: 'bad-event', sessionId: 'old-graph-peer', sequence: 1, + timestamp: new Date().toISOString(), + source: { packageName: '@rushstack/rush-daemon', packageVersion: '0.0.0' }, + type: 'extension', privacy: 'local-sensitive', required: true, + payload: { name: RUSHD_GRAPH_SNAPSHOT, data: { requestId, snapshot: { initialized: 'invalid' } } } + }) + }); + return; + } + await send({ + kind: 'requestResult', payload: { requestId, aborted: false, exitCode: 0, outcome: 'success' } + }); + } + }); + } + }); + const child = spawn( + process.execPath, + [path.resolve(__dirname, '../../bin/rush-client'), 'daemon', 'graph', 'show'], + { cwd: folder, env: { ...process.env, RUSH_DAEMON_EXPERIMENTAL: '1' }, stdio: ['ignore', 'pipe', 'pipe'] } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (bytes: Buffer) => { stdout += bytes.toString(); }); + child.stderr.on('data', (bytes: Buffer) => { stderr += bytes.toString(); }); + expect((await once(child, 'close'))[0]).toBe(1); + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toMatchObject({ kind: 'graphError', message: expect.any(String) }); + expect(stdout).not.toMatch(/using in-process|Usage: rush /); + expect(requests).toBe(mode === 'old-protocol' ? 0 : 1); + } finally { + await Promise.all(Array.from(peers, (peer) => peer.closeAsync())); + await listener?.closeAsync(); + fs.rmSync(folder, { recursive: true, force: true }); + } + }, + 15000 + ); +}); diff --git a/apps/rush-cli-client/src/test/daemonLogs.test.ts b/apps/rush-cli-client/src/test/daemonLogs.test.ts new file mode 100644 index 0000000000..a098d9276d --- /dev/null +++ b/apps/rush-cli-client/src/test/daemonLogs.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Writable } from 'node:stream'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { getDaemonLogFilePath } from '@rushstack/rush-client-core'; +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; +import { printDaemonLogAsync } from '../daemonLogs'; + +async function waitUntilAsync(predicate: () => boolean): Promise { + const deadline: number = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for log output.'); + await delayAsync(10); + } +} + +describe('daemon launcher log following', () => { + let folder: string; + let paths: IDaemonPaths; + let filename: string; + let abort: AbortController; + let running: Promise | undefined; + let chunks: Buffer[]; + let output: Writable; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-log-follow-')); + fs.writeFileSync( + path.join(folder, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + projects: [] + }) + ); + paths = getDaemonConnectionOptions(folder, Rush.version, process.env, false).paths; + filename = getDaemonLogFilePath(paths); + fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filename, '', { mode: 0o600, flag: 'wx' }); + abort = new AbortController(); + running = undefined; + chunks = []; + output = new Writable({ + write: (chunk: Buffer, encoding, callback) => { + chunks.push(Buffer.from(chunk)); + callback(); + } + }); + }); + + afterEach(async () => { + abort.abort(new Error('Log fixture cleanup.')); + await Promise.allSettled([running]); + output.destroy(); + fs.rmSync(filename, { force: true }); + fs.rmSync(`${filename}.previous`, { force: true }); + fs.rmSync(folder, { recursive: true }); + }); + + it('follows appends after EOF in bounded byte-preserving chunks without creating daemon ownership', async () => { + running = printDaemonLogAsync(paths, { follow: true, abortSignal: abort.signal, output }); + await delayAsync(150); + const bytes: Buffer = Buffer.alloc(3 * 64 * 1024 + 17); + for (let index: number = 0; index < bytes.length; index++) bytes[index] = index % 256; + fs.appendFileSync(filename, bytes); + await waitUntilAsync(() => chunks.reduce((size, chunk) => size + chunk.length, 0) === bytes.length); + fs.appendFileSync(filename, 'later\n'); + await waitUntilAsync(() => Buffer.concat(chunks).subarray(-6).toString() === 'later\n'); + abort.abort(new Error('Finished following.')); + await running; + expect(Buffer.concat(chunks)).toEqual(Buffer.concat([bytes, Buffer.from('later\n')])); + expect(chunks.every((chunk) => chunk.length <= 64 * 1024)).toBe(true); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + expect(fs.existsSync(paths.socketPath)).toBe(false); + }); + + it('keeps the default snapshot finite', async () => { + fs.writeFileSync(filename, 'snapshot\n'); + await printDaemonLogAsync(paths, { output }); + fs.appendFileSync(filename, 'not-in-snapshot\n'); + expect(Buffer.concat(chunks).toString()).toBe('snapshot\n'); + }); + + it('honors backpressure and cancels a stalled output without reading unbounded chunks', async () => { + fs.writeFileSync(filename, Buffer.alloc(4 * 64 * 1024)); + let release: () => void = () => {}; + let writes: number = 0; + output = new Writable({ + highWaterMark: 1, + write: (chunk: Buffer, encoding, callback) => { + writes++; + expect(chunk.length).toBeLessThanOrEqual(64 * 1024); + release = callback; + } + }); + running = printDaemonLogAsync(paths, { follow: true, abortSignal: abort.signal, output }); + try { + await waitUntilAsync(() => writes === 1); + await delayAsync(150); + expect(writes).toBe(1); + abort.abort(new Error('Cancel blocked output.')); + await running; + expect(output.destroyed).toBe(true); + } finally { + release(); + } + }); + + it.each(['truncate', 'replace'])( + 'fails explicitly when the followed file is changed by %s', + async (mode) => { + fs.writeFileSync(filename, 'initial\n'); + running = printDaemonLogAsync(paths, { follow: true, abortSignal: abort.signal, output }); + const failed = expect(running).rejects.toThrow(mode === 'truncate' ? 'truncated' : 'replaced'); + await waitUntilAsync(() => chunks.length > 0); + if (mode === 'truncate') fs.truncateSync(filename); + else { + fs.renameSync(filename, `${filename}.previous`); + fs.writeFileSync(filename, 'replacement\n'); + } + await failed; + } + ); + + it('does not hide an output failure', async () => { + fs.writeFileSync(filename, 'output\n'); + output = new Writable({ + write: (chunk, encoding, callback) => callback(new Error('log destination failed')) + }); + await expect( + printDaemonLogAsync(paths, { follow: true, abortSignal: abort.signal, output }) + ).rejects.toThrow('log destination failed'); + }); + + it.each([false, true])( + 'cancels the actual follow CLI with exit 130 (stalled stdout: %s)', + async (stalled) => { + fs.writeFileSync(filename, stalled ? Buffer.alloc(10 * 1024 * 1024) : 'initial\n'); + const windowsSignal: boolean = process.platform === 'win32'; + const child: ChildProcess = spawn( + process.execPath, + [ + windowsSignal + ? path.join(__dirname, 'DaemonLogSignalTestProcess.js') + : path.resolve(__dirname, '../../bin/rush-client'), + 'daemon', + 'logs', + '--follow' + ], + { + cwd: folder, + env: { ...process.env, RUSH_DAEMON: '1' }, + stdio: windowsSignal ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'] + } + ); + const closed: Promise = once(child, 'close'); + let timeout: ReturnType | undefined; + let forced: boolean = false; + let stdout: string = ''; + let stderr: string = ''; + child.stderr!.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + try { + if (stalled) { + await once(child.stdout!, 'readable'); + await delayAsync(150); + } else { + child.stdout!.on('data', (bytes: Buffer) => { + stdout += bytes.toString(); + }); + await waitUntilAsync(() => stdout === 'initial\n'); + fs.appendFileSync(filename, 'appended\n'); + await waitUntilAsync(() => stdout === 'initial\nappended\n'); + } + timeout = setTimeout(() => { + forced = true; + child.kill('SIGKILL'); + }, 3000); + if (windowsSignal) child.send!('SIGINT'); + else child.kill('SIGINT'); + expect((await closed)[0]).toBe(130); + expect(forced).toBe(false); + if (windowsSignal) { + expect(JSON.parse(fs.readFileSync(path.join(folder, 'log-follow-signal.json'), 'utf8'))).toEqual({ + handlerDelivered: true + }); + } + expect(stderr).toBe(''); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + expect(fs.existsSync(paths.socketPath)).toBe(false); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + } + }, + 15000 + ); + + it('reports a real closed output pipe as an I/O failure rather than cancellation', async () => { + fs.writeFileSync(filename, Buffer.alloc(10 * 1024 * 1024)); + const child = spawn( + process.execPath, + [path.resolve(__dirname, '../../bin/rush-client'), 'daemon', 'logs', '--follow'], + { cwd: folder, env: { ...process.env, RUSH_DAEMON: '1' }, stdio: ['ignore', 'pipe', 'pipe'] } + ); + const closed = once(child, 'close'); + const readable = once(child.stdout, 'readable'); + let stderr: string = ''; + let forced: boolean = false; + let timeout: ReturnType | undefined; + child.stderr.on('data', (bytes: Buffer) => { + stderr += bytes.toString(); + }); + try { + await Promise.race([ + readable, + closed.then(() => { + throw new Error('Log client exited before filling its output pipe.'); + }) + ]); + timeout = setTimeout(() => { + forced = true; + child.kill('SIGKILL'); + }, 3000); + child.stdout.destroy(); + expect((await closed)[0]).toBe(1); + expect(forced).toBe(false); + expect(stderr).toMatch(/EPIPE|broken pipe|output failed/i); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + child.stdout.destroy(); + await closed; + } + }, 15000); + + it.each([false, true])( + 'preserves real file redirection (follow: %s)', + async (follow) => { + const bytes: Buffer = Buffer.alloc(128 * 1024 + 17); + for (let index: number = 0; index < bytes.length; index++) bytes[index] = index % 256; + fs.writeFileSync(filename, bytes); + const captured: string = path.join(folder, 'captured.log'); + const outputFd: number = fs.openSync(captured, 'w'); + const windowsSignal: boolean = follow && process.platform === 'win32'; + const child = spawn( + process.execPath, + [ + windowsSignal + ? path.join(__dirname, 'DaemonLogSignalTestProcess.js') + : path.resolve(__dirname, '../../bin/rush-client'), + 'daemon', + 'logs', + ...(follow ? ['--follow'] : []) + ], + { + cwd: folder, + env: { ...process.env, RUSH_DAEMON: '1' }, + stdio: windowsSignal ? ['ignore', outputFd, 'pipe', 'ipc'] : ['ignore', outputFd, 'pipe'] + } + ); + const closed = once(child, 'close'); + let stderr: string = ''; + let forced: boolean = false; + let timeout: ReturnType | undefined; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + try { + if (follow) { + await waitUntilAsync(() => fs.statSync(captured).size === bytes.length); + timeout = setTimeout(() => { + forced = true; + child.kill('SIGKILL'); + }, 3000); + if (windowsSignal) child.send!('SIGINT'); + else child.kill('SIGINT'); + } + expect((await closed)[0]).toBe(follow ? 130 : 0); + expect(forced).toBe(false); + expect(stderr).toBe(''); + expect(fs.readFileSync(captured)).toEqual(bytes); + fs.writeSync(outputFd, 'still-open'); + expect(fs.readFileSync(captured)).toEqual(Buffer.concat([bytes, Buffer.from('still-open')])); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } finally { + clearTimeout(timeout); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + fs.closeSync(outputFd); + } + }, + 15000 + ); +}); diff --git a/apps/rush-cli-client/src/test/launchClient.test.ts b/apps/rush-cli-client/src/test/launchClient.test.ts new file mode 100644 index 0000000000..14c8d888d9 --- /dev/null +++ b/apps/rush-cli-client/src/test/launchClient.test.ts @@ -0,0 +1,425 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as http from 'node:http'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { Rush } from '@microsoft/rush-lib'; +import { DaemonClient, connectOrStartDaemonAsync, getDaemonLogFilePath } from '@rushstack/rush-client-core'; +import { RushDaemonHost, WorkspaceSession } from '@rushstack/rush-daemon'; +import { + removeTestFolderAsync, + waitForTestProcessExitAsync +} from '@rushstack/rush-daemon/lib/test/TestProcessExit'; +import { captureTestDaemonListenerAsync } from '@rushstack/rush-daemon/lib/test/TestDaemonListener'; +import { readDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { getDaemonConnectionOptions } from '../daemonConnectionOptions'; + +interface IInvocationResult { + readonly code: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +describe('standalone rushx fallback', () => { + let folder: string; + let project: string; + let logFilePath: string; + let host: RushDaemonHost | undefined; + let invocationClosures: Promise[]; + let daemonPids: Set; + + beforeEach(() => { + invocationClosures = []; + daemonPids = new Set(); + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-cli-client-')); + project = path.join(folder, 'project'); + fs.mkdirSync(project); + fs.mkdirSync(path.join(folder, 'common/config/rush'), { recursive: true }); + fs.writeFileSync( + path.join(folder, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: '10.27.0', + daemon: { enabled: false, autoStart: false }, + projects: [{ packageName: 'sample', projectFolder: 'project' }], + projectFolderMinDepth: 1 + }) + ); + logFilePath = getDaemonLogFilePath(getDaemonConnectionOptions(folder, Rush.version, {}, false).paths); + fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'sample', + version: '1.0.0', + scripts: { + sample: + 'node -e "process.stdout.write(process.env.CLIENT_MARKER); process.stderr.write(\'script-error\'); process.exitCode=7;"' + } + }) + ); + }); + + afterEach(async () => { + const closingHost: RushDaemonHost | undefined = host; + const closingFolder: string = folder; + const closingLogFilePath: string = logFilePath; + const closingDaemonPids: Set = daemonPids; + host = undefined; + await Promise.all(invocationClosures); + await closingHost?.closeAsync(); + await Promise.all(Array.from(closingDaemonPids, (pid) => waitForTestProcessExitAsync(pid))); + await removeTestFolderAsync(closingLogFilePath, true); + await removeTestFolderAsync(closingFolder); + }); + + async function invokeAsync( + client: boolean, + optIn: boolean, + fakeTty: boolean = false, + managementArgs?: ReadonlyArray, + environmentOverrides: NodeJS.ProcessEnv = {} + ): Promise { + const invocationDaemonPids: Set = daemonPids; + const entry: string = client + ? path.resolve(__dirname, managementArgs ? '../../bin/rush-client' : '../../bin/rushx-client') + : path.resolve(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx'); + const args: string[] = fakeTty + ? [ + '-e', + `Object.defineProperty(process.stdin, 'isTTY', { value: true }); process.argv = [process.execPath, ${JSON.stringify(entry)}, 'sample']; require(${JSON.stringify(entry)});` + ] + : [entry, ...(managementArgs ?? [...(client ? ['--no-daemon'] : []), 'sample'])]; + const child: ChildProcess = spawn(process.execPath, args, { + cwd: project, + env: { + ...process.env, + CLIENT_MARKER: 'script-output', + RUSH_DAEMON: optIn ? '1' : '0', + CI: managementArgs ? 'true' : 'false', + TF_BUILD: 'false', + GITHUB_ACTIONS: 'false', + ...environmentOverrides + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout!.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed: Promise = once(child, 'close'); + invocationClosures.push(closed); + const [code] = await closed; + if ( + code === 0 && + managementArgs?.[0] === 'daemon' && + (managementArgs[1] === 'start' || managementArgs[1] === 'restart') + ) { + const { pid }: { pid: unknown } = JSON.parse(stdout); + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) { + throw new Error('The started fixture daemon did not report a valid PID.'); + } + if (pid !== process.pid) invocationDaemonPids.add(pid); + } + return { code: typeof code === 'number' ? code : undefined, stdout, stderr }; + } + + it('preserves native project-script output and exit code for --no-daemon', async () => { + const native: IInvocationResult = await invokeAsync(false, false); + expect(native.code).toBe(7); + expect(await invokeAsync(true, true)).toEqual(native); + }, 15000); + + it('keeps unknown interactive scripts on the native path even when a daemon is running', async () => { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + const native: IInvocationResult = await invokeAsync(false, false); + const client: IInvocationResult = await invokeAsync(true, true, true); + expect(client.code).toBe(native.code); + expect(client.stdout).toBe(native.stdout); + expect(client).toEqual(native); + }, 15000); + + it('starts idempotently and reports real readiness in CI without execution opt-in', async () => { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + for (const verb of ['start', 'start', 'status']) { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', verb]); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + const status: Record = JSON.parse(result.stdout); + expect(status).toMatchObject({ + state: 'ready', + socketPath: host.paths.socketPath, + daemonVersion: daemonPackage.version + }); + expect(status.uptimeMs).toEqual(expect.any(Number)); + expect(status.pid).toBe(process.pid); + expect(status.residentMemoryBytes).toEqual(expect.any(Number)); + expect(status).not.toHaveProperty('warmProjects'); + } + }, 15000); + + it('status never starts an absent daemon or falls back to command execution', async () => { + const result: IInvocationResult = await invokeAsync(true, true, false, ['daemon', 'status']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Could not connect to daemon'); + expect(result.stderr).not.toContain('using in-process'); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + }); + + it('starts an absent daemon that survives the client and then shuts down when idle', async () => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { enabled: false, autoStart: false, idleTimeoutSeconds: 2 } + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + try { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ state: 'ready', socketPath: paths.socketPath }); + expect(fs.existsSync(paths.lockfilePath)).toBe(true); + } finally { + const deadline: number = Date.now() + 7000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(50); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } + const stopped: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'status']); + expect(stopped.code).toBe(1); + const log: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'logs']); + expect(log.code).toBe(0); + expect(log.stdout).toContain('rushd ready at'); + }, 15000); + + it('reads a saved launcher log without connecting or auto-starting', async () => { + fs.mkdirSync(path.dirname(logFilePath), { recursive: true, mode: 0o700 }); + const contents: string = `${'startup output\n'.repeat(20000)}startup error\n`; + fs.writeFileSync(logFilePath, contents, { mode: 0o600 }); + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'logs']); + expect(result).toEqual({ code: 0, stdout: contents, stderr: '' }); + expect( + fs.existsSync(getDaemonConnectionOptions(folder, Rush.version, {}, false).paths.lockfilePath) + ).toBe(false); + }); + + it('returns an actionable missing-log error without creating a log or daemon', async () => { + const result: IInvocationResult = await invokeAsync(true, true, false, ['daemon', 'logs']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No launcher log exists'); + expect(result.stderr).toContain(logFilePath); + expect(fs.existsSync(logFilePath)).toBe(false); + }); + + it('accepts an empty launcher log and rejects extra follow arguments', async () => { + fs.mkdirSync(path.dirname(logFilePath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(logFilePath, '', { mode: 0o600 }); + expect(await invokeAsync(true, false, false, ['daemon', 'logs'])).toEqual({ + code: 0, + stdout: '', + stderr: '' + }); + expect((await invokeAsync(true, false, false, ['daemon', 'logs', '--follow', 'extra'])).code).toBe(1); + }); + + it('does not start an absent daemon when stop or restart cannot be acknowledged', async () => { + for (const verb of ['stop', 'restart']) { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', verb]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Could not connect to daemon'); + } + }); + + it.each([false, true])( + 'restarts after ownership release and stops the successor (embedded: %s)', + async (embedded) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { enabled: false, autoStart: false, idleTimeoutSeconds: 5 } + }) + ); + const { paths } = getDaemonConnectionOptions(folder, Rush.version, {}, false); + try { + if (embedded) { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version + }); + } else { + const started: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start']); + expect(started.code).toBe(0); + } + const originalLock: { startedAt: string } = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + const restarted: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'restart']); + expect(restarted.stderr).toBe(''); + expect(restarted.code).toBe(0); + expect(JSON.parse(restarted.stdout)).toMatchObject({ + state: 'ready', + pid: expect.any(Number), + residentMemoryBytes: expect.any(Number) + }); + const successorLock: { startedAt: string } = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + expect(successorLock.startedAt).not.toBe(originalLock.startedAt); + await host?.closeAsync(); + expect(fs.existsSync(paths.lockfilePath)).toBe(true); + const stopped: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'stop']); + expect(stopped.code).toBe(0); + expect(JSON.parse(stopped.stdout)).toMatchObject({ state: 'shutdownAccepted' }); + } finally { + const deadline: number = Date.now() + 7000; + while (fs.existsSync(paths.lockfilePath) && Date.now() < deadline) await delayAsync(50); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + } + }, + 30000 + ); + + it('bounds restart after failed workspace cleanup without deleting live ownership', async () => { + const errors: Error[] = []; + const cleanupFailure: Error = new Error('workspace cleanup failed'); + const { value: failedHost, listener } = await captureTestDaemonListenerAsync(() => + RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: 'cleanup-failure-test', + onError: (error) => { + errors.push(error); + }, + createWorkspaceSessionAsync: async (sessionOptions) => + WorkspaceSession.createAsync({ + ...sessionOptions, + createComponentsAsync: async () => ({ + [Symbol.asyncDispose]: async () => { + throw cleanupFailure; + } + }) + }) + }) + ); + let client: DaemonClient | undefined; + try { + const originalRecord: string = fs.readFileSync(failedHost.paths.lockfilePath, 'utf8'); + const previousDaemon = readDaemonLockfile(failedHost.paths.lockfilePath)!; + client = await DaemonClient.connectAsync({ socketPath: failedHost.paths.socketPath }); + await client.shutdownAsync(); + await failedHost.closed; + await expect(failedHost.closeAsync()).rejects.toThrow('workspace cleanup failed'); + expect(errors).toHaveLength(1); + await expect( + connectOrStartDaemonAsync({ + ...getDaemonConnectionOptions(folder, Rush.version, {}, true), + previousDaemon, + startupTimeoutMs: 40 + }) + ).rejects.toThrow('previous daemon still owns'); + expect(fs.readFileSync(failedHost.paths.lockfilePath, 'utf8')).toBe(originalRecord); + if (process.platform !== 'win32') expect(fs.existsSync(failedHost.paths.socketPath)).toBe(true); + } finally { + const cleanup = await Promise.allSettled([client?.closeAsync(), failedHost.closeAsync()]); + try { + expect(cleanup).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'rejected', reason: cleanupFailure } + ]); + } finally { + // The only injected failure owns no live resources; release the real fixture listener last. + await listener.closeAsync(); + } + } + expect(fs.existsSync(failedHost.paths.lockfilePath)).toBe(false); + if (process.platform !== 'win32') expect(fs.existsSync(failedHost.paths.socketPath)).toBe(false); + }); + + it('rejects extra management arguments without silently ignoring them', async () => { + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'status', 'extra']); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Usage: rush-client daemon start|status'); + }); + + it('does not guess a daemon launcher for a different selected Rush version', async () => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config: Record = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync(rushJsonPath, JSON.stringify({ ...config, rushVersion: '0.0.0' })); + let registryRequests: number = 0; + const registry = http.createServer((request, response) => { + registryRequests++; + request.resume(); + response.writeHead(404, { 'content-type': 'application/json', connection: 'close' }); + response.end(JSON.stringify({ error: 'no supported release' })); + }); + await new Promise((resolve) => registry.listen(0, '127.0.0.1', resolve)); + try { + const address = registry.address(); + if (!address || typeof address === 'string') throw new Error('Expected a local registry address.'); + const registryUrl: string = `http://127.0.0.1:${address.port}`; + // Native Rush subprocesses discard NPM_CONFIG_* overrides. + fs.writeFileSync( + path.join(folder, 'common/config/rush/.npmrc'), + `registry=${registryUrl}\ncache=${path.join(folder, 'npm-cache')}\naudit=false\nfund=false\n` + ); + const home: string = path.join(folder, 'home'); + fs.mkdirSync(home); + fs.writeFileSync(path.join(home, '.npmrc'), ''); + const result: IInvocationResult = await invokeAsync(true, false, false, ['daemon', 'start'], { + HOME: home, + USERPROFILE: home, + RUSH_GLOBAL_FOLDER: path.join(folder, 'global'), + // Use Node's npm rather than a user-specific wrapper that depends on the real HOME. + PATH: [path.dirname(process.execPath), process.env.PATH].filter(Boolean).join(path.delimiter) + }); + expect(result.code).toBe(1); + expect(result.stderr).toContain('Cannot launch selected Rush 0.0.0'); + expect(result.stdout).toBe(''); + expect(registryRequests).toBeGreaterThan(0); + expect(fs.existsSync(getDaemonConnectionOptions(folder, '0.0.0', {}, false).paths.lockfilePath)).toBe( + false + ); + } finally { + await new Promise((resolve, reject) => + registry.close((error) => (error ? reject(error) : resolve())) + ); + } + }, 15000); + + it('rejects explicit startup combined with --no-daemon', async () => { + const result: IInvocationResult = await invokeAsync(true, false, false, [ + 'daemon', + 'start', + '--no-daemon' + ]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--no-daemon cannot be combined with daemon start'); + }); +}); diff --git a/apps/rush-cli-client/src/test/nativeBuild.test.ts b/apps/rush-cli-client/src/test/nativeBuild.test.ts new file mode 100644 index 0000000000..2703d68a41 --- /dev/null +++ b/apps/rush-cli-client/src/test/nativeBuild.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { DaemonClient } from '@rushstack/rush-client-core'; +import { RUSHD_GRAPH_SNAPSHOT } from '@rushstack/rush-daemon-protocol'; +import { + createNativeBuildTestFixture, + type INativeBuildTestFixture, + type INativeBuildResult as IResult +} from './NativeBuildTestFixture'; + +// A pending CLI startup can take 15s; allow its join plus daemon stop/drain and fixture removal. +const NATIVE_FIXTURE_CLEANUP_TIMEOUT_MS: number = 35_000; + +describe('native build through the standalone client', () => { + let fixture: INativeBuildTestFixture | undefined; + beforeEach(() => { + fixture = undefined; + fixture = createNativeBuildTestFixture(); + }); + afterEach(async () => { + await fixture?.closeAsync(); + }, NATIVE_FIXTURE_CLEANUP_TIMEOUT_MS); + + function runWithFixtureAsync(work: (current: INativeBuildTestFixture) => Promise): Promise { + if (!fixture) throw new Error('The native build fixture was not initialized.'); + return fixture.runAsync(work); + } + + describe('after a successful initial native build', () => { + const argv: string[] = ['build', '--to', 'b', '--verbose']; + let firstPid: number | undefined; + beforeEach( + () => + runWithFixtureAsync(async ({ paths, invokeAsync }) => { + const first: IResult = await invokeAsync(argv); + expect(first.code).toBe(0); + expect(first.stderr).not.toMatch(/using in-process/i); + expect(first.stdout).toContain('built-a-one'); + expect(first.stdout).toContain('built-b-one'); + expect(first.stdout).toContain('==['); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: paths.socketPath }); + try { + firstPid = (await client.status).pid; + } finally { + await client.closeAsync(); + } + }), + 30000 + ); + + it( + 'executes selected scripts, reuses warm state, and never confuses rushx build with rush build', + () => + runWithFixtureAsync(async ({ folder, invokeAsync }) => { + expect((await invokeAsync(argv)).code).toBe(0); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + const native: IResult = await invokeAsync(['--no-daemon', ...argv]); + expect(native.code).toBe(0); + expect(native.stderr).not.toContain('Another Rush command'); + const beforeChange: string = fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8'); + fs.writeFileSync(path.join(folder, 'a/input.txt'), 'two'); + expect((await invokeAsync(argv)).code).toBe(0); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe( + `${beforeChange}a:two\nb:one\n` + ); + const status = await invokeAsync(['daemon', 'status']); + expect(JSON.parse(status.stdout).pid).toBe(firstPid); + const script: IResult = await invokeAsync(['build'], true); + expect(script.code).toBe(0); + expect(script.stderr).not.toMatch(/using in-process/i); + expect(script.stdout).toContain('rushx-only'); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe( + `${beforeChange}a:two\nb:one\n` + ); + }), + 30000 + ); + }); + + it( + 'gates graph commands and inspects an uninitialized daemon without running work', + () => + runWithFixtureAsync(async ({ folder, environment, paths, invokeAsync, snapshotAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '0'; + const disabled = await invokeAsync(['daemon', 'graph', 'show']); + expect(disabled.code).toBe(1); + expect(JSON.parse(disabled.stdout)).toMatchObject({ kind: 'graphError' }); + expect(disabled.stderr).toBe(''); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['daemon', 'start'])).code).toBe(0); + + expect(await snapshotAsync('show')).toMatchObject({ initialized: false }); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + expect((await invokeAsync(['daemon', 'graph', 'watch'])).code).toBe(1); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + }), + 30000 + ); + + it( + 'provides presentation-free graph controls without implicitly executing scheduled work', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync, snapshotAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b', '--parallelism', '3'])).code).toBe(0); + expect(await snapshotAsync('scope-out', '--project', 'a')).toMatchObject({ + operations: [{ enabled: false }, { enabled: false }] + }); + expect(await snapshotAsync('scope-in', '--operation', 'b (compile)')).toMatchObject({ + operations: [{ enabled: true }, { enabled: true, dependencyIds: ['a (compile)'] }] + }); + expect(await snapshotAsync('pause')).toMatchObject({ pauseNextIteration: true }); + expect(await snapshotAsync('invalidate', '--project', 'a')).toMatchObject({ + hasScheduledIteration: false, + operations: [{ status: 'READY' }, { status: 'SUCCESS' }] + }); + expect(await snapshotAsync('resume')).toMatchObject({ pauseNextIteration: false }); + expect(await snapshotAsync('status')).toMatchObject({ initialized: true }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + }), + 30000 + ); + + it( + 'rejects invalid graph commands without native fallback or executing work', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + for (const args of [['invalid'], ['scope-out', '--project', 'missing'], ['pause', 'invalid']]) { + const result = await invokeAsync(['daemon', 'graph', ...args]); + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ kind: 'requestRejected' }); + } + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + }), + 30000 + ); + + it( + 'streams graph changes and gracefully cancels through the CLI signal handler', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync, snapshotAsync, trackWatch }) => { + environment.RUSH_DAEMON_EXPERIMENTAL = '1'; + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const entry: string = path.resolve( + __dirname, + process.platform === 'win32' ? 'CliSignalTestProcess.js' : '../../bin/rush-client' + ); + const watch = spawn(process.execPath, [entry, 'daemon', 'graph', 'watch'], { + cwd: folder, + env: environment, + stdio: process.platform === 'win32' ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'] + }); + const closed = once(watch, 'close'); + trackWatch(watch, closed); + let output: string = ''; + let errors: string = ''; + watch.stderr!.on('data', (bytes: Buffer) => { + errors += bytes.toString(); + }); + const ready = new Promise((resolve) => { + watch.stdout!.on('data', (bytes: Buffer) => { + output += bytes.toString(); + if (output.includes('\n')) resolve(); + }); + }); + try { + await Promise.race([ + ready, + closed.then(() => { + throw new Error(`Graph watch exited before its first snapshot: ${errors}`); + }) + ]); + expect(await snapshotAsync('scope-out', '--project', 'a')).toMatchObject({ + operations: [{ enabled: false }, { enabled: false }] + }); + if (process.platform === 'win32') watch.send('SIGINT'); + else watch.kill('SIGINT'); + expect(await closed).toEqual([130, null]); + expect(errors).toBe(''); + const records = output + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(records[0]).toMatchObject({ type: 'extension', payload: { name: RUSHD_GRAPH_SNAPSHOT } }); + expect(records.at(-1)).toMatchObject({ + kind: 'requestResult', + payload: { outcome: 'aborted', exitCode: 130 } + }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\n'); + } finally { + if (watch.exitCode === null && watch.signalCode === null) watch.kill('SIGTERM'); + await closed; + } + }), + 30000 + ); + + describe('after observing cold and warm policy through CLI status', () => { + let previousPid: number; + let previousGeneration: number; + let previousGenerationToken: string; + beforeEach( + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + delete environment.RUSH_DAEMON_WATCH; + delete environment.RUSH_DAEMON_WARM_SET_MAX_PROJECTS; + const started: IResult = await invokeAsync(['daemon', 'start']); + expect(started.code).toBe(0); + const cold = JSON.parse(started.stdout); + expect(cold.workspace).toMatchObject({ + graphInitialized: false, + generationToken: expect.any(String), + lastReloadTier: 0 + }); + expect(cold.workspace.warmSet).toBeUndefined(); + expect(fs.existsSync(path.join(folder, 'runs.txt'))).toBe(false); + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const warmStatus: IResult = await invokeAsync(['daemon', 'status']); + expect(warmStatus.code).toBe(0); + const warm = JSON.parse(warmStatus.stdout); + expect(warm.workspace).toMatchObject({ + graphInitialized: true, + warmSet: { + configuration: { watch: false }, + maintenanceState: 'running', + watchedProjectNames: [], + retainedProjectNames: expect.any(Array), + daemonResidentMemoryBytes: expect.any(Number) + } + }); + previousPid = warm.pid; + previousGeneration = warm.workspace.generation; + previousGenerationToken = warm.workspace.generationToken; + }), + 30000 + ); + + it( + 'reports live warm policy and reload generations through ordinary CLI status without running work', + () => + runWithFixtureAsync(async ({ folder, invokeAsync }) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const config = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + fs.writeFileSync( + rushJsonPath, + JSON.stringify({ + ...config, + daemon: { ...config.daemon, watch: true, warmSetMaxProjects: 1 } + }) + ); + expect((await invokeAsync(['build', '--to', 'b'])).code).toBe(0); + const runsBefore: string = fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8'); + const reloadedStatus: IResult = await invokeAsync(['daemon', 'status']); + expect(reloadedStatus.code).toBe(0); + const reloaded = JSON.parse(reloadedStatus.stdout); + expect(reloaded.pid).toBe(previousPid); + expect(reloaded.workspace.generation).toBeGreaterThan(previousGeneration); + expect(reloaded.workspace.generationToken).not.toBe(previousGenerationToken); + expect(reloaded.workspace).toMatchObject({ + lastReloadTier: 1, + graphInitialized: true, + warmSet: { configuration: { watch: true, warmSetMaxProjects: 1 } } + }); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe(runsBefore); + }), + 30000 + ); + }); + + it( + 'runs the genuine preview-selected engine without rewriting the configured Rush version', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + const rushJsonPath: string = path.join(folder, 'rush.json'); + const configured = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + const original: string = JSON.stringify({ ...configured, rushVersion: '5.178.1' }); + fs.writeFileSync(rushJsonPath, original); + environment.RUSH_PREVIEW_VERSION = Rush.version; + const result: IResult = await invokeAsync(['build', '--to', 'b', '--verbose']); + expect(result.code).toBe(0); + expect(result.stderr).not.toMatch(/using in-process/i); + expect(result.stdout).toContain('built-a-one'); + expect(result.stdout).toContain('built-b-one'); + expect(fs.readFileSync(rushJsonPath, 'utf8')).toBe(original); + const status: IResult = await invokeAsync(['daemon', 'status']); + expect(status.code).toBe(0); + const initialPid: number = JSON.parse(status.stdout).pid; + expect((await invokeAsync(['daemon', 'restart'])).code).toBe(0); + expect(JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid).not.toBe(initialPid); + }), + 30000 + ); + + describe('after an initial native build', () => { + const argv: string[] = ['build', '--to', 'b', '--verbose']; + let previousPid: number; + beforeEach( + () => + runWithFixtureAsync(async ({ invokeAsync }) => { + expect((await invokeAsync(argv)).code).toBe(0); + previousPid = JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid; + }), + 30000 + ); + + it( + 'transparently replaces a hard-input generation and executes the original request exactly once', + () => + runWithFixtureAsync(async ({ folder, environment, invokeAsync }) => { + environment.RUSHD_TEST_RESTART_VALUE = 'new-process-environment'; + fs.writeFileSync(path.join(folder, 'a/input.txt'), 'two'); + const changed: IResult = await invokeAsync(argv); + expect(changed.code).toBe(0); + expect(changed.stderr).not.toMatch(/using in-process|restart/i); + expect(changed.stdout).toContain('built-a-two'); + const after = JSON.parse((await invokeAsync(['daemon', 'status'])).stdout); + expect(after.pid).not.toBe(previousPid); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\na:two\nb:one\n'); + expect((await invokeAsync(argv)).code).toBe(0); + expect(JSON.parse((await invokeAsync(['daemon', 'status'])).stdout).pid).toBe(after.pid); + expect(fs.readFileSync(path.join(folder, 'runs.txt'), 'utf8')).toBe('a:one\nb:one\na:two\nb:one\n'); + }), + 45000 + ); + }); +}); diff --git a/apps/rush-cli-client/src/test/nativeMutation.test.ts b/apps/rush-cli-client/src/test/nativeMutation.test.ts new file mode 100644 index 0000000000..062ff31a78 --- /dev/null +++ b/apps/rush-cli-client/src/test/nativeMutation.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { SuccessfulMutationFixture } from '@rushstack/rush-daemon/lib/test/SuccessfulMutationFixture'; +import { computeDaemonWorkspaceKey, resolveDaemonPaths } from '@rushstack/rush-daemon-transport'; + +interface IClientResult { + readonly exitCode: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +async function invokeAsync( + fixture: SuccessfulMutationFixture, + argv: ReadonlyArray +): Promise { + const child = spawn(process.execPath, [path.resolve(__dirname, '../../bin/rush-client'), ...argv], { + cwd: fixture.repoRoot, + env: { ...fixture.environment, RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy' }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + const [exitCode] = await once(child, 'close'); + return { exitCode: exitCode ?? undefined, stdout, stderr }; +} + +describe('native mutations through the standalone CLI and default daemon', () => { + it.each(['install', 'update'] as const)('runs %s without fallback or administrative replay', async (command) => { + const fixture: SuccessfulMutationFixture = await SuccessfulMutationFixture.createAsync(); + fixture.paths = resolveDaemonPaths( + { platform: process.platform, env: fixture.environment, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + computeDaemonWorkspaceKey({ canonicalRepoRoot: fs.realpathSync.native(fixture.repoRoot), rushVersion: Rush.version }) + ); + try { + expect(await fixture.runWorkerAsync('update')).toMatchObject({ exitCode: 0 }); + const started: IClientResult = await invokeAsync(fixture, ['daemon', 'start']); + expect(started.exitCode).toBe(0); + fixture.initialPid = JSON.parse(started.stdout).pid; + expect(fixture.initialPid).toEqual(expect.any(Number)); + expect(await invokeAsync(fixture, ['build', '--to', '@mutation/app', '--parallelism', '3'])) + .toMatchObject({ exitCode: 0 }); + expect(fixture.readAppOutput()).toBe('provider-one-installed:before'); + const lockfile: string = path.join(fixture.repoRoot, 'common/config/rush/pnpm-lock.yaml'); + const previousLockfile: string = fs.readFileSync(lockfile, 'utf8'); + if (command === 'update') fixture.selectSecondProvider(); + else fixture.removeInstalledAppDependency(); + + const mutation: IClientResult = await invokeAsync(fixture, [command, '--bypass-policy', '--offline']); + expect(mutation.exitCode).toBe(0); + expect(mutation.stderr).not.toMatch(/using in-process/i); + expect(mutation.stdout).toContain(`Found pnpm version ${fixture.pnpmVersion}`); + const successor = await fixture.waitForSuccessorAsync(); + expect(successor.pid).not.toBe(fixture.initialPid); + const status: IClientResult = await invokeAsync(fixture, ['daemon', 'status']); + expect(status.exitCode).toBe(0); + expect(JSON.parse(status.stdout).pid).toBe(successor.pid); + const currentLockfile: string = fs.readFileSync(lockfile, 'utf8'); + if (command === 'install') expect(currentLockfile).toBe(previousLockfile); + else expect(currentLockfile).not.toBe(previousLockfile); + + const rebuilt: IClientResult = await invokeAsync(fixture, ['rebuild', '--to', '@mutation/app', '--parallelism', '3']); + expect(rebuilt.exitCode).toBe(0); + expect(rebuilt.stderr).not.toMatch(/using in-process/i); + expect(fixture.readAppOutput()).toBe(`${command === 'install' ? 'provider-one' : 'provider-two'}-installed:before`); + expect(fs.readFileSync(lockfile, 'utf8')).toBe(currentLockfile); + expect(JSON.parse((await invokeAsync(fixture, ['daemon', 'status'])).stdout).pid).toBe(successor.pid); + } finally { + await fixture[Symbol.asyncDispose](); + } + }, 120_000); +}); diff --git a/apps/rush-cli-client/src/test/persistentIpc.test.ts b/apps/rush-cli-client/src/test/persistentIpc.test.ts new file mode 100644 index 0000000000..b4eab26fdc --- /dev/null +++ b/apps/rush-cli-client/src/test/persistentIpc.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { createPersistentIpcTestFixture, type IPersistentIpcTestFixture } from './PersistentIpcTestFixture'; + +jest.setTimeout(45_000); + +describe('explicit persistent Node IPC through the public client', () => { + let activeFixture: IPersistentIpcTestFixture | undefined; + afterEach(async () => { + const closing = activeFixture; + activeFixture = undefined; + await closing?.closeAsync(); + }, 35_000); + + function withFixtureAsync( + work: (current: IPersistentIpcTestFixture) => Promise, + options: Parameters[0] = {} + ): Promise { + const current = createPersistentIpcTestFixture(options); + activeFixture = current; + return current.runAsync(() => work(current)); + } + + it('runs the real Node launcher, preserves raw args, reuses PID and skips unchanged work', () => withFixtureAsync(async (fixture) => { + const result = await fixture.invokeAsync(['build', '--only', 'a', '--label', 'custom raw value', '--ignored', 'excluded', '--verbose']); + if (result.code !== 0) throw new Error(`IPC invocation failed: ${result.stderr}\n${result.stdout}`); + expect(result.code).toBe(0); + expect(result.stderr).not.toMatch(/using in-process/i); + expect(result.stdout).toContain('ipc-a-one-original'); + const ready = fixture.events().filter((event) => event.kind === 'ready'); + expect(ready).toHaveLength(1); + expect(ready[0].args).toEqual(['literal space', '"quoted"', '%NOT_EXPANDED%', '--label', 'custom raw value']); + const status = await fixture.statusAsync(); + expect(status.workspace?.warmSet?.measuredRunnerMemoryBytes).toBeGreaterThan(0); + expect(status.workspace?.warmSet?.unmeasuredRunnerCount).toBe(0); + const argv = ['build', '--only', 'a', '--label', 'custom raw value', '--ignored', 'excluded', '--verbose']; + expect((await fixture.invokeAsync(argv)).code).toBe(0); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(1); + fixture.input('a', { value: 'two' }); + await delayAsync(100); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(1); + expect((await fixture.invokeAsync(argv)).code).toBe(0); + const events = fixture.events(); + expect(events.filter((event) => event.kind === 'ready')).toHaveLength(1); + expect(events.filter((event) => event.kind === 'complete').map((event) => event.pid)).toEqual([ready[0].pid, ready[0].pid]); + expect((await fixture.statusAsync()).workspace?.generationToken).toBe(status.workspace?.generationToken); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/result.txt'), 'utf8')).toBe('two:original'); + })); + + it('retires entrypoint and local tool implementation changes without restarting on ordinary input', () => withFixtureAsync(async (fixture) => { + await fixture.buildAsync('--only', 'a'); + const first = await fixture.statusAsync(); + const oldPid = fixture.events().find((event) => event.kind === 'ready')!.pid; + fixture.write('a/tools/ipc/implementation.cjs', "exports.version = 'changed';\n"); + await fixture.buildAsync('--only', 'a'); + const second = await fixture.statusAsync(); + expect(second.pid).toBe(first.pid); + expect(second.workspace?.generationToken).not.toBe(first.workspace?.generationToken); + expect(fixture.events().some((event) => event.kind === 'closed' && event.pid === oldPid)).toBe(true); + expect(fixture.events().filter((event) => event.kind === 'ready').at(-1)?.pid).not.toBe(oldPid); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/result.txt'), 'utf8')).toBe('one:changed'); + const nextPid = fixture.events().filter((event) => event.kind === 'ready').at(-1)!.pid; + const entry = fs.readFileSync(path.join(fixture.folder, 'a/tools/ipc/entry.cjs'), 'utf8'); + fixture.write('a/tools/ipc/entry.cjs', `${entry}\n// Updated entrypoint implementation.\n`); + await fixture.buildAsync('--only', 'a'); + expect(fixture.events().some((event) => event.kind === 'closed' && event.pid === nextPid)).toBe(true); + expect(fixture.events().filter((event) => event.kind === 'ready').at(-1)?.pid).not.toBe(nextPid); + })); + + it('leaves non-opted-in builds and rebuilds on the native shell path', () => withFixtureAsync(async (fixture) => { + await fixture.buildAsync('--only', 'a'); + expect(fixture.events()).toEqual([]); + expect(fs.readFileSync(path.join(fixture.folder, 'runs.txt'), 'utf8')).toContain('a:one'); + const config = JSON.parse(fs.readFileSync(path.join(fixture.folder, 'rush.json'), 'utf8')); + config.daemon.usePersistentIpcRunners = true; + fixture.write('rush.json', JSON.stringify(config)); + await fixture.buildAsync('--only', 'a'); + const oldPid = fixture.events().find((event) => event.kind === 'ready')!.pid; + for (let index = 0; index < 2; index++) { + const result = await fixture.invokeAsync(['rebuild', '--only', 'a', '--verbose']); + expect(result.code).toBe(0); + expect(result.stderr).not.toMatch(/using in-process/i); + } + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(1); + expect(fixture.events().some((event) => event.kind === 'closed' && event.pid === oldPid)).toBe(true); + expect(fs.readFileSync(path.join(fixture.folder, 'runs.txt'), 'utf8').trim().split('\n')).toHaveLength(3); + }, { enabled: false })); +}); diff --git a/apps/rush-cli-client/src/test/persistentIpcBoundaries.test.ts b/apps/rush-cli-client/src/test/persistentIpcBoundaries.test.ts new file mode 100644 index 0000000000..3c3b424052 --- /dev/null +++ b/apps/rush-cli-client/src/test/persistentIpcBoundaries.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { createPersistentIpcTestFixture, type IPersistentIpcTestFixture } from './PersistentIpcTestFixture'; + +jest.setTimeout(60_000); + +describe('production Node descriptor boundaries through the public client', () => { + let activeFixture: IPersistentIpcTestFixture | undefined; + afterEach(async () => { + const closing = activeFixture; + activeFixture = undefined; + await closing?.closeAsync(); + }, 35_000); + + function withFixtureAsync( + callback: (fixture: IPersistentIpcTestFixture) => Promise, + options: Parameters[0] = {} + ): Promise { + const fixture = createPersistentIpcTestFixture(options); + activeFixture = fixture; + return fixture.runAsync(() => callback(fixture)); + } + + it.each(['direct', 'inherited', 'rig'] as const)('replaces a changed %s descriptor before reusing a child', (kind) => + withFixtureAsync(async (fixture) => { + await fixture.buildAsync('--only', 'a'); + const before = await fixture.statusAsync(); + const previous = fixture.events().find((event) => event.kind === 'ready')!; + const relative = kind === 'direct' ? 'a/config/rush-project.json' : kind === 'inherited' + ? 'common/temp/shared-ipc.json' + : 'a/node_modules/ipc-fixture-rig/profiles/default/config/rush-project.json'; + const config = JSON.parse(fs.readFileSync(path.join(fixture.folder, relative), 'utf8')); + config.operationSettings[0].daemonIpc.args = ['changed descriptor', '"literal"']; + fixture.write(relative, JSON.stringify(config)); + await fixture.buildAsync('--only', 'a'); + const after = await fixture.statusAsync(); + const next = fixture.events().filter((event) => event.kind === 'ready').at(-1)!; + expect(after.pid).toBe(before.pid); + expect(after.workspace?.generationToken).not.toBe(before.workspace?.generationToken); + expect(next.pid).not.toBe(previous.pid); + expect(next.args).toEqual(['changed descriptor', '"literal"']); + expect(fixture.events().some((event) => event.kind === 'closed' && event.pid === previous.pid)).toBe(true); + }, { configurationKind: kind }) + ); + + it('preserves native NoOp, missing-script errors, and empty selection without starting a tool', () => + withFixtureAsync(async (fixture) => { + const config = JSON.parse(fs.readFileSync(path.join(fixture.folder, 'a/package.json'), 'utf8')); + config.scripts['_phase:compile'] = ''; + fixture.write('a/package.json', JSON.stringify(config)); + await fixture.buildAsync('--only', 'a'); + expect(fixture.events()).toEqual([]); + const empty = await fixture.invokeAsync(['build', '--to-except', 'a', '--verbose']); + expect(empty.code).toBe(0); + expect(fixture.events()).toEqual([]); + delete config.scripts['_phase:compile']; + fixture.write('a/package.json', JSON.stringify(config)); + const missing = await fixture.invokeAsync(['build', '--only', 'a', '--verbose']); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain('does not define'); + expect(fixture.events()).toEqual([]); + }) + ); + + it('preserves preassigned native shard runners instead of applying the Node descriptor to them', () => + withFixtureAsync(async (fixture) => { + const project = JSON.parse(fs.readFileSync(path.join(fixture.folder, 'a/package.json'), 'utf8')); + project.scripts['_phase:compile:shard'] = 'node build.cjs'; + fixture.write('a/package.json', JSON.stringify(project)); + const config = JSON.parse(fs.readFileSync(path.join(fixture.folder, 'a/config/rush-project.json'), 'utf8')); + config.operationSettings[0].sharding = { count: 2 }; + config.operationSettings.push({ + operationName: '_phase:compile:shard', + daemonIpc: config.operationSettings[0].daemonIpc + }); + fixture.write('a/config/rush-project.json', JSON.stringify(config)); + await fixture.buildAsync('--only', 'a'); + expect(fixture.events()).toEqual([]); + expect(fs.readFileSync(path.join(fixture.folder, 'runs.txt'), 'utf8').trim().split('\n')).toHaveLength(3); + }) + ); + + it('honors native --no-ipc where registered without enabling graph watch mode', () => + withFixtureAsync(async (fixture) => { + const commands = JSON.parse(fs.readFileSync(path.join(fixture.folder, 'common/config/rush/command-line.json'), 'utf8')); + commands.commands[0].watchOptions = { alwaysWatch: false, watchPhases: ['_phase:compile', '_phase:empty'] }; + fixture.write('common/config/rush/command-line.json', JSON.stringify(commands)); + fixture.write('common/config/rush/experiments.json', '{"useIPCScriptsInWatchMode":true}'); + const result = await fixture.invokeAsync(['build', '--only', 'a', '--no-ipc', '--verbose']); + if (result.code !== 0) throw new Error(`Native --no-ipc failed: ${result.stderr}\n${result.stdout}`); + expect(result.code).toBe(0); + expect(fixture.events()).toEqual([]); + await fixture.buildAsync('--only', 'a'); + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(1); + }) + ); + + it('preserves complete Unicode output and warning/failure results from the real retained tool', () => + withFixtureAsync(async (fixture) => { + fixture.input('a', { value: 'output', outputBytes: 32_769, warning: true }); + const warning = await fixture.invokeAsync(['build', '--only', 'a', '--verbose']); + expect(warning.code).toBe(1); + const beginning = warning.stdout.indexOf('PAYLOAD_BEGIN:'); + const ending = warning.stdout.indexOf(':PAYLOAD_END', beginning); + expect(beginning).toBeGreaterThanOrEqual(0); + expect(ending).toBeGreaterThan(beginning); + expect(warning.stdout.slice(beginning + 'PAYLOAD_BEGIN:'.length, ending)).toBe(String.fromCodePoint(0x1f642).repeat(32_769)); + expect(warning.stderr + warning.stdout).toContain('ipc-warning'); + fixture.input('a', { value: 'failure', failure: true }); + const failed = await fixture.invokeAsync(['build', '--only', 'a', '--verbose']); + expect(failed.code).toBe(1); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(2); + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(1); + }) + ); + + it('does not replay a launched descriptor that exits without IPC readiness', () => + withFixtureAsync(async (fixture) => { + fixture.write('a/tools/ipc/entry.cjs', "require('node:fs').appendFileSync('../common/temp/no-ipc-runs', 'once\\n');\n"); + const result = await fixture.invokeAsync(['build', '--only', 'a', '--verbose']); + expect(result.code).toBe(1); + expect(result.stderr + result.stdout).toContain('without completing IPC readiness'); + expect(result.stderr).not.toContain('using in-process'); + expect(fs.readFileSync(path.join(fixture.folder, 'common/temp/no-ipc-runs'), 'utf8')).toBe('once\n'); + expect(fs.existsSync(path.join(fixture.folder, 'runs.txt'))).toBe(false); + }) + ); +}); diff --git a/apps/rush-cli-client/src/test/persistentIpcCancellation.test.ts b/apps/rush-cli-client/src/test/persistentIpcCancellation.test.ts new file mode 100644 index 0000000000..3db580f15f --- /dev/null +++ b/apps/rush-cli-client/src/test/persistentIpcCancellation.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { createPersistentIpcTestFixture, type IPersistentIpcTestFixture } from './PersistentIpcTestFixture'; + +jest.setTimeout(45_000); + +describe('public-client cancellation of an admitted Node operation', () => { + let activeFixture: IPersistentIpcTestFixture | undefined; + afterEach(async () => { + const closing = activeFixture; + activeFixture = undefined; + await closing?.closeAsync(); + }, 35_000); + + it('joins already started work and never automatically replays it after cancellation', () => { + const fixture = createPersistentIpcTestFixture(); + activeFixture = fixture; + return fixture.runAsync(async () => { + await fixture.buildAsync('--only', 'a'); + fixture.input('a', { value: 'cancelled-client', delayMs: 1000 }); + const entry = path.resolve(__dirname, + process.platform === 'win32' ? 'CliSignalTestProcess.js' : '../../bin/rush-client'); + const client = spawn(process.execPath, [entry, 'build', '--only', 'a', '--parallelism', '2', '--verbose'], { + cwd: fixture.folder, + env: fixture.environment, + stdio: process.platform === 'win32' ? ['ignore', 'pipe', 'pipe', 'ipc'] : ['ignore', 'pipe', 'pipe'] + }); + const closed = once(client, 'close'); + fixture.trackWatch(client, closed); + let stderr = ''; + client.stdout!.resume(); + client.stderr!.setEncoding('utf8'); + client.stderr!.on('data', (text: string) => { stderr += text; }); + try { + const deadline = Date.now() + 10_000; + while (!fixture.events().some((event) => event.kind === 'started' && event.iteration === 2)) { + if (Date.now() >= deadline || client.exitCode !== null || client.signalCode !== null) { + throw new Error(`The cancellation target did not start: ${stderr}`); + } + await delayAsync(10); + } + if (process.platform === 'win32') client.send('SIGINT'); + else client.kill('SIGINT'); + // Phased cancellation preserves the daemon's existing aborted-result exit code. + expect(await closed).toEqual([1, null]); + expect(stderr).not.toMatch(/using in-process|not retried|timed out/i); + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(1); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(2); + await fixture.buildAsync('--only', 'a'); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(2); + } finally { + if (client.exitCode === null && client.signalCode === null) client.kill('SIGTERM'); + await closed; + } + }); + }); +}); diff --git a/apps/rush-cli-client/src/test/persistentIpcRetention.test.ts b/apps/rush-cli-client/src/test/persistentIpcRetention.test.ts new file mode 100644 index 0000000000..cf23c15243 --- /dev/null +++ b/apps/rush-cli-client/src/test/persistentIpcRetention.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { setTimeout as delayAsync } from 'node:timers/promises'; +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DaemonClient } from '@rushstack/rush-client-core'; +import type { IDaemonWarmProjectRank, IDaemonWarmSetStatus } from '@rushstack/rush-daemon-protocol'; + +import { + createPersistentIpcTestFixture, + type IIpcEvent, + type IPersistentIpcTestFixture +} from './PersistentIpcTestFixture'; +import { getPressureAllocationBytes, type IPressureMemorySample } from './PersistentIpcPressure'; +import { writeIpcFixtureFile } from './IpcFixtureFile'; + +const BUDGET_MB: number = 512; +const BYTES_PER_MB: number = 1024 * 1024; +const PRESSURE_WORK_MARGIN_MS: number = 100; +const PROJECTS = ['a', 'b'] as const; +type ProjectName = typeof PROJECTS[number]; +const MEMORY_BYTES: Readonly> = { + a: 64 * BYTES_PER_MB, + b: 16 * BYTES_PER_MB +}; +jest.setTimeout(60_000); + +interface IMeasuredRank extends IDaemonWarmProjectRank { + readonly timeSavedMs: number; + readonly measuredRunnerMemoryBytes: number; +} + +function measuredRank(warm: IDaemonWarmSetStatus, name: ProjectName): IMeasuredRank { + const rank = warm.projectRanks?.find((project) => project.projectName === name); + if (!rank || rank.timeSavedMs === undefined || !rank.measuredRunnerMemoryBytes) { + throw new Error(`Missing real timing/RSS measurements for ${name}: ${JSON.stringify(warm)}`); + } + return { ...rank, timeSavedMs: rank.timeSavedMs, measuredRunnerMemoryBytes: rank.measuredRunnerMemoryBytes }; +} + +function score(rank: IMeasuredRank): number { + return rank.timeSavedMs * rank.frequency / rank.measuredRunnerMemoryBytes; +} + +describe('measured production IPC retention through the public client', () => { + let activeFixture: IPersistentIpcTestFixture | undefined; + afterEach(async () => { + const closing = activeFixture; + activeFixture = undefined; + await closing?.closeAsync(); + }, 35_000); + + it.each([ + [false, 'a'], [true, 'a'], [false, 'b'], [true, 'b'] + ] as const)('uses actual score versus opposite recency under real memory pressure (telemetry: %s, cold work: %s)', (telemetry, coldWork) => { + const fixture = createPersistentIpcTestFixture({ telemetry, budgetMB: BUDGET_MB }); + activeFixture = fixture; + return fixture.runAsync(async () => { + const started = await fixture.invokeAsync(['daemon', 'start']); + expect(started.code).toBe(0); + expect(JSON.parse(started.stdout).workspace.graphInitialized).toBe(false); + expect(fixture.events()).toEqual([]); + const coldInvocationMs: Record = { a: 0, b: 0 }; + for (const name of PROJECTS) { + fixture.input(name, { + value: 'cold', + memoryBytes: MEMORY_BYTES[name], + retainMemory: true, + coldDelayMs: name === coldWork ? 800 : 50 + }); + const startedAt = performance.now(); + await fixture.buildAsync('--only', name); + coldInvocationMs[name] = performance.now() - startedAt; + } + for (const name of PROJECTS) { + fixture.input(name, { value: 'warm', memoryBytes: MEMORY_BYTES[name], retainMemory: true }); + await fixture.buildAsync('--only', name); + } + const initial = await fixture.statusAsync(); + if (!initial.workspace?.warmSet) throw new Error(`Missing warm status: ${JSON.stringify(initial)}`); + if (fixture.events().filter((event) => event.kind === 'complete').length !== 4) { + throw new Error(`Expected four real setup executions: ${JSON.stringify({ initial, events: fixture.events() })}`); + } + const initialA = measuredRank(initial.workspace.warmSet, 'a'); + const initialB = measuredRank(initial.workspace.warmSet, 'b'); + expect(initialA.frequency).toBe(2); + expect(initialB.frequency).toBe(2); + const higherValue: ProjectName = score(initialA) >= score(initialB) ? 'a' : 'b'; + const newer: ProjectName = higherValue === 'a' ? 'b' : 'a'; + const initialHigh = higherValue === 'a' ? initialA : initialB; + const initialLow = newer === 'a' ? initialA : initialB; + expect(score(initialHigh)).toBeGreaterThan(0); + + // Compute the entire bounded request plan from the real measurements, never retry until an assertion passes. + const cacheHitRequests: ProjectName[] = []; + if (initialHigh.lastUsed >= initialLow.lastUsed || score(initialHigh) === score(initialLow)) { + const lowScoreAfterHit = initialLow.timeSavedMs * (initialLow.frequency + 1) / + initialLow.measuredRunnerMemoryBytes; + const highScorePerHit = initialHigh.timeSavedMs / initialHigh.measuredRunnerMemoryBytes; + const requiredHighFrequency = Math.floor(lowScoreAfterHit / highScorePerHit) + 1; + const highHits = Math.max(0, requiredHighFrequency - initialHigh.frequency); + expect(highHits).toBeLessThanOrEqual(2); + cacheHitRequests.push(...Array(highHits).fill(higherValue), newer); + } + expect(cacheHitRequests.length).toBeLessThanOrEqual(3); + for (const name of cacheHitRequests) await fixture.buildAsync('--only', name); + const before = await fixture.statusAsync(); + const warm = before.workspace?.warmSet; + if (!warm) throw new Error(`Missing warm status: ${JSON.stringify(before)}`); + const a = measuredRank(warm, 'a'); + const b = measuredRank(warm, 'b'); + const high = higherValue === 'a' ? a : b; + const low = newer === 'a' ? a : b; + expect(high.frequency).toBe(initialHigh.frequency + cacheHitRequests.filter((name) => name === higherValue).length); + expect(low.frequency).toBe(initialLow.frequency + cacheHitRequests.filter((name) => name === newer).length); + expect(high.timeSavedMs).toBeGreaterThan(0); + expect(low.timeSavedMs).toBeGreaterThanOrEqual(0); + expect(score(high)).toBeGreaterThan(score(low)); + expect(low.lastUsed).toBeGreaterThan(high.lastUsed); + expect(warm.retainedProjectNames).toEqual(telemetry ? [higherValue, newer] : [newer, higherValue]); + expect(before.workspace?.generationToken).toBe(initial.workspace.generationToken); + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(2); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(4); + for (const [original, current] of [[initialA, a], [initialB, b]]) { + expect(current.timeSavedMs).toBe(original.timeSavedMs); + expect(current.measuredRunnerMemoryBytes).toBe(original.measuredRunnerMemoryBytes); + } + expect(warm.overMemoryBudget).toBe(false); + for (const rank of [a, b]) { + const sample = fixture.events().filter((event) => event.project === rank.projectName && event.kind === 'complete').at(-1); + expect(rank.measuredRunnerMemoryBytes).toBe(sample?.residentMemoryBytes); + } + + // A cold public invocation contains the complete native cold operation. Real pressure work lasting + // longer than that upper bound must have zero savings under the unchanged production formula. + const pressureDelayMs = Math.ceil(coldInvocationMs[newer]) + PRESSURE_WORK_MARGIN_MS; + const pressureGate = path.join(fixture.folder, `common/temp/pressure-gate-${randomUUID()}.json`); + fixture.input(newer, { + value: 'pressure', + memoryBytes: MEMORY_BYTES[newer], + retainMemory: true, + pressureGate, + delayMs: pressureDelayMs + }); + let allocation: number; + let pressureAdjustment: number; + let pressureAccounting: IDaemonWarmSetStatus | undefined; + let verifiedPressure: IPressureMemorySample | undefined; + let requestEnded = false; + const request = fixture.buildAsync('--only', newer); + void request.finally(() => { requestEnded = true; }).catch(() => undefined); + try { + const readyDeadline = Date.now() + pressureDelayMs + 15_000; + const waitForPressureSampleAsync = async ( + kind: 'pressure-ready' | 'pressure-allocated' | 'pressure-adjusted' + ): Promise => { + let sample: IIpcEvent | undefined; + while (!(sample = fixture.events().find((event) => + event.project === newer && event.kind === kind && event.pressureGate === pressureGate + ))) { + if (requestEnded) { + await request; + throw new Error(`The real pressure operation completed before ${kind}.`); + } + if (Date.now() >= readyDeadline) throw new Error(`The real pressure operation did not reach ${kind}.`); + await delayAsync(10); + } + if (!sample.residentMemoryBytes) throw new Error(`Missing real child RSS at ${kind}.`); + return { ...sample, residentMemoryBytes: sample.residentMemoryBytes }; + }; + const readPressureAccountingAsync = async (): Promise => { + const client = await DaemonClient.connectAsync({ socketPath: fixture.paths.socketPath }); + try { + const status = (await client.status).workspace?.warmSet; + if (!status) throw new Error('Missing live pressure accounting.'); + return status; + } finally { + await client.closeAsync(); + } + }; + const ready = await waitForPressureSampleAsync('pressure-ready'); + pressureAccounting = await readPressureAccountingAsync(); + expect([...pressureAccounting.retainedProjectNames].sort()).toEqual(['a', 'b']); + const sampleMemory = (daemon: IDaemonWarmSetStatus, child: number): IPressureMemorySample => ({ + budgetBytes: BUDGET_MB * BYTES_PER_MB, + daemonBytes: daemon.daemonResidentMemoryBytes, + pressureRunnerBytes: child, + otherRunnerBytes: high.measuredRunnerMemoryBytes + }); + allocation = getPressureAllocationBytes(sampleMemory(pressureAccounting, ready.residentMemoryBytes)); + expect(allocation).toBeGreaterThan(16 * BYTES_PER_MB); + writeIpcFixtureFile(pressureGate, JSON.stringify({ additionalMemoryBytes: allocation })); + + // A real daemon working-set drop can invalidate the first sizing sample. Recalibrate once, + // then verify actual pressure while this request still owns admission, before releasing it. + const allocated = await waitForPressureSampleAsync('pressure-allocated'); + pressureAdjustment = getPressureAllocationBytes(sampleMemory( + await readPressureAccountingAsync(), allocated.residentMemoryBytes + )); + writeIpcFixtureFile(`${pressureGate}.adjust`, JSON.stringify({ additionalMemoryBytes: pressureAdjustment })); + const adjusted = await waitForPressureSampleAsync('pressure-adjusted'); + verifiedPressure = sampleMemory(await readPressureAccountingAsync(), adjusted.residentMemoryBytes); + const retainedBytes: number = verifiedPressure.daemonBytes + verifiedPressure.pressureRunnerBytes; + expect(retainedBytes + verifiedPressure.otherRunnerBytes).toBeGreaterThan(verifiedPressure.budgetBytes); + expect(retainedBytes).toBeLessThan(verifiedPressure.budgetBytes); + writeIpcFixtureFile(`${pressureGate}.release`, '{}'); + await request; + } finally { + try { + for (const gate of [pressureGate, `${pressureGate}.adjust`, `${pressureGate}.release`]) { + if (!fs.existsSync(gate)) writeIpcFixtureFile(gate, '{"cancelled":true}'); + } + } finally { + await Promise.allSettled([request]); + } + } + const pressure = fixture.events().filter((event) => event.project === newer && event.kind === 'complete').at(-1); + expect(pressure?.durationMs).toBeGreaterThanOrEqual(coldInvocationMs[newer]); + expect(pressure?.residentMemoryBytes).toBeGreaterThan(low.measuredRunnerMemoryBytes); + const keep = telemetry ? higherValue : newer; + const evict = telemetry ? newer : higherValue; + const deadline = Date.now() + 10_000; + let after = await fixture.statusAsync(); + while (after.workspace?.warmSet?.retainedProjectNames.length !== 1 && Date.now() < deadline) { + await delayAsync(50); + after = await fixture.statusAsync(); + } + expect(after.workspace?.warmSet?.retainedProjectNames).toEqual([keep]); + expect(after.workspace?.warmSet?.watchedProjectNames).toEqual([keep]); + expect(after.workspace?.warmSet?.overMemoryBudget).toBe(false); + expect(after.workspace?.generationToken).toBe(before.workspace?.generationToken); + if (!after.workspace?.warmSet) throw new Error(`Missing final warm status: ${JSON.stringify(after)}`); + const retainedRank = measuredRank(after.workspace.warmSet, keep); + expect(retainedRank.timeSavedMs).toBe(telemetry ? high.timeSavedMs : 0); + expect(retainedRank.frequency).toBe(telemetry ? high.frequency : low.frequency + 1); + const evictedPid = fixture.events().find((event) => event.project === evict && event.kind === 'ready')!.pid; + expect(fixture.events().some((event) => event.kind === 'closed' && event.pid === evictedPid)).toBe(true); + expect(fixture.events().filter((event) => event.kind === 'ready')).toHaveLength(2); + expect(fixture.events().filter((event) => event.kind === 'complete')).toHaveLength(5); + const evidenceFile = process.env.RUSHD_P2_EVIDENCE; + if (evidenceFile) { + fs.appendFileSync(evidenceFile, JSON.stringify({ + platform: process.platform, + nodeVersion: process.version, + telemetry, + coldWork, + daemonPid: before.pid, + initialRankingInputs: [initialA, initialB], + rankingInputs: [a, b], + independentlyCalculatedScores: { a: score(a), b: score(b) }, + higherValue, + newer, + cacheHitRequests, + coldInvocationMs, + pressureDelayMs, + pressure, + allocation, + pressureAdjustment, + pressureAccounting, + verifiedPressure, + retained: after.workspace?.warmSet?.retainedProjectNames, + watched: after.workspace?.warmSet?.watchedProjectNames, + events: fixture.events() + }) + '\n'); + } + }); + }); +}); diff --git a/apps/rush-cli-client/src/test/pipedInput.test.ts b/apps/rush-cli-client/src/test/pipedInput.test.ts new file mode 100644 index 0000000000..1698ef0683 --- /dev/null +++ b/apps/rush-cli-client/src/test/pipedInput.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { captureDaemonRequest, DaemonClient } from '@rushstack/rush-client-core'; +import { + DaemonRequestDispatchError, + RushDaemonHost, + type GlobalCommandExecutor, + type IDaemonRequestResolver +} from '@rushstack/rush-daemon'; + +const SCRIPT: string = + "const chunks=[];process.stdin.on('data',c=>{chunks.push(c);process.stdin.pause();" + + "setTimeout(()=>process.stdin.resume(),1);});" + + "process.stdin.on('end',()=>{process.stdout.write(Buffer.concat(chunks));" + + "process.stderr.write('pipe-ended');process.exitCode=7;});"; + +interface IPipedResult { + readonly code: number | undefined; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +describe('standalone client piped input', () => { + let folder: string; + let project: string; + let host: RushDaemonHost | undefined; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-cli-input-')); + project = path.join(folder, 'project'); + fs.mkdirSync(project); + fs.mkdirSync(path.join(folder, 'common/config/rush'), { recursive: true }); + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: '10.27.0', + daemon: { enabled: false, autoStart: false }, + projects: [{ packageName: 'sample', projectFolder: 'project' }], + projectFolderMinDepth: 1 + })); + fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ + name: 'sample', + version: '1.0.0', + scripts: { sample: `node -e "${SCRIPT}"` } + })); + }); + + afterEach(async () => { + const closingHost: RushDaemonHost | undefined = host; + host = undefined; + await closingHost?.closeAsync(); + fs.rmSync(folder, { recursive: true }); + }); + + async function startHostAsync(requestResolver: IDaemonRequestResolver): Promise { + const daemonPackage: { version: string } = require('@rushstack/rush-daemon/package.json'); + host = await RushDaemonHost.startAsync({ + repoRoot: folder, + rushVersion: Rush.version, + daemonVersion: daemonPackage.version, + requestResolver + }); + } + + async function invokeAsync( + input: Buffer, + useClient: boolean = true, + admissionArgs: ReadonlyArray = [] + ): Promise { + const entry: string = useClient + ? path.resolve(__dirname, '../../bin/rushx-client') + : path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rushx'); + const child = spawn(process.execPath, [entry, 'sample', ...admissionArgs], { + cwd: project, + env: { + ...process.env, RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy', RUSH_QUIET_MODE: '1', + CI: 'false', TF_BUILD: 'false', GITHUB_ACTIONS: 'false' + }, + stdio: 'pipe' + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on('data', (bytes: Buffer) => stdout.push(bytes)); + child.stderr.on('data', (bytes: Buffer) => stderr.push(bytes)); + child.stdin.end(input); + const [code] = await once(child, 'close'); + return { + code: typeof code === 'number' ? code : undefined, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr) + }; + } + + it.each([Buffer.from([0, 3, 255, 13]), Buffer.alloc(0), Buffer.alloc(2 * 1024 * 1024, 3)])( + 'delivers pipe bytes and EOF through the real host without treating Ctrl+C bytes as signals', + async (input) => { + const executorAsync: GlobalCommandExecutor = async (context) => { + const child = context.spawnChild(process.execPath, ['-e', SCRIPT], { forwardInput: true }); + const [code] = await once(child, 'close'); + return { exitCode: typeof code === 'number' ? code : 1 }; + }; + await startHostAsync({ + resolveRequestAsync: async () => ({ + kind: 'global', + executor: executorAsync + }) + }); + const result: IPipedResult = await invokeAsync(input); + expect(result.code).toBe(7); + expect(result.stdout.equals(input)).toBe(true); + expect(result.stderr.toString()).toBe('pipe-ended'); + }, + 15000 + ); + + it.each([ + { args: ['--no-wait'], admission: { noWait: true }, reason: 'no-wait' }, + { args: ['--wait-timeout=0.01'], admission: { waitTimeoutMs: 10 }, reason: 'wait-timeout' } + ])('forwards $args without leaking queue flags to scripts', async ({ args, admission, reason }) => { + let started: () => void = () => {}; + let release: () => void = () => {}; + const running: Promise = new Promise((resolve) => { started = resolve; }); + const released: Promise = new Promise((resolve) => { release = resolve; }); + const runScriptAsync = jest.fn(async () => ({ exitCode: 0 })); + const holdAsync: GlobalCommandExecutor = async () => { + started(); + await released; + return { exitCode: 0 }; + }; + await startHostAsync({ + resolveRequestAsync: async ({ envelope }) => { + if (envelope.commandName === 'hold') return { kind: 'global', executor: holdAsync }; + expect(envelope.argv).toEqual(['sample']); + expect(envelope.admission).toEqual(admission); + return { kind: 'global', executor: runScriptAsync }; + } + }); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: host!.paths.socketPath }); + const holding = client.executeAsync({ + request: captureDaemonRequest({ + argv: ['hold'], commandName: 'hold', commandOrigin: 'custom', cwd: project, + environment: {}, terminal: { isTTY: false, supportsColor: false } + }) + }); + try { + await Promise.race([ + running, + holding.then(() => { throw new Error('The holding request finished before acquiring admission.'); }) + ]); + const result: IPipedResult = await invokeAsync(Buffer.alloc(0), true, args); + expect(result.code).toBe(1); + expect(result.stderr.toString()).toContain(`daemon admission failed (${reason})`); + expect(runScriptAsync).not.toHaveBeenCalled(); + } finally { + release(); + await holding; + await client.closeAsync(); + } + }, 15000); + + it('preserves every byte for native fallback when the host rejects before execution', async () => { + await startHostAsync({ + resolveRequestAsync: async () => { + throw new DaemonRequestDispatchError('unsupported', 'No command integration in this test.'); + } + }); + const input: Buffer = Buffer.from('untouched stdin\n'); + const native: IPipedResult = await invokeAsync(input, false); + const client: IPipedResult = await invokeAsync(input); + expect(client.code).toBe(native.code); + expect(client.stdout).toEqual(native.stdout); + expect(client.stderr.toString()).toContain('using in-process Rush'); + expect(client.stderr.toString()).toContain('pipe-ended'); + }, 15000); +}); diff --git a/apps/rush-cli-client/src/test/routing.test.ts b/apps/rush-cli-client/src/test/routing.test.ts new file mode 100644 index 0000000000..b71a98a4e6 --- /dev/null +++ b/apps/rush-cli-client/src/test/routing.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { selectClientRoute } from '../routing'; + +describe('opt-in routing', () => { + it.each([ + { argv: ['build'], enabled: false, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: {}, daemon: true }, + { argv: ['build', '--no-daemon'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { CI: '1' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { CI: '1', RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['build'], enabled: true, environment: { CI: 'false' }, daemon: true }, + { argv: ['build', '--reporter=json'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--output', 'build.log'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--log-level=debug'], enabled: true, environment: {}, daemon: false }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'json' }, daemon: false }, + { argv: ['build'], enabled: true, environment: { RUSH_REPORTER: 'legacy' }, daemon: true }, + { argv: ['install'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['update'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: true }, + { argv: ['install', '--no-daemon'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['install'], enabled: true, environment: { CI: '1' }, daemon: false }, + { argv: ['publish'], enabled: true, environment: { RUSH_DAEMON: '1' }, daemon: false }, + { argv: ['daemon', 'status'], enabled: true, environment: {}, daemon: false }, + { argv: ['--help'], enabled: true, environment: {}, daemon: false }, + { argv: ['build', '--help'], enabled: true, environment: {}, daemon: false } + ])('selects $daemon for $argv', ({ daemon, ...options }) => { + expect(selectClientRoute({ ...options, rushx: false }).daemon).toBe(daemon); + }); + + it('preserves script arguments after -- and permits scripts named like built-ins', () => { + expect( + selectClientRoute({ + argv: ['install', '--', '--no-daemon'], + enabled: true, + environment: {}, + rushx: true + }) + ).toEqual({ argv: ['install', '--', '--no-daemon'], commandName: 'install', daemon: true }); + }); + + it('uses native Rushx option boundaries instead of treating script flags as Rush options', () => { + const argv: string[] = ['-q', '-d', '--ignore-hooks', 'build', '--help', '--reporter=json', '--', '-h']; + expect(selectClientRoute({ argv, enabled: true, environment: {}, rushx: true })).toMatchObject({ + argv, + commandName: 'build', + daemon: true + }); + + expect( + selectClientRoute({ + argv: ['--unknown', 'build'], + enabled: true, + environment: {}, + rushx: true + }).daemon + ).toBe(false); + }); + + it('keeps unknown interactive Rushx scripts native before daemon connection or input admission', () => { + expect( + selectClientRoute({ + argv: ['script'], + enabled: true, + environment: { RUSH_DAEMON: '1' }, + rushx: true, + hasTerminal: true + }).daemon + ).toBe(false); + expect( + selectClientRoute({ + argv: ['script'], + enabled: true, + environment: { RUSH_DAEMON: '1' }, + rushx: true, + hasTerminal: false + }).daemon + ).toBe(true); + expect( + selectClientRoute({ + argv: ['build'], + enabled: true, + environment: { RUSH_DAEMON: '1' }, + rushx: false, + hasTerminal: true + }).daemon + ).toBe(true); + }); +}); diff --git a/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts b/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts new file mode 100644 index 0000000000..cb29decaa6 --- /dev/null +++ b/apps/rush-cli-client/src/test/versionSelectionFallback.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@microsoft/rush/lib/start', () => ({})); + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; + +import * as connectionOptions from '../daemonConnectionOptions'; +import { launchClientAsync } from '../launchClient'; + +it('uses the native version selector when no compatible foreign daemon launcher exists', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-version-fallback-')); + const originalArgv: string[] = process.argv; + const originalEnvironment: NodeJS.ProcessEnv = process.env; + fs.writeFileSync(path.join(folder, 'rush.json'), JSON.stringify({ + rushVersion: '5.178.1', pnpmVersion: '10.27.0', projects: [] + })); + const selection = jest.spyOn(connectionOptions, 'getDaemonConnectionOptionsAsync').mockRejectedValue( + new DaemonLauncherUnavailableError('5.178.1', 'No compatible request-launch APIs.') + ); + const output = jest.spyOn(process.stderr, 'write').mockReturnValue(true); + jest.spyOn(process, 'cwd').mockReturnValue(folder); + process.argv = [process.execPath, 'rush-client', 'build', '--to', 'project']; + process.env = { ...originalEnvironment, CI: 'false', RUSH_DAEMON: '1', RUSH_REPORTER: 'legacy' }; + delete process.env.RUSH_PREVIEW_VERSION; + try { + await launchClientAsync(false); + expect(process.argv[1]).toBe(path.join(path.dirname(require.resolve('@microsoft/rush/package.json')), 'bin/rush')); + expect(selection).toHaveBeenCalledWith(folder, '5.178.1', expect.objectContaining({ RUSH_DAEMON: '1' }), true); + expect(Object.isFrozen(selection.mock.calls[0][2])).toBe(true); + expect(process.argv.slice(2)).toEqual(['build', '--to', 'project']); + expect(process.env.RUSH_DAEMON).toBeUndefined(); + expect(output).toHaveBeenCalledWith(expect.stringContaining('Using in-process Rush')); + } finally { + process.argv = originalArgv; + process.env = originalEnvironment; + jest.restoreAllMocks(); + fs.rmSync(folder, { recursive: true }); + } +}); diff --git a/apps/rush-cli-client/src/writeStreamAsync.ts b/apps/rush-cli-client/src/writeStreamAsync.ts new file mode 100644 index 0000000000..94f7cd3eeb --- /dev/null +++ b/apps/rush-cli-client/src/writeStreamAsync.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Writable } from 'node:stream'; + +export function writeStreamAsync(destination: Writable, bytes: Uint8Array): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error); + destination.once('error', onError); + destination.write(bytes, (error?: Error | null) => { + if (error) { + reject(error); + } else { + destination.removeListener('error', onError); + resolve(); + } + }); + }); +} diff --git a/apps/rush-cli-client/tsconfig.json b/apps/rush-cli-client/tsconfig.json new file mode 100644 index 0000000000..dac21d0408 --- /dev/null +++ b/apps/rush-cli-client/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 603f1863bb..42e506ccaa 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -15,4 +15,13 @@ import type { ILaunchOptions, IRushSessionReporterOptions } from '@microsoft/rus export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporter: IRushSessionReporterOptions; readonly reporterCloseAsync: () => Promise; + readonly reporterEnabled: boolean; + readonly reporterStdoutIsMachineReadable?: boolean; + readonly reporterStdoutIsReserved?: boolean; + readonly reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } diff --git a/apps/rush/src/MinimalRushConfiguration.ts b/apps/rush/src/MinimalRushConfiguration.ts index 1ad16898ae..4ef953fca9 100644 --- a/apps/rush/src/MinimalRushConfiguration.ts +++ b/apps/rush/src/MinimalRushConfiguration.ts @@ -55,7 +55,9 @@ export class MinimalRushConfiguration { this.#useRushReporter = experimentsConfiguration?.useRushReporter === true; } - public static loadFromDefaultLocation(): MinimalRushConfiguration | undefined { + public static loadFromDefaultLocation( + writeLine: (message: string) => void = (message) => console.log(message) + ): MinimalRushConfiguration | undefined { const showVerbose: boolean = !RushCommandLineParser.shouldRestrictConsoleOutput(); const rushJsonLocation: string | undefined = RushConfiguration.tryFindRushJsonLocation({ showVerbose: false @@ -76,13 +78,13 @@ export class MinimalRushConfiguration { const effectiveRushVersion: string = getRushPreviewVersion() ?? configuration.rushVersion; legacyPresentation = legacyFallbackRequested || - effectiveRushVersion !== currentPackageVersion || + (effectiveRushVersion !== currentPackageVersion && explicitReporter === undefined) || (!configuration.useRushReporter && explicitReporter === undefined); } if (showVerbose && legacyPresentation) { // Preserve discovery even when the full engine must report a configuration load error. - console.log('Found configuration in ' + rushJsonLocation); - console.log(''); + writeLine('Found configuration in ' + rushJsonLocation); + writeLine(''); } return configuration; } else { diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 8d29eac6af..2db3426593 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -2,6 +2,15 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +import { + LegacyFallbackSink, + OldEngineOutputAdapter, + REPORTER_PROTOCOL_VERSION, + resolveReporterCompatibility, + type IReporterCompatibilityDecision +} from '@rushstack/rush-reporter'; import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; @@ -37,32 +46,170 @@ export class RushCommandSelector { } const commandName: CommandName = _getCommandName(); - - if (commandName === 'rush-pnpm') { - if (!Rush.launchRushPnpm) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rush-pnpm" command` - ); + const engineProtocolMajor: number | undefined = ( + Rush as typeof Rush & { readonly _reporterProtocolMajor?: number } + )._reporterProtocolMajor; + const compatibility: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: REPORTER_PROTOCOL_VERSION.major, hasManager: true }, + { + supportsStructuredSink: engineProtocolMajor !== undefined, + protocolMajor: engineProtocolMajor } - Rush.launchRushPnpm(launcherVersion, { - isManaged: options.isManaged, - alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError - }); - } else if (commandName === 'rushx') { - if (!Rush.launchRushX) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rushx" command` + ); + let effectiveOptions: IRushFrontendLaunchOptions = options; + let restoreEngineOutput: (() => void) | undefined; + if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { + if (options.reporterSelectionReason === 'explicit --reporter') { + throw new Error( + `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + + `frontend supports major ${REPORTER_PROTOCOL_VERSION.major}. Update global Rush or use ` + + '--reporter=legacy.' ); } - Rush.launchRushX(launcherVersion, options); - } else { - Rush.launch(launcherVersion, options); + effectiveOptions = { + ...options, + reporter: { + ...options.reporter, + eventSink: new LegacyFallbackSink() + }, + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }; + } else if (options.reporterEnabled) { + restoreEngineOutput = _observeEngineOutput(options, Rush.version); + effectiveOptions = { + ...options, + reporterCloseAsync: async () => { + restoreEngineOutput?.(); + await options.reporterCloseAsync(); + } + }; + } + + try { + if (commandName === 'rush-pnpm') { + if (!Rush.launchRushPnpm) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rush-pnpm" command` + ); + } + Rush.launchRushPnpm(launcherVersion, { + isManaged: options.isManaged, + alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError + }); + } else if (commandName === 'rushx') { + if (!Rush.launchRushX) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rushx" command` + ); + } + Rush.launchRushX(launcherVersion, effectiveOptions); + } else { + Rush.launch(launcherVersion, effectiveOptions); + } + } catch (error) { + restoreEngineOutput?.(); + throw error; } } } +function _observeEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): () => void { + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: options.reporter.eventSink, + sessionId: options.reporter.sessionId, + source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } + }); + const restoreStdout: () => void = _observeStream( + process.stdout, + 'stdout', + adapter, + process.stdout.write.bind(process.stdout), + (options.reporterStdoutIsReserved ?? options.reporterStdoutIsMachineReadable) !== true + ); + const restoreStderr: () => void = _observeStream( + process.stderr, + 'stderr', + adapter, + process.stderr.write.bind(process.stderr), + true + ); + let restored: boolean = false; + const restore: () => void = () => { + if (restored) { + return; + } + restored = true; + process.removeListener('beforeExit', restore); + process.removeListener('exit', restore); + restoreStdout(); + restoreStderr(); + }; + process.once('beforeExit', restore); + process.once('exit', restore); + return restore; +} + +function _observeStream( + stream: NodeJS.WriteStream, + streamName: 'stdout' | 'stderr', + adapter: OldEngineOutputAdapter, + legacyWrite: typeof process.stdout.write, + renderLive: boolean +): () => void { + const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); + const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + if (markedStream[marker]) { + return () => {}; + } + markedStream[marker] = true; + + let captureInProgress: boolean = false; + const decoder: StringDecoder = new StringDecoder('utf8'); + const originalWrite: typeof stream.write = stream.write; + stream.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ): boolean => { + const text: string = + typeof chunk === 'string' + ? chunk + : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + if (text && !captureInProgress) { + captureInProgress = true; + try { + adapter.capture(streamName, text, renderLive); + } finally { + captureInProgress = false; + } + } + if (!renderLive) { + const writeCallback: ((error?: Error | null) => void) | undefined = + typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + if (writeCallback) { + process.nextTick(writeCallback); + } + return true; + } + if (typeof encodingOrCallback === 'function') { + return legacyWrite(chunk, encodingOrCallback); + } + return legacyWrite(chunk, encodingOrCallback, callback); + }) as typeof stream.write; + return () => { + const remaining: string = decoder.end(); + if (remaining) { + adapter.capture(streamName, remaining, renderLive); + } + stream.write = originalWrite; + delete markedStream[marker]; + }; +} + function _failWithError(message: string): never { throw new Error(message); } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 75de36faf7..eb8b45b8b9 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'; import type { ILaunchOptions } from '@microsoft/rush-lib'; -import { DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; +import { + DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS, + REPORTER_PROTOCOL_VERSION, + resolveColorEnabled +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -164,6 +168,12 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr const reporterCloseAsync: () => Promise = () => reporterLifecycle?.closeAsync() ?? reporterHost.closeAsync(); const sessionId: string = createSessionId(); + const requestId: string = sessionId; + const stdoutColumns: number | undefined = process.stdout.columns; + const terminalWidth: number = + stdoutColumns !== undefined && Number.isSafeInteger(stdoutColumns) && stdoutColumns > 0 + ? stdoutColumns + : 80; if (reporterHost.selection.enabled && reporterHost.logArtifact?.path) { reporterHost.sink.emit({ protocolVersion: REPORTER_PROTOCOL_VERSION, @@ -179,14 +189,44 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr } }); } + const hasReporterStdoutOutput: boolean = reporterHost.selection.outputs.some( + (output) => output.target === 'stdout' + ); const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporter: { eventSink: reporterHost.sink, sessionId, - operationStreamEnabled: reporterHost.selection.enabled + operationStreamEnabled: reporterHost.selection.enabled, + childProcessReporter: reporterHost.selection.enabled + ? { + requestId, + context: { + reporter: reporterHost.selection.reporter, + logLevel: reporterHost.selection.logLevel, + color: + reporterHost.selection.reporter === 'default' + ? resolveColorEnabled(process.env, process.stdout.isTTY === true) + : false, + terminalWidth + }, + ingestForeignEnvelope: (envelope) => reporterHost.host.manager.ingestForeignEnvelope(envelope) + } + : undefined }, - reporterCloseAsync + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterStdoutIsMachineReadable: + reporterHost.selection.reporter === 'ai' || + reporterHost.selection.reporter === 'json' || + hasReporterStdoutOutput, + reporterStdoutIsReserved: + hasReporterStdoutOutput || + (!reporterHost.selection.commandJson && + (reporterHost.selection.reporter === 'ai' || + reporterHost.selection.reporter === 'json' || + reporterHost.selection.reporter === 'file')), + reporterSelectionReason: reporterHost.selection.reason }; try { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 954e87e786..df31e76a57 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -28,7 +28,11 @@ import { type ReporterEventType, type ReporterLogLevel, type ReporterName, - type ReporterManager + type ReporterManager, + type IBootstrapReplayResult, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '@rushstack/rush-reporter'; import { @@ -56,6 +60,9 @@ export interface IRushReporterHostOptions { readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; readonly manager?: ReporterManager; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; } export interface IRushReporterSelection { @@ -68,7 +75,11 @@ export interface IRushReporterSelection { readonly reporterValueFlagsToStrip: readonly string[]; readonly reporterFlagsToStrip?: readonly string[]; readonly reason: - 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' | 'pre-major legacy default'; + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } export interface IInitializedRushReporterHost { @@ -77,6 +88,8 @@ export interface IInitializedRushReporterHost { readonly selection: IRushReporterSelection; readonly logArtifact: IFileReporterArtifact | undefined; closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -143,6 +156,40 @@ class LogLevelReporter implements IReporter { } } +class VisibleBootstrapOutputFilterReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + + public constructor(reporter: IReporter) { + this._reporter = reporter; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + const payload: { readonly wasRendered?: unknown } | undefined = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as { readonly wasRendered?: unknown }) + : undefined; + if (event.type === 'externalOutput' && payload?.wasRendered === true) { + return; + } + this._reporter.report(event); + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + class ExplicitOutputReporter implements IReporter { public readonly name: string; @@ -473,12 +520,20 @@ function hasHelpControl(argv: readonly string[]): boolean { return false; } -function getImplicitHelpValueFlagsToStrip( - argv: readonly string[], - ownership: IReporterCommandLineOwnership, - actionName: string | undefined -): readonly string[] { - if (actionName !== undefined && !ownership.known) { +function getImplicitHelpValueFlagsToStrip(argv: readonly string[], cwd: string): readonly string[] { + let commandName: string | undefined; + for (const argument of stripReporterValueControls(argv)) { + if (argument === '--') { + break; + } + if (!argument.startsWith('-')) { + commandName = argument; + break; + } + } + const ownership: IReporterCommandLineOwnership = getReporterCommandLineOwnership(commandName, cwd); + if (commandName !== undefined && !ownership.known) { + // An unknown or plugin-owned command can declare options outside the repository configuration. return []; } const commandOwnedFlags: ReadonlySet = ownership.parameters; @@ -657,11 +712,6 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = const cwd: string = options.cwd ?? process.cwd(); const commandJson: boolean = separateJsonControls(argv).commandJson; - const separator: number = argv.indexOf('--'); - const actionName: string | undefined = stripReporterValueControls( - separator < 0 ? argv : argv.slice(0, separator) - ).find((argument) => !argument.startsWith('-')); - let commandOwnership: IReporterCommandLineOwnership | undefined; const reporterProbe: IParsedReporterControls = parseReporterControls(argv, false, true); if (isLegacyEmergencyFallbackRequested(env)) { @@ -726,6 +776,7 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = }; } + let commandOwnership: IReporterCommandLineOwnership | undefined; if (hasHelpControl(argv)) { const reporterValueFlagsToStrip: readonly string[] = requestedReporter !== undefined @@ -733,13 +784,13 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = ? REPORTER_SELECTION_FLAG : ALL_REPORTER_VALUE_FLAGS : options.repositoryOptIn - ? getImplicitHelpValueFlagsToStrip(argv, getCommandOwnership(), actionName) + ? getImplicitHelpValueFlagsToStrip(argv, cwd) : []; - const reporterFlagsToStrip: readonly string[] = ( - requestedReporter === undefined ? options.repositoryOptIn === true : requestedReporter !== 'legacy' - ) - ? getFlagsToStrip(selectionControls) - : []; + const reporterFlagsToStrip: readonly string[] = + (requestedReporter !== undefined && requestedReporter !== 'legacy') || + (requestedReporter === undefined && options.repositoryOptIn) + ? getFlagsToStrip(selectionControls) + : []; return { reporter: 'legacy', logLevel: 'normal', @@ -769,6 +820,10 @@ export function resolveRushReporterSelection(options: IRushReporterHostOptions = function getCommandOwnership(): IReporterCommandLineOwnership { if (!commandOwnership) { + const separator: number = argv.indexOf('--'); + const actionName: string | undefined = stripReporterValueControls( + separator < 0 ? argv : argv.slice(0, separator) + ).find((argument) => !argument.startsWith('-')); commandOwnership = getReporterCommandLineOwnership(actionName, cwd); } return commandOwnership; @@ -916,77 +971,163 @@ export async function initializeRushReporterHostAsync( options: IRushReporterHostOptions = {} ): Promise { const env: Record = options.env ?? process.env; - const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; - const stderr: IRushReporterOutputStream = options.stderr ?? process.stderr; - const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); - const host: ReporterHost = new ReporterHost({ env, manager: options.manager }); + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + get columns() { + return process.stdout.columns; + }, + write: process.stdout.write.bind(process.stdout) + }; + const stderr: IRushReporterOutputStream = options.stderr ?? { + isTTY: process.stderr.isTTY, + columns: process.stderr.columns, + write: process.stderr.write.bind(process.stderr) + }; + const host: ReporterHost = new ReporterHost({ + env, + manager: options.manager, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; let fullDetailReporter: FileReporter | undefined; - if (selection.enabled) { - const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); - if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { - fullDetailReporter = new FileReporter({ - commonTempFolder: options.commonTempFolder, - actionName: options.actionName - }); - host.manager.addReporter(fullDetailReporter, { destination: 'file:auto' }); - } + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); - if (primaryReporter) { - host.manager.addReporter( - new LogLevelReporter(primaryReporter, selection.logLevel, selection.reporter === 'plaintext'), - { - destination: 'stdout' - } - ); - } + if (options.includeDefaultFileReporter !== false || selection.reporter === 'file') { + fullDetailReporter = new FileReporter({ + commonTempFolder: options.commonTempFolder, + actionName: options.actionName + }); + host.manager.addReporter(fullDetailReporter, { + destination: 'file:auto' + }); + } - if (selection.reporter === 'file') { - host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { - destination: 'stderr' - }); + if (primaryReporter) { + const filteredReporter: IReporter = new LogLevelReporter( + primaryReporter, + selection.logLevel, + selection.reporter === 'plaintext' + ); + host.manager.addReporter( + selection.reporter === 'default' || selection.reporter === 'plaintext' + ? new VisibleBootstrapOutputFilterReporter(filteredReporter) + : filteredReporter, + { + destination: 'stdout' + } + ); + } + + if (selection.reporter === 'file') { + host.manager.addReporter(new FilePathReporter((text: string) => stderr.write(text)), { + destination: 'stderr' + }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + const outputStream: IRushReporterOutputStream | undefined = isReporterStreamTarget(output.target) + ? output.target === 'stdout' + ? stdout + : stderr + : undefined; + host.manager.addReporter( + new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel, outputStream), + { destination: output.target } + ); + } } - for (const output of selection.outputs) { - const outputLogLevel: ReporterLogLevel = - output.params.logLevel && isSupportedLogLevel(output.params.logLevel) - ? output.params.logLevel - : output.reporter === 'file' - ? 'debug' - : selection.logLevel; - const outputStream: IRushReporterOutputStream | undefined = isReporterStreamTarget(output.target) - ? output.target === 'stdout' - ? stdout - : stderr - : undefined; - host.manager.addReporter( - new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel, outputStream), - { destination: output.target } - ); + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + const artifactCompletionSink: ArtifactCompletionReporterSink | undefined = fullDetailReporter + ? new ArtifactCompletionReporterSink(host, fullDetailReporter) + : undefined; + let sink: IReporterEventSink = artifactCompletionSink ?? host.getSink(); + if ( + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' + ) { + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + const incompatibility: string = + bootstrapReplay.skipReason === 'incompatible-protocol' + ? 'protocol is incompatible' + : 'contains an unsupported required event'; + throw new Error( + `The install-run-rush bootstrap reporter ${incompatibility} with this Rush frontend. ` + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + ...(selection.reporterFlagsToStrip ? { reporterFlagsToStrip: selection.reporterFlagsToStrip } : {}), + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); } - } - await host.manager.initializeAsync(); - const artifactCompletionSink: ArtifactCompletionReporterSink | undefined = fullDetailReporter - ? new ArtifactCompletionReporterSink(host, fullDetailReporter) - : undefined; - let closePromise: Promise | undefined; - return { - host, - sink: artifactCompletionSink ?? host.getSink(), - selection, - logArtifact: fullDetailReporter?.getArtifact(), - closeAsync: (timeoutMs?: number) => { - closePromise ??= (async () => { - const fullyFlushed: boolean = await host.manager._flushAndConfirmAsync(timeoutMs); - if (fullyFlushed) { - await fullDetailReporter?.closeAsync(); - artifactCompletionSink?.publishIfChanged(); - } - await host.manager.closeAsync(timeoutMs); - })(); - return closePromise; + return { + host, + sink, + selection, + logArtifact: fullDetailReporter?.getArtifact(), + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= (async () => { + const fullyFlushed: boolean = await host.manager._flushAndConfirmAsync(timeoutMs); + if (fullyFlushed) { + await fullDetailReporter?.closeAsync(); + artifactCompletionSink?.publishIfChanged(); + } + await host.manager.closeAsync(timeoutMs); + })(); + return closePromise; + } + }; + } catch (error) { + const [disposal]: PromiseSettledResult[] = await Promise.allSettled([ + host.manager._disposeInitializedReportersAsync(error) + ]); + if (disposal.status === 'rejected') { + // Even a failed emergency write must not replace the original startup failure. + await Promise.allSettled([ + Promise.resolve().then(() => stderr.write(`[reporter] ${String(disposal.reason)}\n`)) + ]); } - }; + throw error; + } finally { + if (!handoffReplayAttempted) { + await host.discardBootstrapHandoffAsync(); + } + delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + } } diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 20c3d01dad..ce89508f80 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import * as semver from 'semver'; import { LockFile, Import } from '@rushstack/node-core-library'; +import { REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; import { _FlagFile, _RushGlobalFolder } from '@microsoft/rush-lib'; @@ -39,16 +40,19 @@ export class RushVersionSelector { let installIsValid: boolean = await installMarker.isValidAsync(); if (!installIsValid) { // Need to install Rush - console.log(`Rush version ${version} is not currently installed. Installing...`); + this.#reportStartupMessage( + executeOptions, + `Rush version ${version} is not currently installed. Installing...` + ); const resourceName: string = `rush-${version}`; - console.log(`Trying to acquire lock for ${resourceName}`); + this.#reportStartupMessage(executeOptions, `Trying to acquire lock for ${resourceName}`); const lock: LockFile = await LockFile.acquireAsync(expectedRushPath, resourceName); installIsValid = await installMarker.isValidAsync(); if (installIsValid) { - console.log('Another process performed the installation.'); + this.#reportStartupMessage(executeOptions, 'Another process performed the installation.'); } else { await Utilities.installPackageInDirectoryAsync({ directory: expectedRushPath, @@ -69,7 +73,10 @@ export class RushVersionSelector { filterNpmIncompatibleProperties: true }); - console.log(`Successfully installed Rush version ${version} in ${expectedRushPath}.`); + this.#reportStartupMessage( + executeOptions, + `Successfully installed Rush version ${version} in ${expectedRushPath}.` + ); // If we've made it here without exception, write the flag file await installMarker.createAsync(); @@ -101,4 +108,19 @@ export class RushVersionSelector { RushCommandSelector.execute(this.#currentPackageVersion, rushCliEntrypoint, executeOptions); } } + + #reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { + if (options.reporterEnabled) { + options.reporter.eventSink.emit({ + protocolVersion: REPORTER_PROTOCOL_VERSION, + sessionId: options.reporter.sessionId, + source: { packageName: '@microsoft/rush', packageVersion: this.#currentPackageVersion }, + privacy: 'public', + type: 'activityChanged', + payload: { kind: 'version-selection', text } + }); + } else { + console.log(text); + } + } } diff --git a/apps/rush/src/test/MinimalRushConfiguration.test.ts b/apps/rush/src/test/MinimalRushConfiguration.test.ts index 861304b929..cc94e37011 100644 --- a/apps/rush/src/test/MinimalRushConfiguration.test.ts +++ b/apps/rush/src/test/MinimalRushConfiguration.test.ts @@ -152,6 +152,21 @@ describe(MinimalRushConfiguration.name, () => { ); }); + it('uses the caller-owned discovery writer when rush.json cannot load', () => { + const repo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined); + const writeLine = jest.fn(); + jest.spyOn(process, 'cwd').mockReturnValue(repo); + jest.spyOn(JsonFile, 'load').mockImplementation(() => { + throw new SyntaxError('Malformed rush.json'); + }); + process.argv = ['node', 'rush', 'build']; + + expect(MinimalRushConfiguration.loadFromDefaultLocation(writeLine)).toBeUndefined(); + expect(writeLine.mock.calls).toEqual([[`Found configuration in ${path.join(repo, 'rush.json')}`], ['']]); + expect(consoleLog).not.toHaveBeenCalled(); + }); + it('suppresses legacy discovery output for an explicit reporter', () => { const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); const consoleLog: jest.SpiedFunction = jest @@ -186,11 +201,30 @@ describe(MinimalRushConfiguration.name, () => { ]); }); + it.each(['json', 'ai', 'file'])( + 'keeps discovery off stdout when an incompatible engine rejects %s', + (reporter) => { + const legacyRepo: string = path.join(__dirname, 'sandbox', 'legacy-repo'); + const consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined); + jest.spyOn(PackageJsonLookup, 'loadOwnPackageJson').mockReturnValue({ + name: '@microsoft/rush', + version: '5.178.1' + }); + jest.spyOn(process, 'cwd').mockReturnValue(path.join(legacyRepo, 'project')); + delete process.env.RUSH_REPORTER; + process.argv = ['node', 'rush', 'build', `--reporter=${reporter}`]; + + MinimalRushConfiguration.loadFromDefaultLocation(); + + expect(consoleLog).not.toHaveBeenCalled(); + } + ); + it.each([ ['environment fallback', ['build', '--reporter=json'], 'legacy'], ['explicit legacy reporter', ['build', '--reporter=legacy'], undefined], ['help fallback', ['build', '--reporter=json', '--help'], undefined], - ['cross-version fallback', ['build', '--reporter=json'], undefined] + ['cross-version fallback', ['build'], undefined] ])('restores legacy discovery output in an opted-in repository for %s', (testName, args, envValue) => { void testName; const repo: string = path.join(__dirname, 'sandbox', 'repo'); diff --git a/apps/rush/src/test/ReporterDemo.test.ts b/apps/rush/src/test/ReporterDemo.test.ts new file mode 100644 index 0000000000..2beb07f56b --- /dev/null +++ b/apps/rush/src/test/ReporterDemo.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const operationId: string = '@rushstack/rush-reporter#_phase:build'; +const rawEvent = { + sequence: 1, + type: 'externalOutput', + scope: { operationId }, + payload: { stream: 'stdout', text: '---- build started ----\n' } +}; +const childEvent = { + ...rawEvent, + parentSessionId: 'parent', + parentRequestId: 'request', + parentOperationId: operationId, + sourceSequence: 1, + source: { packageName: '@rushstack/heft' } +}; + +function validate(events: readonly unknown[], platform: NodeJS.Platform): SpawnSyncReturns { + const moduleUrl: string = pathToFileURL( + path.resolve(__dirname, '../../src/test/sandbox/reporter-demo/validateHeftOutput.mjs') + ).href; + return spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { readFileSync } from 'node:fs'; + const { validateHeftOutput } = await import(process.argv[1]); + const input = JSON.parse(readFileSync(0, 'utf8')); + validateHeftOutput(input.events, input.platform);`, + moduleUrl + ], + { input: JSON.stringify({ events, platform }), encoding: 'utf8', timeout: 5000 } + ); +} + +describe('reporter demo Heft expectations', () => { + it('accepts the intentional Windows raw fallback and the negotiated Unix path', () => { + expect(validate([rawEvent], 'win32').status).toBe(0); + expect(validate([childEvent], 'linux').status).toBe(0); + expect(validate([childEvent], 'darwin').status).toBe(0); + }); + + it('does not weaken the supported Unix negotiation requirement', () => { + const result: SpawnSyncReturns = validate([rawEvent], 'linux'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('did not negotiate structured reporting'); + }); + + it('requires readable Windows fallback without raw protocol records', () => { + expect(validate([], 'win32').status).toBe(1); + expect(validate([childEvent], 'win32').status).toBe(1); + expect( + validate( + [{ ...rawEvent, payload: { stream: 'stdout', text: '---- build started ----\n{"kind":"hello"}\n' } }], + 'win32' + ).status + ).toBe(1); + }); + + it.each(['win32', 'linux'] as const)('rejects reordered or oversized %s output', (platform) => { + const event = platform === 'win32' ? rawEvent : childEvent; + expect(validate([event, event], platform).status).toBe(1); + expect( + validate( + [{ ...event, payload: { stream: 'stdout', text: 'x'.repeat(64 * 1024 + 1) } }], + platform + ).status + ).toBe(1); + }); +}); diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 0000000000..eb7e9383e9 --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,536 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + LegacyFallbackSink, + ReporterManager, + REPORTER_PROTOCOL_VERSION, + type IReporter, + type IReporterEventEnvelope +} from '@rushstack/rush-reporter'; +import { Rush } from '@microsoft/rush-lib'; + +import { RushCommandSelector } from '../RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly events: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise {} + + public report(event: IReporterEventEnvelope): void { + this.events.push(event); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +class WritingReporter implements IReporter { + public readonly name: string = 'writing'; + public reportCount: number = 0; + + public async initializeAsync(): Promise {} + + public report(): void { + this.reportCount++; + process.stdout.write('reporter output\n'); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +type BeforeExitListener = (code: number) => void; + +function restoreObservedOutput( + previousBeforeExitListeners: readonly BeforeExitListener[], + required: boolean = true +): void { + const currentListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + const restoreListener: BeforeExitListener | undefined = currentListeners.find( + (listener: BeforeExitListener) => !previousBeforeExitListeners.includes(listener) + ); + if (!restoreListener) { + if (required) { + throw new Error('Expected an old-engine output restoration listener.'); + } + return; + } + restoreListener(0); +} + +describe(RushCommandSelector.name, () => { + it('publishes the current engine reporter protocol major', () => { + expect((Rush as typeof Rush & { readonly _reporterProtocolMajor?: number })._reporterProtocolMajor).toBe( + REPORTER_PROTOCOL_VERSION.major + ); + }); + + it('does not observe output from a matching structured engine when reporters are disabled', () => { + const manager: ReporterManager = new ReporterManager(); + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: false, + reporterSelectionReason: 'explicit --reporter' + }; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const currentRushLib = { + Rush: { + version: '5.178.1', + _reporterProtocolMajor: REPORTER_PROTOCOL_VERSION.major, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', currentRushLib, options); + + expect(process.stdout.write).toBe(originalStdoutWrite); + expect(receivedOptions?.reporter).toBe(options.reporter); + }); + + it('does not recapture reporter output while observing an old engine', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: WritingReporter = new WritingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => process.stdout.write('legacy output\n') + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.reportCount).toBe(1); + expect(stdoutText.match(/reporter output/g)).toHaveLength(1); + expect(stdoutText.match(/legacy output/g)).toHaveLength(1); + }); + + it('keeps ordered old-engine stdout and stderr on their original streams', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + let stderrText: string = ''; + process.argv = ['node', 'rush', 'build']; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = ((text: string): boolean => { + stderrText += text; + return true; + }) as typeof process.stderr.write; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('stdout 1\n'); + process.stderr.write('stderr 1\n'); + process.stdout.write('stdout 2\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, options); + expect(process.stdout.write).not.toBe(stdoutWrite); + expect(process.stderr.write).not.toBe(stderrWrite); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + + it('captures asynchronous old-engine output until the process lifecycle completes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + setImmediate(() => { + process.stdout.write('async stdout\n'); + process.stderr.write('async stderr\n'); + }); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + await new Promise((resolve) => setImmediate(resolve)); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'async stdout\n', wasRendered: true }, + { stream: 'stderr', text: 'async stderr\n', wasRendered: true } + ]); + }); + + it.each([undefined, REPORTER_PROTOCOL_VERSION.major])( + 'keeps engine stdout structured for machine reporters (protocol %s)', + async (protocolMajor) => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + let closeFromEngine: (() => Promise) | undefined; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: protocolMajor, + launch: (version: string, options: IRushFrontendLaunchOptions) => { + void version; + closeFromEngine = options.reporterCloseAsync; + process.stdout.write('legacy stdout\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => { + expect(process.stdout.write).toBe(stdoutWrite); + await manager.closeAsync(); + }, + reporterEnabled: true, + reporterStdoutIsMachineReadable: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + expect(closeFromEngine).toBeDefined(); + await closeFromEngine!(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe(''); + expect(reporter.events[0].payload).toEqual({ + stream: 'stdout', + text: 'legacy stdout\n' + }); + } + ); + + it('preserves a UTF-8 code point split across old-engine buffer writes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write(Buffer.from([0xe2])); + process.stdout.write(Buffer.from([0x82, 0xac])); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€', wasRendered: true }); + }); + + it('restores old-engine stream writers when launch throws', () => { + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + + try { + expect(() => + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + throw new Error('launch failed'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ) + ).toThrow('launch failed'); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + }); + + it('fails an explicit reporter request for an incompatible new engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 2/ + ); + }); + + it('fails an explicit reporter request for an incompatible older engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 0/ + ); + }); + + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); + }); + + it('falls back to legacy engine rendering for an implicit older protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: new ReporterManager(), sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporter.eventSink).toBeInstanceOf(LegacyFallbackSink); + }); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 206ac01890..9244a089c1 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -8,8 +8,8 @@ import * as path from 'node:path'; import * as rushLib from '@microsoft/rush-lib'; import type { ILaunchOptions } from '@microsoft/rush-lib'; import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; -import { RushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; +import { LockFile } from '@rushstack/node-core-library'; import { ReporterHost, ReporterManager, @@ -23,6 +23,7 @@ import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../ import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, + resolveRushReporterSelection, type IInitializedRushReporterHost, type IRushReporterSelection } from '../RushReporterHost'; @@ -42,6 +43,8 @@ async function createInitializedHostAsync( host, sink: host.getSink(), logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'legacy', logLevel: 'normal', @@ -71,6 +74,8 @@ async function createEnabledHostAsync( host, sink: host.getSink(), logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -109,6 +114,8 @@ async function createPhaseHangingHostAsync( host, sink: host.getSink(), logArtifact: undefined, + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -181,7 +188,104 @@ function emitCommandStarted(sink: IReporterEventSink): void { }); } +function releaseParserTestLocks(spy: jest.SpiedFunction): void { + try { + // Native parser locks live until process exit; these fixtures run the parser inside Jest instead. + for (const result of spy.mock.results) { + if (result.type === 'return' && result.value && !result.value.isReleased) result.value.release(); + } + } finally { + spy.mockRestore(); + } +} + describe(launchRushFrontendAsync.name, () => { + it.each([ + { reporter: 'file', output: undefined, commandJson: false, machineStdout: false }, + { reporter: 'json', output: undefined, commandJson: false, machineStdout: true }, + { reporter: 'file', output: 'json://stdout', commandJson: false, machineStdout: true }, + { reporter: 'file', output: 'file://stdout', commandJson: false, machineStdout: true }, + { reporter: 'file', output: 'json://stderr', commandJson: false, machineStdout: false }, + { reporter: 'file', output: 'json://./stdout', commandJson: false, machineStdout: false }, + { reporter: 'file', output: 'json://stdout', commandJson: true, machineStdout: true } + ])('classifies $reporter / $output stdout ownership with command JSON $commandJson', async (testCase) => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'list', `--reporter=${testCase.reporter}`]; + if (testCase.output) { + process.argv.push(`--output=${testCase.output}`); + } + if (testCase.commandJson) { + process.argv.push('--json'); + } + let receivedOptions: IRushFrontendLaunchOptions | undefined; + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async (options) => ({ + ...(await createEnabledHostAsync()), + selection: resolveRushReporterSelection({ + ...options, + env: {}, + stdout: { isTTY: false, write: () => undefined } + }) + }), + executeCurrentRush: (version, selectedRushLib, launchOptions) => { + void version; + void selectedRushLib; + receivedOptions = launchOptions; + return launchOptions.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + + expect(receivedOptions?.reporterEnabled).toBe(true); + expect(receivedOptions?.reporterSelectionReason).toBe('explicit --reporter'); + expect(receivedOptions?.reporterStdoutIsMachineReadable).toBe(testCase.machineStdout); + expect(receivedOptions?.reporterStdoutIsReserved).toBe(true); + } finally { + process.argv = originalArgv; + } + }); + + it.each([ + ['file', false, true], + ['file', true, false], + ['json', false, true], + ['ai', false, true], + ['plaintext', false, false] + ] as const)('reserves stdout for %s with command JSON %s: %s', async (reporter, commandJson, reserved) => { + const initialized: IInitializedRushReporterHost = await createEnabledHostAsync(); + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'list']; + try { + await launchRushFrontendAsync({ + currentPackageVersion: '5.178.1', + rushVersionToLoad: undefined, + configuration: undefined, + launchOptions: { isManaged: false }, + currentRushLib: rushLib, + initializeReporterHostAsync: async () => ({ + ...initialized, + selection: { ...initialized.selection, reporter, commandJson } + }), + executeCurrentRush: (version, selectedRushLib, options) => { + void version; + void selectedRushLib; + expect(options.reporterStdoutIsReserved).toBe(reserved); + return options.reporterCloseAsync(); + }, + processLifecycle: createTestProcessLifecycle() + }); + } finally { + await initialized.closeAsync(); + process.argv = originalArgv; + } + }); + it('creates the authoritative host before invoking the bundled rush-lib and passes only its channel', async () => { const order: string[] = []; let receivedOptions: IRushFrontendLaunchOptions | undefined; @@ -343,7 +447,7 @@ describe(launchRushFrontendAsync.name, () => { it('keeps an implicit repository opt-in on the legacy path for an incompatible engine', async () => { const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); - const versionSelector: RushVersionSelector = new RushVersionSelector('5.178.1'); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); let receivedArgv: string[] | undefined; versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { void version; @@ -415,7 +519,7 @@ describe(launchRushFrontendAsync.name, () => { } ])('preserves the old-engine $name escape path', async ({ reporter, expectedArgv }) => { const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); - const versionSelector: RushVersionSelector = new RushVersionSelector('5.178.1'); + const versionSelector: RushVersionSelector = Object.create(RushVersionSelector.prototype); let receivedArgv: string[] | undefined; versionSelector.ensureRushVersionInstalledAsync = async (version, configuration, launchOptions) => { void version; @@ -540,6 +644,7 @@ describe(launchRushFrontendAsync.name, () => { } process.argv.push('--verbose'); let selection: IRushReporterSelection | undefined; + const lockSpy = jest.spyOn(LockFile, 'tryAcquire'); try { EnvironmentConfiguration.reset(); @@ -577,10 +682,16 @@ describe(launchRushFrontendAsync.name, () => { expect( JSON.parse(await fs.promises.readFile(path.join(repoPath, 'custom-output-args.json'), 'utf8')) ).toEqual(testCase.expectedArguments); + expect( + lockSpy.mock.results.some( + (result) => result.type === 'return' && result.value && !result.value.isReleased + ) + ).toBe(true); } finally { EnvironmentConfiguration.reset(); process.argv = originalArgv; process.exitCode = originalExitCode; + releaseParserTestLocks(lockSpy); await fs.promises.rm(directory, { recursive: true, force: true }); } }); @@ -630,6 +741,7 @@ describe(launchRushFrontendAsync.name, () => { process.argv = ['node', 'rush', 'custom-reporter-flag', '--reporter']; const processLifecycle: ITestProcessLifecycle = createTestProcessLifecycle(); let selection: IRushReporterSelection | undefined; + const lockSpy = jest.spyOn(LockFile, 'tryAcquire'); try { EnvironmentConfiguration.reset(); @@ -675,10 +787,16 @@ describe(launchRushFrontendAsync.name, () => { ).toEqual(['--reporter']); expect(processLifecycle.beforeExitListener).toBeUndefined(); expect(processLifecycle.signalListeners.size).toBe(0); + expect( + lockSpy.mock.results.some( + (result) => result.type === 'return' && result.value && !result.value.isReleased + ) + ).toBe(true); } finally { EnvironmentConfiguration.reset(); process.argv = originalArgv; process.exitCode = originalExitCode; + releaseParserTestLocks(lockSpy); await fs.promises.rm(directory, { recursive: true, force: true }); } } @@ -757,23 +875,24 @@ describe(launchRushFrontendAsync.name, () => { void version; void selectedRushLib; emitCommandStarted(launchOptions.reporter.eventSink); + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: directory, + reporterCloseAsync: launchOptions.reporterCloseAsync + }); + jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync') + .mockRejectedValue(new Error('parser failed')); process.exitCode = 1; - return new Promise((resolve: () => void) => { + const exited: Promise = new Promise((resolve: () => void) => { jest.spyOn(process, 'exit').mockImplementation(() => { outputAtExit = fs.readFileSync(outputPath, 'utf8'); resolve(); return undefined as never; }); jest.spyOn(console, 'error').mockImplementation(() => undefined); - jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { - throw new Error('parser failed'); - }); - const parser: RushCommandLineParser = new RushCommandLineParser({ - cwd: directory, - reporterCloseAsync: launchOptions.reporterCloseAsync - }); - void parser.executeAsync(); + }); + return Promise.all([exited, parser.executeAsync(['build'])]).then(([, succeeded]) => { + expect(succeeded).toBe(false); }); }, processLifecycle: createTestProcessLifecycle() diff --git a/apps/rush/src/test/RushReporterControlOwnership.test.ts b/apps/rush/src/test/RushReporterControlOwnership.test.ts index 4bdb6846fc..e9b752402c 100644 --- a/apps/rush/src/test/RushReporterControlOwnership.test.ts +++ b/apps/rush/src/test/RushReporterControlOwnership.test.ts @@ -124,6 +124,19 @@ describe('reporter command-line ownership', () => { } ); + it.each([false, true])( + 'keeps native list help parseable with reporter --verbose (repository opt-in: %s)', + async (implicit) => { + const result = await executeAsync( + ['list', ...(implicit ? [] : ['--reporter=json']), '--verbose', '--help'], + implicit + ); + expect(result.succeeded).toBe(true); + expect(result.forwarded).toEqual(['list', '--help']); + expect(result.selection.enabled).toBe(false); + } + ); + it('preserves action-owned --verbose and every -v meaning', () => { for (const actionName of ['build', 'rebuild', 'check', 'custom-output']) { const argv: string[] = [actionName, '--reporter=plaintext', '--verbose', '-v']; diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 6f913501b4..3a5666bc66 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -6,18 +6,23 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { + OldEngineOutputAdapter, ReporterManager, type IReporter, type IReporterContext, - OldEngineOutputAdapter, type IReporterEventEnvelope, - type IReporterEventSink + type IReporterEventSink, + BootstrapEventBuffer, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync } from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, resolveRushReporterSelection, stripReporterValueControls, + type IInitializedRushReporterHost, type IRushReporterOutputStream, type IRushReporterSelection } from '../RushReporterHost'; @@ -258,6 +263,22 @@ describe(resolveRushReporterSelection.name, () => { ).toEqual(['node', 'rush', 'custom', '--output', 'custom.zip', '--log-level', 'custom', '--verbose']); }); + it('keeps help on the legacy parser-only path', () => { + expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ + reporter: 'legacy', + enabled: false, + reporterControlsOwnedByFrontend: true + }); + }); + + it('ignores help controls after the pass-through separator', () => { + expect(resolve(['build', '--reporter=json', '--', '--help'])).toMatchObject({ + reporter: 'json', + enabled: true, + reporterControlsOwnedByFrontend: true + }); + }); + it.each([['--reporter=junit'], ['--reporter'], ['--reporter', '--verbose']])( 'preserves custom reporter controls during repository rollback: %j', (...argv: string[]) => { @@ -306,6 +327,19 @@ describe(resolveRushReporterSelection.name, () => { ]); }); + it.each(['--reporter', '--output', '--log-level'])( + 'does not consume legacy flags after a value-less %s during rollback', + (flag) => { + const argv: string[] = ['build', '--reporter=json', flag, '--quiet', '--debug']; + const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); + expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ + 'build', + '--quiet', + '--debug' + ]); + } + ); + it('keeps help on the legacy parser-only path', () => { expect(resolve(['build', '--help', '--reporter=json'], {}, false)).toMatchObject({ reporter: 'legacy', @@ -445,19 +479,6 @@ describe(resolveRushReporterSelection.name, () => { }); }); - it.each(['--reporter', '--output', '--log-level'])( - 'does not consume legacy flags after a value-less %s during rollback', - (flag) => { - const argv: string[] = ['build', '--reporter=json', flag, '--quiet', '--debug']; - const selection: IRushReporterSelection = resolve(argv, { RUSH_REPORTER: 'legacy' }); - expect(stripReporterValueControls(argv, new Set(selection.reporterValueFlagsToStrip))).toEqual([ - 'build', - '--quiet', - '--debug' - ]); - } - ); - it('removes reporter-only value controls before invoking a legacy engine', () => { expect( stripReporterValueControls([ @@ -537,6 +558,9 @@ describe(resolveRushReporterSelection.name, () => { enabled: false, reason: 'pre-major legacy default' }); + expect( + resolve(['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid']) + ).toMatchObject({ reporter: 'json', enabled: true }); }); it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { @@ -727,8 +751,68 @@ describe(resolveRushReporterSelection.name, () => { }); describe(initializeRushReporterHostAsync.name, () => { + it.each(['json', 'plaintext'])( + 'preserves unrendered unscoped and command-scoped output alongside native operations: %s', + async (reporter) => { + let output: string = ''; + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', `--reporter=${reporter}`, '--log-level=debug'], + env: { CI: 'true' }, + stdout: { isTTY: false, write: (text: string) => (output += text) }, + includeDefaultFileReporter: false + }); + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: initialized.sink, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' } + }); + try { + emitCommandStarted(initialized.sink); + adapter.capture('stdout', 'bootstrap stdout\n', false); + emitOperationEvents(initialized.sink); + adapter.capture('stderr', 'bootstrap stderr\n', false); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 1 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + scope: { commandName: 'build' }, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stdout', text: 'command output\n' } + }); + await initialized.closeAsync(); + + if (reporter === 'json') { + const events: IReporterEventEnvelope<{ stream?: string; text?: string }>[] = output + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect( + events.filter((event) => event.type === 'externalOutput').map((event) => event.payload) + ).toEqual([ + { stream: 'stdout', text: 'bootstrap stdout\n' }, + { stream: 'stdout', text: 'raw operation output\n' }, + { stream: 'stderr', text: 'bootstrap stderr\n' }, + { stream: 'stdout', text: 'command output\n' } + ]); + expect(events.filter((event) => event.type === 'operationRegistered')).toHaveLength(1); + expect(events.filter((event) => event.type === 'operationCompleted')).toHaveLength(1); + } else { + const unscopedText: string[] = ['bootstrap stdout\n', 'bootstrap stderr\n', 'command output\n']; + for (const text of [...unscopedText, 'raw operation output\n']) { + expect(output.split(text)).toHaveLength(2); + } + expect(output.indexOf(unscopedText[0])).toBeLessThan(output.indexOf(unscopedText[1])); + expect(output.indexOf(unscopedText[1])).toBeLessThan(output.indexOf(unscopedText[2])); + } + } finally { + await initialized.closeAsync(); + } + } + ); + it.each([false, true])( - 'retains full log debug details independently of the selected level: %s', + 'retains the automatic full-detail log independently of the selected display level: %s', async (normal) => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-file-level-')); const osModule: typeof os = jest.requireActual('node:os'); @@ -831,6 +915,73 @@ describe(initializeRushReporterHostAsync.name, () => { } ); + it.each(['incompatible-protocol', 'unsupported-required-event'])( + 'closes initialized output descriptors when rejecting %s', + async (skipReason: string) => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-host-disposal-')); + const outputPath: string = path.join(directory, 'output.ndjson'); + let outputDescriptor: number | undefined; + const filesystem: typeof fs = jest.requireActual('node:fs'); + const originalOpen: typeof fs.openSync = filesystem.openSync; + const openSpy: jest.SpyInstance = jest + .spyOn(filesystem, 'openSync') + .mockImplementation((filePath, flags, mode) => { + const descriptor: number = originalOpen(filePath, flags, mode); + if (filePath === outputPath) { + outputDescriptor = descriptor; + } + return descriptor; + }); + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const event: Record = JSON.parse(lines[1]); + if (skipReason === 'incompatible-protocol') { + event.protocolVersion = { major: 99, minor: 0 }; + } else { + event.type = 'futureRequiredEvent'; + event.required = true; + } + lines[1] = JSON.stringify(event); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + const env: Record = { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', `--output=json://${outputPath}`], + env, + handoffDirectory: directory, + includeDefaultFileReporter: false, + stdout: { isTTY: false, write: () => undefined }, + stderr: { write: () => undefined } + }) + ).rejects.toThrow(/bootstrap reporter/); + expect(outputDescriptor).toBeDefined(); + expect(() => fs.fstatSync(outputDescriptor!)).toThrow(expect.objectContaining({ code: 'EBADF' })); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env).toEqual({}); + } finally { + openSpy.mockRestore(); + if (outputDescriptor !== undefined) { + try { + fs.closeSync(outputDescriptor); + } catch (error) { + expect(error).toMatchObject({ code: 'EBADF' }); + } + } + await fs.promises.rm(directory, { recursive: true, force: true }); + } + } + ); + it('writes ./stdout to a file without conflicting with the primary stdout reporter', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-stream-path-')); let stdoutText: string = ''; @@ -852,6 +1003,53 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('preserves the initialization error after cleanup and emergency reporting fail', async () => { + const originalError: Error = new Error('original initialization failure'); + let context: IReporterContext | undefined; + let abortedWhenClosing: boolean | undefined; + const close: jest.Mock = jest.fn(async () => { + abortedWhenClosing = context?.abortSignal?.aborted; + throw new Error('cleanup failure'); + }); + const reporter: IReporter = { + name: 'partially-initialized', + initializeAsync: async (reporterContext: IReporterContext) => { + context = reporterContext; + throw originalError; + }, + report: () => undefined, + flushAsync: async () => undefined, + closeAsync: close + }; + const initialize: typeof ReporterManager.prototype.initializeAsync = + ReporterManager.prototype.initializeAsync; + const initializeSpy: jest.SpiedFunction = jest + .spyOn(ReporterManager.prototype, 'initializeAsync') + .mockImplementation(async function (this: ReporterManager): Promise { + this.addReporter(reporter); + await initialize.call(this); + }); + try { + await expect( + initializeRushReporterHostAsync({ + argv: [], + env: {}, + includeDefaultFileReporter: false, + stderr: { + write: () => { + throw new Error('emergency output failed'); + } + } + }) + ).rejects.toBe(originalError); + expect(close).toHaveBeenCalledTimes(1); + expect(abortedWhenClosing).toBe(true); + expect(context?.abortSignal?.reason).toBe(originalError); + } finally { + initializeSpy.mockRestore(); + } + }); + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { let output: string = ''; const stdout: IRushReporterOutputStream = { @@ -891,20 +1089,7 @@ describe(initializeRushReporterHostAsync.name, () => { expect(initialized.logArtifact?.path).toMatch( new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) ); - initialized.sink.emit({ - protocolVersion: { major: 1, minor: 1 }, - sessionId: 'automatic-file-level', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, - privacy: 'public', - type: 'messageEmitted', - payload: { severity: 'debug', text: 'automatic-full-detail' } - }); await initialized.closeAsync(); - const logPath: string | undefined = initialized.logArtifact?.path; - if (!logPath) { - throw new Error('Expected the automatic full-detail log path'); - } - expect(await fs.promises.readFile(logPath, 'utf8')).toContain('automatic-full-detail'); } finally { await fs.promises.rm(directory, { recursive: true, force: true }); } @@ -1096,7 +1281,63 @@ describe(initializeRushReporterHostAsync.name, () => { } }); - it('publishes artifact completeness as a frozen boolean snapshot', async () => { + it('observes terminal resizing after binding the default stdout writer', async () => { + const originalColumns: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + process.stdout, + 'columns' + ); + const originalIsTTY: PropertyDescriptor | undefined = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY' + ); + let output: string = ''; + const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += chunk.toString(); + return true; + }); + let initialized: IInitializedRushReporterHost | undefined; + try { + Object.defineProperty(process.stdout, 'columns', { configurable: true, writable: true, value: 80 }); + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); + initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env: { NO_COLOR: '' }, + includeDefaultFileReporter: false + }); + const activity: string = 'a'.repeat(60); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, + privacy: 'public', + type: 'activityChanged', + payload: { text: activity } + }); + await initialized.host.manager.flushAsync(); + expect(output).toContain(activity); + + output = ''; + process.stdout.columns = 20; + await initialized.host.manager.flushAsync(); + expect(output).not.toContain(activity); + expect(output).toContain('a'.repeat(19)); + } finally { + await initialized?.closeAsync(); + writeSpy.mockRestore(); + for (const [property, descriptor] of [ + ['columns', originalColumns], + ['isTTY', originalIsTTY] + ] as const) { + if (descriptor) { + Object.defineProperty(process.stdout, property, descriptor); + } else { + Reflect.deleteProperty(process.stdout, property); + } + } + } + }); + + it('publishes a frozen complete artifact after archiving replayed bootstrap output', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-artifact-snapshot-')); const reported: IReporterEventEnvelope[] = []; const manager: ReporterManager = new ReporterManager(); @@ -1113,9 +1354,23 @@ describe(initializeRushReporterHostAsync.name, () => { }; manager.addReporter(captureReporter); try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'bootstrap output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); const initialized = await initializeRushReporterHostAsync({ argv: ['build', '--reporter=json'], - env: {}, + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory, commonTempFolder: directory, actionName: 'build', stdout: { isTTY: false, write: () => undefined }, @@ -1144,6 +1399,11 @@ describe(initializeRushReporterHostAsync.name, () => { }); await initialized.closeAsync(); + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(await fs.promises.readFile(initialized.logArtifact!.path!, 'utf8')).toContain( + 'bootstrap output\n' + ); const finalArtifact: IReporterEventEnvelope = reported .filter(({ type }) => type === 'artifactAvailable') .at(-1)!; @@ -1162,79 +1422,352 @@ describe(initializeRushReporterHostAsync.name, () => { } }); - it.each(['json', 'plaintext'])( - 'preserves unscoped and command-scoped output alongside presented operations: %s', - async (reporter) => { - let output: string = ''; + it('replays and deletes a bootstrap handoff before returning the authoritative host', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: { rushVersion: '5.178.1' } }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + const initialized = await initializeRushReporterHostAsync({ - argv: ['build', `--reporter=${reporter}`, '--log-level=debug'], - env: { CI: 'true' }, - stdout: { isTTY: false, write: (text: string) => (output += text) }, + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, includeDefaultFileReporter: false }); - const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ - sink: initialized.sink, - sessionId: 'session', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' } + await initialized.host.manager.flushAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(JSON.parse(stdoutText).type).toBe('sessionStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('does not replay live bootstrap output to the same visible destination', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } }); - try { - emitCommandStarted(initialized.sink); - adapter.capture('stdout', 'bootstrap stdout\n'); - emitOperationEvents(initialized.sink); - adapter.capture('stderr', 'bootstrap stderr\n'); - initialized.sink.emit({ - protocolVersion: { major: 1, minor: 1 }, - sessionId: 'session', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.178.1' }, - scope: { commandName: 'build' }, - privacy: 'local-sensitive', + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: [ + 'build', + '--reporter=plaintext', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'old-engine-session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.0' }, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }); + await initialized.host.manager.closeAsync(); + + expect(stdoutText).toBe(''); + expect( + (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + expect.objectContaining({ type: 'externalOutput', - payload: { stream: 'stdout', text: 'command output\n' } - }); - await initialized.closeAsync(); + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }), + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }) + ]); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); - if (reporter === 'json') { - const events: IReporterEventEnvelope<{ stream?: string; text?: string }>[] = output - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - expect(events.map((event) => event.type)).toEqual([ - 'commandStarted', - 'externalOutput', - 'operationRegistered', - 'operationStatusChanged', - 'externalOutput', - 'operationStreamClosed', - 'operationCompleted', - 'externalOutput', - 'externalOutput' - ]); - expect( - events.filter((event) => event.type === 'externalOutput').map((event) => event.payload) - ).toEqual([ - { stream: 'stdout', text: 'bootstrap stdout\n' }, - { stream: 'stdout', text: 'raw operation output\n' }, - { stream: 'stderr', text: 'bootstrap stderr\n' }, - { stream: 'stdout', text: 'command output\n' } - ]); - } else { - expect(output.startsWith('Starting "rush build"\n')).toBe(true); - let previousOffset: number = -1; - for (const text of [ - 'bootstrap stdout\n', - 'raw operation output\n', - 'bootstrap stderr\n', - 'command output\n' - ]) { - expect(output.split(text)).toHaveLength(2); - const offset: number = output.indexOf(text); - expect(offset).toBeGreaterThan(previousOffset); - previousOffset = offset; + it('retains bootstrap stdout and stderr records in the primary JSON stream', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'captured stdout\n' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--log-level=debug'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; } - } - } finally { - await initialized.closeAsync(); - } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect( + stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line).payload) + ).toEqual([ + { stream: 'stdout', text: 'captured stdout\n' }, + { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + ]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); } - ); + }); + + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const argv: string[] = ['list', '--verbose', '--log-level=verbose']; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'activityChanged', payload: { text: 'installing Rush' } }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace(/"major":1/g, '"major":2')); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv, + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback', + reporterValueFlagsToStrip: ['--log-level'], + reporterFlagsToStrip: ['--verbose'] + }); + expect( + stripReporterValueControls( + argv, + new Set(initialized.selection.reporterValueFlagsToStrip), + new Set(initialized.selection.reporterFlagsToStrip) + ) + ).toEqual(['list']); + expect(stdoutText).toBe('installing Rush\nnpm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('falls back when repository opt-in meets an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const argv: string[] = ['list', '--verbose', '--log-level=verbose']; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv, + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback', + reporterValueFlagsToStrip: ['--log-level'], + reporterFlagsToStrip: ['--verbose'] + }); + expect( + stripReporterValueControls( + argv, + new Set(initialized.selection.reporterValueFlagsToStrip), + new Set(initialized.selection.reporterFlagsToStrip) + ) + ).toEqual(['list']); + expect(stdoutText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('fails an explicit reporter request for an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stderrText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/unsupported required event/); + + expect(stderrText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('deletes an authenticated handoff when explicit reporter validation fails', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/requires an interactive TTY/); + + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/apps/rush/src/test/sandbox/reporter-demo/README.md b/apps/rush/src/test/sandbox/reporter-demo/README.md index 8a071f69fd..ca4a53a5ce 100644 --- a/apps/rush/src/test/sandbox/reporter-demo/README.md +++ b/apps/rush/src/test/sandbox/reporter-demo/README.md @@ -17,8 +17,12 @@ plaintext, JSON, AI, file, and quiet modes, plus parser failure, help, and comma It verifies payload-only machine stdout, one visible writer, ordered/lossless plaintext grouping from a same-invocation JSON sidecar, final artifact completeness, owner-only log permissions, failure flushing, AI parser-error context, command-JSON ownership, exclusive sidecar destinations, and the -`RUSH_REPORTER=legacy` rollback transcript. Inherited `RUSH_REPORTER`, `RUSH_LOG_LEVEL`, and -`RUSH_QUIET_MODE` values are removed from the self-check matrix. It also verifies CI plaintext output, +`RUSH_REPORTER=legacy` rollback transcript. Non-phased `rush list` output must also remain structured in +JSON mode and reach the full-detail log without leaking onto stdout in file mode. +Inherited `RUSH_REPORTER`, `RUSH_LOG_LEVEL`, and +`RUSH_QUIET_MODE` values are removed from the self-check matrix. The matrix sets `RUSH_PREVIEW_VERSION` +to the locally built Rush package version so it exercises the integrated frontend and engine even when +`rush.json` pins an older release; preview warnings remain on stderr. It also verifies CI plaintext output, cache-path output, a matching `RUSH_PREVIEW_VERSION`, normalized `RUSH_TEMP_FOLDER` log placement, and matching purge-path selection. Captured stdout/stderr files are written to a temporary folder. @@ -52,11 +56,6 @@ Repositories can opt in without a command-line flag by setting `"useRushReporter `common/config/rush/experiments.json`. Remove that setting or use `RUSH_REPORTER=legacy` for immediate rollback. -Help stays on the legacy parser path. With repository opt-in, parameters declared for a command remain -command-owned even when their values look like reporter controls (for example, `--output=json://...` -or `--log-level=debug`). Custom `--verbose` flags are preserved as well; help does not run the command -or open reporter output files. - The expected output is shape-based: - legacy keeps the existing Rush banner, operation blocks, and final status sections; @@ -79,3 +78,8 @@ After the demo exits, inspect the latest invocation log with: ls -lt common/temp/rush-logs sed -n '1,120p' common/temp/rush-logs/latest.log ``` + +Help stays on the legacy parser path. With repository opt-in, parameters declared for a command remain +command-owned even when their values look like reporter controls (for example, `--output=json://...` +or `--log-level=debug`). Custom `--verbose` flags are preserved as well; help does not run the command +or open reporter output files. diff --git a/apps/rush/src/test/sandbox/reporter-demo/run.mjs b/apps/rush/src/test/sandbox/reporter-demo/run.mjs index 7e6e650931..42049302f2 100644 --- a/apps/rush/src/test/sandbox/reporter-demo/run.mjs +++ b/apps/rush/src/test/sandbox/reporter-demo/run.mjs @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { validateHeftOutput } from './validateHeftOutput.mjs'; const scriptFolder = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptFolder, '..', '..', '..', '..', '..', '..'); @@ -12,6 +13,7 @@ const rushVersion = JSON.parse( ).version; const outputFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-reporter-demo-')); const commonArgs = ['build', '--only', '@rushstack/rush-reporter']; +// Exercise the built frontend and engine even when rush.json pins an older release. const baseEnv = { ...process.env, RUSH_PREVIEW_VERSION: rushVersion }; delete baseEnv.RUSH_REPORTER; delete baseEnv.RUSH_LOG_LEVEL; @@ -72,6 +74,15 @@ const flagOffHelp = run('help-flag-off', ['--help']).stdout; const help = run('help', ['--help', '--reporter=json'], { RUSH_REPORTER: 'legacy' }).stdout; const commandJson = run('command-json', ['list', '--json', '--reporter=file']); const commandJsonConflict = run('command-json-conflict', ['list', '--json', '--reporter=json'], {}, 1); +const listReporterJson = run('list-reporter-json', ['list', '--reporter=json', '--log-level=debug']); +const listReporterFile = run('list-reporter-file', ['list', '--reporter=file']); +const heftChild = run('heft-child', [ + 'rebuild', + '--only', + '@rushstack/rush-reporter', + '--reporter=json', + '--log-level=debug' +]).stdout; const duplicateOutputPath = path.join(outputFolder, 'duplicate-output.jsonl'); const outputConflict = run( 'output-conflict', @@ -97,13 +108,16 @@ if ( throw new Error('RUSH_TEMP_FOLDER did not own the full-detail log path.'); } const tempPurge = run('temp-purge', ['purge', '--reporter=file'], { RUSH_TEMP_FOLDER: tempOverride }); -if (!tempPurge.stdout.includes(`Purging ${tempOverride}`)) { - throw new Error('rush purge did not use the same normalized RUSH_TEMP_FOLDER path as the reporter log.'); -} const purgeLogMatch = tempPurge.stderr.match(/^Rush full log: (.+)$/m); if (!purgeLogMatch || purgeLogMatch[1].startsWith(tempOverride) || !fs.existsSync(purgeLogMatch[1])) { throw new Error('The active purge reporter log was not preserved outside RUSH_TEMP_FOLDER.'); } +if ( + tempPurge.stdout !== '' || + !fs.readFileSync(purgeLogMatch[1], 'utf8').includes(`Purging ${tempOverride}`) +) { + throw new Error('File-mode purge must log the normalized RUSH_TEMP_FOLDER without writing to stdout.'); +} function parseNdjson(text, name) { if (text.includes('\u001b')) { @@ -121,6 +135,19 @@ const aiRecords = parseNdjson(ai, 'ai'); const failureJsonEvents = parseNdjson(failureJson, 'failure-json'); const failureAiRecords = parseNdjson(failureAi, 'failure-ai'); const plaintextEvents = parseNdjson(fs.readFileSync(plaintextEventsPath, 'utf8'), 'plaintext sidecar'); +const listingOutput = parseNdjson(listReporterJson.stdout, 'list reporter JSON') + .filter((event) => event.type === 'externalOutput') + .map((event) => event.payload.text) + .join(''); +const listingLogMatch = listReporterFile.stderr.match(/^Rush full log: (.+)$/m); +if ( + !listingOutput.includes('@rushstack/rush-reporter') || + listReporterFile.stdout !== '' || + !listingLogMatch || + !fs.readFileSync(listingLogMatch[1], 'utf8').includes('@rushstack/rush-reporter') +) { + throw new Error('Non-phased command output bypassed reporter stdout ownership or its full-detail log.'); +} for (const [name, events] of [ ['json', jsonEvents], @@ -151,6 +178,7 @@ for (const [name, events] of [ } } } +validateHeftOutput(parseNdjson(heftChild, 'Heft child')); const logMatch = plaintext.match(/^Full log: (.+)$/m); if (!logMatch || !path.isAbsolute(logMatch[1]) || !fs.existsSync(logMatch[1])) { diff --git a/apps/rush/src/test/sandbox/reporter-demo/validateHeftOutput.mjs b/apps/rush/src/test/sandbox/reporter-demo/validateHeftOutput.mjs new file mode 100644 index 0000000000..354ee25c29 --- /dev/null +++ b/apps/rush/src/test/sandbox/reporter-demo/validateHeftOutput.mjs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const operationId = '@rushstack/rush-reporter#_phase:build'; + +export function validateHeftOutput(events, platform = process.platform) { + const childEvents = events.filter((event) => event.parentSessionId); + const outputEvents = events.filter( + (event) => event.type === 'externalOutput' && event.scope?.operationId === operationId + ); + if ( + outputEvents.some( + (event, index) => + !Number.isSafeInteger(event.sequence) || + (index > 0 && event.sequence <= outputEvents[index - 1].sequence) || + (event.payload?.stream !== 'stdout' && event.payload?.stream !== 'stderr') || + typeof event.payload?.text !== 'string' || + Buffer.byteLength(event.payload.text, 'utf8') > 64 * 1024 + ) + ) { + throw new Error('The Heft operation output was invalid, out of order, or above the chunk limit.'); + } + + if (platform === 'win32') { + const rawOutput = outputEvents.map((event) => event.payload.text).join(''); + if ( + childEvents.length !== 0 || + !rawOutput.includes('---- build started ----') || + /"kind"\s*:\s*"hello(?:Ack)?"/.test(rawOutput) + ) { + throw new Error('The Windows Heft shell launcher did not preserve readable raw fallback output.'); + } + return; + } + + if (childEvents.length === 0) { + throw new Error('The current Heft child did not negotiate structured reporting.'); + } + if ( + childEvents.some( + (event, index) => + !Number.isSafeInteger(event.sourceSequence) || + (index > 0 && event.sourceSequence <= childEvents[index - 1].sourceSequence) + ) + ) { + throw new Error('The current Heft child source sequence was not preserved in order.'); + } + if ( + childEvents.some( + (event) => + event.source?.packageName !== '@rushstack/heft' || + !event.parentRequestId || + !event.parentOperationId || + event.scope?.operationId !== event.parentOperationId + ) + ) { + throw new Error('The current Heft child events were not correlated to their parent operation.'); + } +} diff --git a/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 0000000000..0405adb230 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a bounded nonce-protected install-run-rush handoff, replay it before version selection, and bridge cross-version reporter compatibility.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 0000000000..465b34aaf2 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Relay compatible Heft child events through the selected Rush reporter with ordered raw fallback and problem matcher diagnostics.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/daemon-watch-policy-4747_2026-09-07.json b/common/changes/@microsoft/rush/daemon-watch-policy-4747_2026-09-07.json new file mode 100644 index 0000000000..21d7ae59cc --- /dev/null +++ b/common/changes/@microsoft/rush/daemon-watch-policy-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Document daemon.watch as persistent host observation of warm projects, defaulting to root/config guards only without enabling automatic builds.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/daemon-windows-path-environment_2026-09-07.json b/common/changes/@microsoft/rush/daemon-windows-path-environment_2026-09-07.json new file mode 100644 index 0000000000..0f39c7bd16 --- /dev/null +++ b/common/changes/@microsoft/rush/daemon-windows-path-environment_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve the inherited Windows Path when preparing native daemon operation environments.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/graph-prepared-refresh_2026-09-06.json b/common/changes/@microsoft/rush/graph-prepared-refresh_2026-09-06.json new file mode 100644 index 0000000000..6d7158dec3 --- /dev/null +++ b/common/changes/@microsoft/rush/graph-prepared-refresh_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow hosts to discard unstarted graph iterations without executing scripts or closing retained runners, preserving completed results for safe replanning.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@microsoft/rush/native-entry-contracts_2026-09-08.json b/common/changes/@microsoft/rush/native-entry-contracts_2026-09-08.json new file mode 100644 index 0000000000..e4661d122b --- /dev/null +++ b/common/changes/@microsoft/rush/native-entry-contracts_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve the original native Rushx registration path unless an explicit invocation namespace is supplied, and release fixture-owned native parser locks after frontend tests.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/preserve-live-purge-lock_2026-09-08.json b/common/changes/@microsoft/rush/preserve-live-purge-lock_2026-09-08.json new file mode 100644 index 0000000000..2473350d0f --- /dev/null +++ b/common/changes/@microsoft/rush/preserve-live-purge-lock_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve the active repository mutex and its Windows interrupted-owner marker when purging temporary files.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/propagate-aggregate-runner-cleanup_2026-09-09.json b/common/changes/@microsoft/rush/propagate-aggregate-runner-cleanup_2026-09-09.json new file mode 100644 index 0000000000..fd410d73ab --- /dev/null +++ b/common/changes/@microsoft/rush/propagate-aggregate-runner-cleanup_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve unexpected aggregate cleanup errors in per-operation runner lifetime handling so dependents do not execute after a failed cleanup notification.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/reporter-dag-review-fixes_2026-09-07.json b/common/changes/@microsoft/rush/reporter-dag-review-fixes_2026-09-07.json new file mode 100644 index 0000000000..46d281e70a --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-dag-review-fixes_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve custom bootstrap controls and legacy environment overrides, keep direct command output within reporter-owned destinations, and suppress duplicate watch presentation.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-environment-native-engine_2026-09-06.json b/common/changes/@microsoft/rush/reporter-environment-native-engine_2026-09-06.json new file mode 100644 index 0000000000..a3fbd250d6 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-environment-native-engine_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Recognize frontend-owned reporter environment controls when native engines or rushx execute without frontend environment scrubbing.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@microsoft/rush/reporter-heft-review-fixes_2026-09-09.json b/common/changes/@microsoft/rush/reporter-heft-review-fixes_2026-09-09.json new file mode 100644 index 0000000000..54107d5589 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-heft-review-fixes_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Fail corrupted negotiated Heft reporter streams while preserving genuine raw fallback, and validate the demo's intentional Windows fallback.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/reporter-stdout-compatibility_2026-09-09.json b/common/changes/@microsoft/rush/reporter-stdout-compatibility_2026-09-09.json new file mode 100644 index 0000000000..576bb83361 --- /dev/null +++ b/common/changes/@microsoft/rush/reporter-stdout-compatibility_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve machine-readable stdout ownership for additional reporter outputs during engine compatibility handoff.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/review-base-control-composition_2026-09-10.json b/common/changes/@microsoft/rush/review-base-control-composition_2026-09-10.json new file mode 100644 index 0000000000..3a3cf60e16 --- /dev/null +++ b/common/changes/@microsoft/rush/review-base-control-composition_2026-09-10.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve both resolved reporter-control stripping lists when bootstrap compatibility falls back to legacy output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json b/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json new file mode 100644 index 0000000000..526946c83c --- /dev/null +++ b/common/changes/@microsoft/rush/review-r6-cleanup_2026-09-09-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Close initialized reporter destinations when bootstrap host creation fails without replacing the original startup error.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/review-r6-disposal-abort_2026-09-09-14-50.json b/common/changes/@microsoft/rush/review-r6-disposal-abort_2026-09-09-14-50.json new file mode 100644 index 0000000000..ac698892fb --- /dev/null +++ b/common/changes/@microsoft/rush/review-r6-disposal-abort_2026-09-09-14-50.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Forward startup failures into reporter cancellation before awaiting initialization cleanup.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/runner-lifetime-completion_2026-09-06-14-26.json b/common/changes/@microsoft/rush/runner-lifetime-completion_2026-09-06-14-26.json new file mode 100644 index 0000000000..eab51e4248 --- /dev/null +++ b/common/changes/@microsoft/rush/runner-lifetime-completion_2026-09-06-14-26.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Close non-persistent operation runners before dependents execute, including skipped and cached operations, and wait for IPC child streams to close. Preserve the default persistent behavior and existing per-iteration configuration hook.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/rushd-client_2026-09-06.json b/common/changes/@microsoft/rush/rushd-client_2026-09-06.json new file mode 100644 index 0000000000..6738aee4d1 --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-client_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Publish validated opt-in daemon configuration and environment overrides. Watch and warm-set settings remain explicitly documented integration seams.", + "type": "minor" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/rushd-iteration-lease-4747_2026-09-06.json b/common/changes/@microsoft/rush/rushd-iteration-lease-4747_2026-09-06.json new file mode 100644 index 0000000000..5edc3d066a --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-iteration-lease-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Release the native phased engine lock while idle and expose iteration-scoped execution leases. Support native rig/inherited project configuration with isolated loaders and request-time graph/cache validation, without modifying shared configuration caches.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/rushd-native-engine-4747_2026-09-06.json b/common/changes/@microsoft/rush/rushd-native-engine-4747_2026-09-06.json new file mode 100644 index 0000000000..de00eab52d --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-native-engine-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose a bounded native phased-command engine factory that reuses Rush command parsing and the all-project operation graph pipeline without CLI process-state mutation, with explicit lifetime cleanup and native lock ownership.", + "type": "minor" + } + ] +} diff --git a/common/changes/@microsoft/rush/rushd-native-path-aliases_2026-09-07.json b/common/changes/@microsoft/rush/rushd-native-path-aliases_2026-09-07.json new file mode 100644 index 0000000000..09d644efb2 --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-native-path-aliases_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Resolve native daemon command working directories physically before enforcing workspace confinement and native project selection, accepting Windows short paths and workspace aliases while rejecting symlink escapes.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/rushd-warmset-4747_2026-09-06.json b/common/changes/@microsoft/rush/rushd-warmset-4747_2026-09-06.json new file mode 100644 index 0000000000..a863401147 --- /dev/null +++ b/common/changes/@microsoft/rush/rushd-warmset-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Support guarded idle result eviction with detached execution contexts, optional measured IPC runner RSS, and awaited IPC closure without treating an interrupted protocol result as success. Document generation-attached daemon warm policies without changing ordinary native runner persistence.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/rushx-execution_2026-09-06.json b/common/changes/@microsoft/rush/rushx-execution_2026-09-06.json new file mode 100644 index 0000000000..5a9540f866 --- /dev/null +++ b/common/changes/@microsoft/rush/rushx-execution_2026-09-06.json @@ -0,0 +1,15 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose native Rushx parsing and request-local lifecycle execution with owned asynchronous spawning, isolated dotenv preparation, native diagnostics and injected-dependency synchronization.", + "type": "minor" + }, + { + "packageName": "@microsoft/rush", + "comment": "Allow the standalone Rushx frontend to capture and replay native configuration-discovery lines without changing legacy startup output.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/rushx-invocation-namespace_2026-09-07.json b/common/changes/@microsoft/rush/rushx-invocation-namespace_2026-09-07.json new file mode 100644 index 0000000000..e9d956d93d --- /dev/null +++ b/common/changes/@microsoft/rush/rushx-invocation-namespace_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Preserve native Rushx invocation/configuration path namespaces for isolated lifecycle execution, including Windows aliases and registration diagnostics.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@microsoft/rush/warm-ipc-process-factory_2026-09-07.json b/common/changes/@microsoft/rush/warm-ipc-process-factory_2026-09-07.json new file mode 100644 index 0000000000..571960ec86 --- /dev/null +++ b/common/changes/@microsoft/rush/warm-ipc-process-factory_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Allow an explicit IPC process factory to reuse native lifecycle environment, stdio and process-tree ownership without passing Node's IPC channel through an intermediate shell. Existing default runner behavior is unchanged.", + "type": "patch" + } + ] +} diff --git a/common/changes/@microsoft/rush/windows-ipc-resource-order_2026-09-07.json b/common/changes/@microsoft/rush/windows-ipc-resource-order_2026-09-07.json new file mode 100644 index 0000000000..8493ad3e16 --- /dev/null +++ b/common/changes/@microsoft/rush/windows-ipc-resource-order_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Assert IPC cleanup resolves after close without assuming an operating-system-specific delay between exit and close events.", + "type": "none" + } + ] +} diff --git a/common/changes/@microsoft/rush/ws3-explicit-node-ipc_2026-09-09.json b/common/changes/@microsoft/rush/ws3-explicit-node-ipc_2026-09-09.json new file mode 100644 index 0000000000..9148b560f0 --- /dev/null +++ b/common/changes/@microsoft/rush/ws3-explicit-node-ipc_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add false-default persistent daemon Node runners selected by explicit per-operation daemonIpc descriptors, including the SDK declaration proxy. Preserve raw custom arguments, canonical hashes, native/rebuild/NoOp/shard behavior, and bounded implementation-tree reloads. Keep watch-only IPC unchanged and decode IPC stdout/stderr incrementally.", + "type": "minor" + } + ] +} diff --git a/common/changes/@microsoft/rush/ws3-lifecycle-4747_2026-09-06.json b/common/changes/@microsoft/rush/ws3-lifecycle-4747_2026-09-06.json new file mode 100644 index 0000000000..b2bae8fdb8 --- /dev/null +++ b/common/changes/@microsoft/rush/ws3-lifecycle-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Expose stable workspace/runtime input fingerprints and safe borrowing of an already-held native preparation lock for generation-scoped engine reload.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 0000000000..42fc090934 --- /dev/null +++ b/common/changes/@rushstack/heft/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Emit negotiated structured child output and diagnostics when Rush provides a reporter descriptor, while preserving standalone terminal behavior.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/heft/preserve-child-reporter-private-members_2026-09-08.json b/common/changes/@rushstack/heft/preserve-child-reporter-private-members_2026-09-08.json new file mode 100644 index 0000000000..1b2df38249 --- /dev/null +++ b/common/changes/@rushstack/heft/preserve-child-reporter-private-members_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Preserve negotiated child-reporter routing and scoped diagnostic accounting when integrating native private members.", + "type": "patch" + } + ], + "packageName": "@rushstack/heft" +} diff --git a/common/changes/@rushstack/heft/reporter-windows-pipes_2026-09-07.json b/common/changes/@rushstack/heft/reporter-windows-pipes_2026-09-07.json new file mode 100644 index 0000000000..2a0379cc6d --- /dev/null +++ b/common/changes/@rushstack/heft/reporter-windows-pipes_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Recognize inherited reporter pipes on Windows while continuing to reject regular-file descriptors.", + "type": "patch" + } + ], + "packageName": "@rushstack/heft", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/node-core-library/windows-native-mutex_2026-09-07.json b/common/changes/@rushstack/node-core-library/windows-native-mutex_2026-09-07.json new file mode 100644 index 0000000000..04dee1eb1b --- /dev/null +++ b/common/changes/@rushstack/node-core-library/windows-native-mutex_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Use native Windows exclusive sharing for file mutexes and preserve dirty-owner state across close/delete handoffs.", + "type": "patch" + } + ], + "packageName": "@rushstack/node-core-library" +} diff --git a/common/changes/@rushstack/operation-graph/rushd-warmset-4747_2026-09-06.json b/common/changes/@rushstack/operation-graph/rushd-warmset-4747_2026-09-06.json new file mode 100644 index 0000000000..2b7bdd2e12 --- /dev/null +++ b/common/changes/@rushstack/operation-graph/rushd-warmset-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/operation-graph", + "comment": "Include an optional measured process RSS sample in WatchLoop IPC completion messages for compatible hosts' best-effort warm resource accounting.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-cli-client/admission-controls_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/admission-controls_2026-09-06.json new file mode 100644 index 0000000000..7b2f14e816 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/admission-controls_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Parse --no-wait and --wait-timeout into daemon admission options, preserve script arguments, and report queue failures without leaking controls to scripts.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-cli-client/fixture-git-line-endings_2026-09-08.json b/common/changes/@rushstack/rush-cli-client/fixture-git-line-endings_2026-09-08.json new file mode 100644 index 0000000000..94a0a46055 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/fixture-git-line-endings_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Configure generated Git fixtures locally to preserve LF files without Windows autocrlf warnings; leave user settings and CI warning policy unchanged.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/graph-generation-4747_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/graph-generation-4747_2026-09-07.json new file mode 100644 index 0000000000..e4c21f882f --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/graph-generation-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Fence graph mutations against stale workspace generations and support explicit snapshot tokens.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/hosted-ci-rushx-pipe-fixture_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/hosted-ci-rushx-pipe-fixture_2026-09-07.json new file mode 100644 index 0000000000..77f87f7dde --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/hosted-ci-rushx-pipe-fixture_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Use Rush's native pre-LTS warning control in the Rushx parity fixture so raw binary stream assertions remain runtime-independent.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/joined-launcher-fixture-cleanup_2026-09-08.json b/common/changes/@rushstack/rush-cli-client/joined-launcher-fixture-cleanup_2026-09-08.json new file mode 100644 index 0000000000..29c9a304f9 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/joined-launcher-fixture-cleanup_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Join the PIDs reported by fixture daemon start/restart commands, then reuse bounded Windows sharing cleanup after launcher and host joins without weakening lifecycle assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-cli-rendering_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/native-cli-rendering_2026-09-06.json new file mode 100644 index 0000000000..c2ac0402a6 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-cli-rendering_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Route native build/rebuild requests to the production engine and render operation events with awaited output, retaining raw global streams and native reporter-control fallback.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-early-stdin-fixture_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/native-early-stdin-fixture_2026-09-07.json new file mode 100644 index 0000000000..ee001c8487 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-early-stdin-fixture_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Accept Windows EOF alongside EPIPE when a test script exits without reading stdin, preserving its authoritative exit status and byte-for-byte native parity assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-fixture-callback-lifetime_2026-09-08.json b/common/changes/@rushstack/rush-cli-client/native-fixture-callback-lifetime_2026-09-08.json new file mode 100644 index 0000000000..58e6fb46d1 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-fixture-callback-lifetime_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Bind native CLI test continuations to their own fixtures, join complete callbacks before cleanup, and separate initial daemon preparation from reload and restart assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-graph-cli-fixtures_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/native-graph-cli-fixtures_2026-09-07.json new file mode 100644 index 0000000000..8cdae17859 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-graph-cli-fixtures_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Split real graph CLI scenarios, exercise graceful Windows signal cancellation, and join fixture invocations before cleanup.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-reuse-setup_2026-09-08.json b/common/changes/@rushstack/rush-cli-client/native-reuse-setup_2026-09-08.json new file mode 100644 index 0000000000..32e38b4fc5 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-reuse-setup_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Separate initial native daemon preparation from warm-reuse and Rushx parity assertions while preserving execution deadlines and all assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/native-workspace-identity_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/native-workspace-identity_2026-09-07.json new file mode 100644 index 0000000000..49727077fc --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/native-workspace-identity_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Use native filesystem canonicalization so Windows short-path aliases address the same daemon as the host.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/registry-miss-fixture_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/registry-miss-fixture_2026-09-07.json new file mode 100644 index 0000000000..f42120937e --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/registry-miss-fixture_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Isolate selected-version metadata failure with native .npmrc configuration, allow its real subprocess startup budget, and join CLI child closure before fixture cleanup.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/restart-retry-4747_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/restart-retry-4747_2026-09-07.json new file mode 100644 index 0000000000..5a86af9703 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/restart-retry-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Retry guaranteed pre-execution hard-restart outcomes once and forward opt-in native install/update requests.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/retention-gate-publication_2026-09-11.json b/common/changes/@rushstack/rush-cli-client/retention-gate-publication_2026-09-11.json new file mode 100644 index 0000000000..f20c3ab932 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/retention-gate-publication_2026-09-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Atomically publish IPC retention fixture gates and inputs, consume only complete event frames, and correlate pressure samples with their unique request.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/retention-pressure-handshake_2026-09-11.json b/common/changes/@rushstack/rush-cli-client/retention-pressure-handshake_2026-09-11.json new file mode 100644 index 0000000000..6a5287eba5 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/retention-pressure-handshake_2026-09-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Confirm real IPC retention-test memory pressure after allocation and one measured adjustment before releasing admission, preserving retention policies, budgets, and deadlines.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/rushd-client_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/rushd-client_2026-09-06.json new file mode 100644 index 0000000000..57ecb8d6e3 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/rushd-client_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Add separate opt-in rush-client and rushx-client bins with safe in-process fallback, protocol-based daemon start/status/stop/restart, and local launcher-log reading; document host integration blockers without replacing rush/rushx.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/rushd-graph-4747_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/rushd-graph-4747_2026-09-06.json new file mode 100644 index 0000000000..7dd626de0f --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/rushd-graph-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Implement RUSH_DAEMON_EXPERIMENTAL-gated daemon graph show/status/scope-in/scope-out/invalidate/watch/pause/resume as a public wire/core NDJSON client with no native fallback.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-cli-client/rushx-execution_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/rushx-execution_2026-09-06.json new file mode 100644 index 0000000000..99a3c0bcd3 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/rushx-execution_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Tag Rushx requests explicitly, reuse native argument boundaries and frontend discovery output, and validate real daemon script parity with native Rushx.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/rushx-invocation-namespace_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/rushx-invocation-namespace_2026-09-07.json new file mode 100644 index 0000000000..ae905a4d94 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/rushx-invocation-namespace_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Add exact native Rushx alias parity regressions for lifecycle paths, project registration, pnpm-sync diagnostics and safe pre-execution rejection.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/stdin-lifecycle_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/stdin-lifecycle_2026-09-06.json new file mode 100644 index 0000000000..6217ff961e --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/stdin-lifecycle_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Enable negotiated piped stdin and EOF while preserving native fallback input and treating pipe Ctrl+C bytes as data.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-cli-client/version-selected-launcher_2026-09-07.json b/common/changes/@rushstack/rush-cli-client/version-selected-launcher_2026-09-07.json new file mode 100644 index 0000000000..e59b84e743 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/version-selected-launcher_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Add asynchronous version-selected daemon connection preparation and prevent the synchronous launcher from impersonating a different Rush engine.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/version-skew-restart_2026-09-06.json b/common/changes/@rushstack/rush-cli-client/version-skew-restart_2026-09-06.json new file mode 100644 index 0000000000..5f3a47cf35 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/version-skew-restart_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Reuse ownership-checked shutdown for explicit restart and automatically recover implementation-version skew when the selected launcher is available.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-cli-client/windows-log-follow_2026-09-09.json b/common/changes/@rushstack/rush-cli-client/windows-log-follow_2026-09-09.json new file mode 100644 index 0000000000..8ff4fa7fd3 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/windows-log-follow_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Keep Windows daemon-log following cancellable behind unread stdout using bounded, acknowledged output and an explicitly joined output-only process; preserve snapshot, redirection and I/O-error behavior.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-cli-client/ws3-explicit-node-ipc_2026-09-09.json b/common/changes/@rushstack/rush-cli-client/ws3-explicit-node-ipc_2026-09-09.json new file mode 100644 index 0000000000..0b5e201606 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/ws3-explicit-node-ipc_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Document the explicit Node-only persistent daemon path and cover production launch, measured retention, raw Unicode output, cancellation, NoOp/shards, and descriptor/implementation reload through the public client.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-cli-client/ws4-acceptance-fixes_2026-09-09.json b/common/changes/@rushstack/rush-cli-client/ws4-acceptance-fixes_2026-09-09.json new file mode 100644 index 0000000000..60324773c9 --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/ws4-acceptance-fixes_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Keep unknown interactive Rushx scripts in-process before consuming input, preserve one cancellable graph admission budget across generation preflight and mutation, and add bounded daemon logs --follow.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-cli-client" +} diff --git a/common/changes/@rushstack/rush-client-core/atomic-fixture-pid-marker_2026-09-08.json b/common/changes/@rushstack/rush-client-core/atomic-fixture-pid-marker_2026-09-08.json new file mode 100644 index 0000000000..dffe02a972 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/atomic-fixture-pid-marker_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Publish the pre-bind test PID marker atomically so a starter cannot read an empty newly-created file as PID zero; retain single-daemon and ownership assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/hosted-ci-startup-lifetime_2026-09-07.json b/common/changes/@rushstack/rush-client-core/hosted-ci-startup-lifetime_2026-09-07.json new file mode 100644 index 0000000000..5698ddffd8 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/hosted-ci-startup-lifetime_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Join detached startup helper closure without prematurely disconnecting IPC, keep its cwd outside the launched workspace, and drain failed launcher processes before reporting startup failure.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/no-replay-after-terminal-control_2026-09-06.json b/common/changes/@rushstack/rush-client-core/no-replay-after-terminal-control_2026-09-06.json new file mode 100644 index 0000000000..d552c2f8f9 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/no-replay-after-terminal-control_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Reject in-process fallback after terminal control, even without a stdin source, while restoring raw mode and avoiding command replay.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-client-core/prebind-startup-handoff_2026-09-07.json b/common/changes/@rushstack/rush-client-core/prebind-startup-handoff_2026-09-07.json new file mode 100644 index 0000000000..63a0ce58a9 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/prebind-startup-handoff_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Hand detached daemon startup to an independent helper with a durable pre-bind reservation. Prevent duplicate launches after the starting client dies, preserve explicit launch commands and ownership-based restart, and fail closed on ambiguous launcher failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/restart-retry-4747_2026-09-07.json b/common/changes/@rushstack/rush-client-core/restart-retry-4747_2026-09-07.json new file mode 100644 index 0000000000..35125ae6f5 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/restart-retry-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Support one ownership-attested pre-execution restart retry, cancellation during handoff, and native mutation protocol admission.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/reuse-native-startup-lock_2026-09-07.json b/common/changes/@rushstack/rush-client-core/reuse-native-startup-lock_2026-09-07.json new file mode 100644 index 0000000000..d18a9ab3a3 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/reuse-native-startup-lock_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Reuse the validated native Windows LockFile exclusion for startup instead of maintaining a separate pipe mutex, preserving durable crash reservations and fail-closed handoff behavior.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/rushd-client_2026-09-06.json b/common/changes/@rushstack/rush-client-core/rushd-client_2026-09-06.json new file mode 100644 index 0000000000..f1022e20b5 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/rushd-client_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Introduce the opt-in daemon client with immutable requests, backpressured output, cancellation, raw input cleanup, private append-only launcher logs, serialized detached startup, and protocol-gated shutdown with bounded ownership-release coordination.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/rushx-execution_2026-09-06.json b/common/changes/@rushstack/rush-client-core/rushx-execution_2026-09-06.json new file mode 100644 index 0000000000..62cc0aba82 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/rushx-execution_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Capture the native invocation kind, reject Rushx peers older than protocol 0.8 before request submission, and prevent fallback after output or stdin admission.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/startup-readiness-cancellation_2026-09-07.json b/common/changes/@rushstack/rush-client-core/startup-readiness-cancellation_2026-09-07.json new file mode 100644 index 0000000000..8442dac35e --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/startup-readiness-cancellation_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Honor cancellation during daemon hello/pong readiness, close newly ready clients when cancellation wins, and preserve abort reasons instead of treating handshake failures as retryable.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/stdin-lifecycle_2026-09-06.json b/common/changes/@rushstack/rush-client-core/stdin-lifecycle_2026-09-06.json new file mode 100644 index 0000000000..127f83b31b --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/stdin-lifecycle_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Stream bounded stdin chunks only after admission and write credits, deliver ordered EOF, and preserve input for fallback to older peers.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-client-core", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-client-core/version-skew-restart_2026-09-06.json b/common/changes/@rushstack/rush-client-core/version-skew-restart_2026-09-06.json new file mode 100644 index 0000000000..6c45513fa0 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/version-skew-restart_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Replace a mismatched daemon implementation under the startup mutex using an explicit launcher and verified ownership, before submitting any request.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-client-core", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-client-core/windows-startup-sharing_2026-09-07.json b/common/changes/@rushstack/rush-client-core/windows-startup-sharing_2026-09-07.json new file mode 100644 index 0000000000..d3ab20955b --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/windows-startup-sharing_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Use an OS-owned Windows startup mutex and keep sharing-denied ownership reads unknown until the existing handoff deadline, preserving fail-closed startup and no-replay behavior.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core" +} diff --git a/common/changes/@rushstack/rush-client-core/ws3-closing-readiness_2026-09-09.json b/common/changes/@rushstack/rush-client-core/ws3-closing-readiness_2026-09-09.json new file mode 100644 index 0000000000..3f30738293 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/ws3-closing-readiness_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Retry pre-request handshake resets within the startup deadline while a closing daemon retains resource ownership. Preserve fail-closed ownership checks and prohibit replay after execution begins.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/daemon-watch-policy-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon-protocol/daemon-watch-policy-4747_2026-09-07.json new file mode 100644 index 0000000000..d3ea8e5f3f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/daemon-watch-policy-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Report and validate the effective watch setting in warm status configuration while accepting its omission by older peers.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/graph-generation-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon-protocol/graph-generation-4747_2026-09-07.json new file mode 100644 index 0000000000..76e00187ba --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/graph-generation-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add generation-fenced experimental graph mutations in protocol 0.9.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/reload-tier-status-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon-protocol/reload-tier-status-4747_2026-09-07.json new file mode 100644 index 0000000000..2deaf92ba8 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/reload-tier-status-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add optional validated lastReloadTier workspace status metadata for initial/reuse, successful reload, and requested restart; accept omission by older peers.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/restart-retry-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon-protocol/restart-retry-4747_2026-09-07.json new file mode 100644 index 0000000000..6b04ee330e --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/restart-retry-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add protocol 0.10 native mutation and guaranteed pre-execution restart results.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/rushd-graph-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon-protocol/rushd-graph-4747_2026-09-06.json new file mode 100644 index 0000000000..05e16cc02a --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/rushd-graph-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Define JSON-safe experimental graph snapshot DTOs and the rushd.graph-snapshot extension name without changing transport contracts or runtime dependencies.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/rushd-lifecycle-4747_2026-09-06-14-40.json b/common/changes/@rushstack/rush-daemon-protocol/rushd-lifecycle-4747_2026-09-06-14-40.json new file mode 100644 index 0000000000..04ac94a942 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/rushd-lifecycle-4747_2026-09-06-14-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add protocol 0.6 acknowledged shutdown controls and optional process identity and resident-memory status fields.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/rushx-execution_2026-09-06.json b/common/changes/@rushstack/rush-daemon-protocol/rushx-execution_2026-09-06.json new file mode 100644 index 0000000000..42eaa443a2 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/rushx-execution_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add the validated optional Rush/Rushx invocation kind in protocol 0.8 without changing legacy request routing.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/stdin-lifecycle_2026-09-06.json b/common/changes/@rushstack/rush-daemon-protocol/stdin-lifecycle_2026-09-06.json new file mode 100644 index 0000000000..ffd0378361 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/stdin-lifecycle_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add protocol 0.7 stdin admission/write-credit and EOF controls with explicit capability negotiation.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/warm-status-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon-protocol/warm-status-4747_2026-09-07.json new file mode 100644 index 0000000000..780f2559dc --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/warm-status-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add optional validated workspace generation and warm-resource accounting to pong without changing request/rejection types or the protocol 0.9 generation-fencing contract.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/ws3-explicit-node-ipc_2026-09-09.json b/common/changes/@rushstack/rush-daemon-protocol/ws3-explicit-node-ipc_2026-09-09.json new file mode 100644 index 0000000000..f4e756057a --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/ws3-explicit-node-ipc_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add optional validated raw warm-project ranking inputs to workspace status while preserving unknown measurements and older-peer compatibility.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon-transport/rushd-handoff-4747_2026-09-06-14-50.json b/common/changes/@rushstack/rush-daemon-transport/rushd-handoff-4747_2026-09-06-14-50.json new file mode 100644 index 0000000000..48a7415c77 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-transport/rushd-handoff-4747_2026-09-06-14-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-transport", + "comment": "Retain daemon ownership while connections stop, refuse a still-live owner, and make endpoint release idempotent across successor startup.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-transport", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon-transport/rushd-startup-cleanup-4747_2026-09-06-15-00.json b/common/changes/@rushstack/rush-daemon-transport/rushd-startup-cleanup-4747_2026-09-06-15-00.json new file mode 100644 index 0000000000..d041d37fb4 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-transport/rushd-startup-cleanup-4747_2026-09-06-15-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-transport", + "comment": "Close a newly bound endpoint if publishing daemon ownership fails, avoiding an orphaned listener after failed startup.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon-transport", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/canonical-warm-fixture_2026-09-07.json b/common/changes/@rushstack/rush-daemon/canonical-warm-fixture_2026-09-07.json new file mode 100644 index 0000000000..dddb099c49 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/canonical-warm-fixture_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Use the physical graph fixture root so warm-policy and watcher expectations agree with canonical workspace paths, including Windows short-name aliases.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/ci-daemon-setup-ownership_2026-09-11.json b/common/changes/@rushstack/rush-daemon/ci-daemon-setup-ownership_2026-09-11.json new file mode 100644 index 0000000000..550f997bd6 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/ci-daemon-setup-ownership_2026-09-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Own and join graph test fixture initialization during timed-out setup without changing hook deadlines or production behavior.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/composite-lifecycle-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/composite-lifecycle-4747_2026-09-06.json new file mode 100644 index 0000000000..d5226f5ce2 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/composite-lifecycle-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Expose composable workspace lifecycle capabilities, preserve resolver decorators across generation reloads, dispose generation-owned resolvers, and pass explicit rushx invocations through without phased or graph interception.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/daemon-watch-policy-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon/daemon-watch-policy-4747_2026-09-07.json new file mode 100644 index 0000000000..46f663973f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/daemon-watch-policy-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Apply daemon.watch to persistent project file observation with safe idle teardown/re-attachment, preserved runners/results and truthful cleanup accounting. Keep root/config guards and explicit request reconciliation active; never schedule autonomous scripts.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/fixture-git-line-endings_2026-09-08.json b/common/changes/@rushstack/rush-daemon/fixture-git-line-endings_2026-09-08.json new file mode 100644 index 0000000000..59d54a2a2c --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/fixture-git-line-endings_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Configure generated Git fixtures locally to preserve LF files without Windows autocrlf warnings; leave user settings and CI warning policy unchanged.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/graph-generation-test-lifetime_2026-09-07.json b/common/changes/@rushstack/rush-daemon/graph-generation-test-lifetime_2026-09-07.json new file mode 100644 index 0000000000..0880d20f5f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/graph-generation-test-lifetime_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Separate real initial graph preparation from soft-reload reference assertions and join timed-out fixture work before cleanup/replacement. Preserve default assertion budgets and all generation-fencing behavior.", + "type": "none" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/graph-prepared-refresh_2026-09-06.json b/common/changes/@rushstack/rush-daemon/graph-prepared-refresh_2026-09-06.json new file mode 100644 index 0000000000..3753e0f22d --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/graph-prepared-refresh_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Refresh prepared graph work under the native execution lease before resuming, preventing stale snapshots and native-command overlap.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/hosted-ci-startup-lifetime_2026-09-07.json b/common/changes/@rushstack/rush-daemon/hosted-ci-startup-lifetime_2026-09-07.json new file mode 100644 index 0000000000..3685efc4a0 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/hosted-ci-startup-lifetime_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Wait for selected installer closure and strengthen launcher and native-mutation fixture teardown, retaining startup diagnostics and keeping fixture socket paths short.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/joined-graph-fixture-cleanup_2026-09-08.json b/common/changes/@rushstack/rush-daemon/joined-graph-fixture-cleanup_2026-09-08.json new file mode 100644 index 0000000000..e453acb2a7 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/joined-graph-fixture-cleanup_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Remove graph fixtures only after successful host closure, using the existing bounded Windows sharing cleanup after owned successor exit.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/linux-descendant-quiescence_2026-09-08.json b/common/changes/@rushstack/rush-daemon/linux-descendant-quiescence_2026-09-08.json new file mode 100644 index 0000000000..4ecbc747a2 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/linux-descendant-quiescence_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Await bounded Linux child-group quiescence after termination and stream closure before completing global requests, preserving immediate descendant-stop assertions and surfacing cleanup inspection failures.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/measured-generation-and-native-setup_2026-09-08.json b/common/changes/@rushstack/rush-daemon/measured-generation-and-native-setup_2026-09-08.json new file mode 100644 index 0000000000..c6a8aaba84 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/measured-generation-and-native-setup_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Share measured telemetry expectations across explicit and automatic warm-set fixtures, and give the real selection-revalidation subprocess test its scoped integration budget.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/measured-retention-expectations_2026-09-08.json b/common/changes/@rushstack/rush-daemon/measured-retention-expectations_2026-09-08.json new file mode 100644 index 0000000000..e5907bc343 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/measured-retention-expectations_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Derive the native warm-retention test's expected order from actual cold/reused timings, request frequency and IPC RSS instead of assuming a fixed project is faster on every CI host; preserve real eviction and contrasting LRU assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-fixture-hooks-cleanup_2026-09-07.json b/common/changes/@rushstack/rush-daemon/native-fixture-hooks-cleanup_2026-09-07.json new file mode 100644 index 0000000000..65067a1c8b --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-fixture-hooks-cleanup_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Use native event-hook command conventions in partial-mutation fixtures and bounded Windows sharing cleanup after selected-launcher process joins.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-fixture-watch-paths_2026-09-07.json b/common/changes/@rushstack/rush-daemon/native-fixture-watch-paths_2026-09-07.json new file mode 100644 index 0000000000..b01c046e1f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-fixture-watch-paths_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Use native physical paths for standalone lifecycle fixture watchers so Windows short-name TEMP aliases do not trigger libuv path assertions.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-graph-setup-budget_2026-09-08.json b/common/changes/@rushstack/rush-daemon/native-graph-setup-budget_2026-09-08.json new file mode 100644 index 0000000000..7931373a2b --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-graph-setup-budget_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Use an explicit 15-second integration budget for real native graph hosts and Git/build subprocesses after repeated Windows setup timeouts; retain every generation assertion, cleanup limit and production deadline.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-retention-fixture-budget_2026-09-08.json b/common/changes/@rushstack/rush-daemon/native-retention-fixture-budget_2026-09-08.json new file mode 100644 index 0000000000..f5cba273a0 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-retention-fixture-budget_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Isolate native retention fixtures from the surrounding Jest worker's RSS, following the graph-fixture pattern; leave production memory defaults and dedicated pressure tests unchanged.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-workspace-identity_2026-09-07.json b/common/changes/@rushstack/rush-daemon/native-workspace-identity_2026-09-07.json new file mode 100644 index 0000000000..af32b4bbbb --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-workspace-identity_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Load workspace sessions and selected installations using native canonical paths, including Windows short-name aliases.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/native-wrong-version-test-budget_2026-09-08.json b/common/changes/@rushstack/rush-daemon/native-wrong-version-test-budget_2026-09-08.json new file mode 100644 index 0000000000..4df520cb9f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/native-wrong-version-test-budget_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Give the wrong-version launcher rejection integration test a 15-second budget for its two isolated Node processes, without changing production deadlines.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/reload-tier-status-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon/reload-tier-status-4747_2026-09-07.json new file mode 100644 index 0000000000..1c19a0b0f2 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/reload-tier-status-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Expose the lifecycle-owned last reload tier through a shared live pong and host-status projection, without triggering initialization or deriving a tier from generation changes.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/restart-retry-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon/restart-retry-4747_2026-09-07.json new file mode 100644 index 0000000000..2fe0d02e01 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/restart-retry-4747_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Drain explicit pre-execution restart results for accepted requests before closing a retiring host.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-client_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushd-client_2026-09-06.json new file mode 100644 index 0000000000..75a20266fc --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-client_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Validate daemon startup configuration and forward idleTimeoutSeconds for the WS3 host lifecycle integration.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-graph-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushd-graph-4747_2026-09-06.json new file mode 100644 index 0000000000..2977e9f7e0 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-graph-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Add gated native graph metadata, dependency-safe scope and invalidation controls, manual-mode pause/resume, and bounded lease-free observation using existing request and extension-event contracts.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-handoff-4747_2026-09-06-14-50.json b/common/changes/@rushstack/rush-daemon/rushd-handoff-4747_2026-09-06-14-50.json new file mode 100644 index 0000000000..ffe8df5662 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-handoff-4747_2026-09-06-14-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Keep endpoint ownership until resolver and workspace cleanup succeed, preventing successor startup against still-resident engine state.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-idle-shutdown-4747_2026-09-06-14-30.json b/common/changes/@rushstack/rush-daemon/rushd-idle-shutdown-4747_2026-09-06-14-30.json new file mode 100644 index 0000000000..2aa1be0f4d --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-idle-shutdown-4747_2026-09-06-14-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Add opt-in idle shutdown that protects pending requests through output drain and cleanup.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-iteration-lease-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushd-iteration-lease-4747_2026-09-06.json new file mode 100644 index 0000000000..a59e48c01c --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-iteration-lease-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Acquire one native execution lease per coalesced batch, retain it through reconciliation and output/runner cleanup, and release it before results so native commands can run while the daemon is idle. Permit explicit retries after initialization contention and detect changed rig/inherited configuration before execution.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-management-4747_2026-09-06-14-40.json b/common/changes/@rushstack/rush-daemon/rushd-management-4747_2026-09-06-14-40.json new file mode 100644 index 0000000000..90d5beba02 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-management-4747_2026-09-06-14-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Support protocol-negotiated acknowledged shutdown and report live process identity and resident memory in status probes.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-native-engine-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushd-native-engine-4747_2026-09-06.json new file mode 100644 index 0000000000..3d58d3687b --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-native-engine-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Bind positively identified native build/rebuild requests to a production all-project warm engine, preserve exact native selections and cache behavior, refresh inputs between requests, and reject incompatible command shapes or environments before execution.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/rushd-warmset-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushd-warmset-4747_2026-09-06.json new file mode 100644 index 0000000000..ccef52535f --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushd-warmset-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Add a generation-owned warm-set attachment with runtime idle, project-limit, sampled memory-budget and telemetry-ranking policies; exclusive scheduler/native leases, awaited runner and scoped watcher eviction, truthful pressure diagnostics, and real native graph integration coverage.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/rushx-execution_2026-09-06.json b/common/changes/@rushstack/rush-daemon/rushx-execution_2026-09-06.json new file mode 100644 index 0000000000..1437d0eec0 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushx-execution_2026-09-06.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Add an explicitly tagged native Rushx resolver and injectable composite resolver with request-owned lifecycle children, canonical cwd/environment overrides, raw output, stdin EOF, and early pipe-close handling.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/rushx-invocation-namespace_2026-09-07.json b/common/changes/@rushstack/rush-daemon/rushx-invocation-namespace_2026-09-07.json new file mode 100644 index 0000000000..3cd5ee9742 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/rushx-invocation-namespace_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Preserve Windows Rushx cwd, lifecycle environment and pnpm-sync path spelling while retaining physical workspace identity, confinement and admission-time alias checks.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/stdin-lifecycle_2026-09-06.json b/common/changes/@rushstack/rush-daemon/stdin-lifecycle_2026-09-06.json new file mode 100644 index 0000000000..15a32463d1 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/stdin-lifecycle_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Admit request input when its sink attaches, acknowledge drained writes, and forward EOF to owned child stdin without blocking cancellation.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/version-selected-launcher_2026-09-07.json b/common/changes/@rushstack/rush-daemon/version-selected-launcher_2026-09-07.json new file mode 100644 index 0000000000..f9c574735b --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/version-selected-launcher_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Select verified bundled or cached daemon installations and prepare exact-engine published releases with native installation APIs. Attest the actual engine and protocol in isolation before launching selected default daemon APIs.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/warm-generation-4747_2026-09-07.json b/common/changes/@rushstack/rush-daemon/warm-generation-4747_2026-09-07.json new file mode 100644 index 0000000000..f0de5865c4 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/warm-generation-4747_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Complete automatic warm-controller generation ownership, fencing cold/late attachment and awaiting pending initialization and maintenance before reload locks. Preserve ownership failures, start cold sessions with scoped watchers, expose non-initializing generation and truthful warm accounting in pong/status, and stop certifying evicted graph results from historical observer state.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/windows-fixture-sharing_2026-09-07.json b/common/changes/@rushstack/rush-daemon/windows-fixture-sharing_2026-09-07.json new file mode 100644 index 0000000000..57aaf06039 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/windows-fixture-sharing_2026-09-07.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Join owned successor processes before fixture teardown and bound Windows sharing retries without deleting fixtures after failed resource disposal.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-daemon/windows-warm-fixtures_2026-09-07.json b/common/changes/@rushstack/rush-daemon/windows-warm-fixtures_2026-09-07.json new file mode 100644 index 0000000000..aae834e5bd --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/windows-warm-fixtures_2026-09-07.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Exercise real warm Node IPC children directly on Windows, canonicalize fixture workspace paths for watcher expectations, preserve native active/protected lifetime coverage without timeout increases, and surface complete operation failures in test diagnostics.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/ws3-explicit-node-ipc_2026-09-09.json b/common/changes/@rushstack/rush-daemon/ws3-explicit-node-ipc_2026-09-09.json new file mode 100644 index 0000000000..e737f72f04 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/ws3-explicit-node-ipc_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Make measured telemetry retention reachable through explicit production Node IPC operations. Report raw ranking inputs and exclude only proven native resource-free NoOp nodes from child-memory scoring.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/ws3-lifecycle-4747_2026-09-06.json b/common/changes/@rushstack/rush-daemon/ws3-lifecycle-4747_2026-09-06.json new file mode 100644 index 0000000000..e018fe595b --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/ws3-lifecycle-4747_2026-09-06.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Reuse unchanged engines, reload changed configuration or command shape in-process under generation admission, and restart process-bound state through existing core ownership/startup contracts. Add isolated native install/update workers with result-before-restart ordering and no post-execution replay.", + "type": "minor" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/ws3-request-cleanup-ownership_2026-09-09.json b/common/changes/@rushstack/rush-daemon/ws3-request-cleanup-ownership_2026-09-09.json new file mode 100644 index 0000000000..72dd4ecaf4 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/ws3-request-cleanup-ownership_2026-09-09.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Retain failed request-resource ownership across result delivery, queued work, reload, shutdown and mutation restart. Keep the failed listener alive to prevent competing startup over unjoined children, while preserving ordinary failed-command restarts and successfully cleaned output failures.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/rush-daemon/ws4-terminal-context_2026-09-09.json b/common/changes/@rushstack/rush-daemon/ws4-terminal-context_2026-09-09.json new file mode 100644 index 0000000000..da91313f51 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/ws4-terminal-context_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Apply request-local Rushx TTY color and width after native lifecycle environment preparation while preserving explicit non-TTY environments and native path spelling.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 0000000000..aa1ab9990e --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Preserve ordered legacy fallback output when a bootstrap handoff protocol is incompatible.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json new file mode 100644 index 0000000000..8470287565 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r7a-heft-reporting_2026-08-28-08-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add bidirectional Heft child negotiation, parent-owned rendering context, bounded structured output, and streaming problem matcher recovery.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r8a-ai-gates_2026-08-28-08-45.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r8a-ai-gates_2026-08-28-08-45.json new file mode 100644 index 0000000000..aba0aed31d --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r8a-ai-gates_2026-08-28-08-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Add adversarial deterministic AI reporter qualification gates and privacy-safe actionable context.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json new file mode 100644 index 0000000000..d854555746 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-telemetry-privacy_2026-08-28-03-20-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Prevent non-public reporter events from contributing unvalidated or unbounded values to telemetry aggregates, and protect parent-owned producer and protocol metadata.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/post-giga-ai-test-isolation_2026-09-11.json b/common/changes/@rushstack/rush-reporter/post-giga-ai-test-isolation_2026-09-11.json new file mode 100644 index 0000000000..00a1d5010f --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/post-giga-ai-test-isolation_2026-09-11.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Isolate integration AI qualification test mutations and join worker cleanup after delayed, rejected, cancelled, or timed-out corpus work without changing existing test deadlines or production qualification gates.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/qualification-setup-budget_2026-09-08.json b/common/changes/@rushstack/rush-reporter/qualification-setup-budget_2026-09-08.json new file mode 100644 index 0000000000..71a3518132 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/qualification-setup-budget_2026-09-08.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Give the full qualification-corpus setup a bounded 15-second integration budget without changing any qualification or performance threshold.", + "type": "none" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/r8-native-windows-qualification_2026-09-09.json b/common/changes/@rushstack/rush-reporter/r8-native-windows-qualification_2026-09-09.json new file mode 100644 index 0000000000..18702fe785 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/r8-native-windows-qualification_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Coalesce buffered start acknowledgements superseded by final results without dropping final context or log references, keeping native Windows AI output within the unchanged raw-byte qualification budget.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/reporter-dag-review-fixes_2026-09-07.json b/common/changes/@rushstack/rush-reporter/reporter-dag-review-fixes_2026-09-07.json new file mode 100644 index 0000000000..e23eda9da3 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/reporter-dag-review-fixes_2026-09-07.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Refresh quiet interactive operations and emit plaintext heartbeats using unrefed timers that stop when reporters close.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/reporter-timer-failure-boundary_2026-09-09.json b/common/changes/@rushstack/rush-reporter/reporter-timer-failure-boundary_2026-09-09.json new file mode 100644 index 0000000000..377c1311f7 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/reporter-timer-failure-boundary_2026-09-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Run background renderer timers through the manager's failure boundary and cancel them on reporter failure or close without changing successful command outcomes.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/review-combined-secret-source_2026-09-09.json b/common/changes/@rushstack/rush-reporter/review-combined-secret-source_2026-09-09.json new file mode 100644 index 0000000000..666d8f8a6c --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-combined-secret-source_2026-09-09.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Prevent human diagnostic source metadata from revealing aliases of explicitly secret parameters while retaining unrelated local-sensitive details.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json b/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json new file mode 100644 index 0000000000..0b741f3733 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r6-cleanup_2026-09-09-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Dispose attempted reporter initialization safely and bound owned abandoned bootstrap handoffs by age and session count.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r6-disposal-abort_2026-09-09-14-50.json b/common/changes/@rushstack/rush-reporter/review-r6-disposal-abort_2026-09-09-14-50.json new file mode 100644 index 0000000000..952500e593 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r6-disposal-abort_2026-09-09-14-50.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Abort all attempted reporter initializations synchronously before serialized disposal, preserving the startup failure reason and once-only cleanup.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json new file mode 100644 index 0000000000..b26bd1297c --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r6-shared-close_2026-09-09-14-10.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Share once-only reporter close operations and serialized lifecycle ordering across manager shutdown and initialization disposal, including failed closes and lifecycle errors.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-reporter/review-r8-qualification_2026-09-09-13-00.json b/common/changes/@rushstack/rush-reporter/review-r8-qualification_2026-09-09-13-00.json new file mode 100644 index 0000000000..c809729dd4 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/review-r8-qualification_2026-09-09-13-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Measure actual AI output bytes, enforce the invocation-wide NDJSON budget, and qualify retained fallback context and exact remediation without inflating baselines.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter" +} diff --git a/common/changes/@rushstack/rush-terminal-renderer/native-cli-rendering_2026-09-06.json b/common/changes/@rushstack/rush-terminal-renderer/native-cli-rendering_2026-09-06.json new file mode 100644 index 0000000000..02b43ebf80 --- /dev/null +++ b/common/changes/@rushstack/rush-terminal-renderer/native-cli-rendering_2026-09-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-terminal-renderer", + "comment": "Decode split UTF-8 independently per operation/output stream and preserve global activity stderr and newline semantics.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-terminal-renderer", + "email": "selarkin@microsoft.com" +} diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index df558dcb2d..9420e1c366 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -46,6 +46,14 @@ "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/rush-client-core", + "allowedCategories": [ "libraries" ] + }, + { + "name": "@rushstack/rush-daemon", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/rush-daemon-protocol", "allowedCategories": [ "libraries", "tests" ] @@ -64,7 +72,7 @@ }, { "name": "@rushstack/rush-terminal-renderer", - "allowedCategories": [ "tests" ] + "allowedCategories": [ "libraries", "tests" ] }, { "name": "@rushstack/rush-themed-ui", diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 0f1ebb8d9a..1533ec419f 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -84,7 +84,7 @@ }, { "name": "@microsoft/rush", - "allowedCategories": [ "tests" ] + "allowedCategories": [ "libraries", "tests" ] }, { "name": "@microsoft/rush-lib", diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index a9c01ddb73..3e17dd0819 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -418,6 +418,46 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../apps/rush-cli-client: + dependencies: + '@microsoft/rush': + specifier: workspace:* + version: link:../rush + '@microsoft/rush-lib': + specifier: workspace:* + version: link:../../libraries/rush-lib + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/rush-client-core': + specifier: workspace:* + version: link:../../libraries/rush-client-core + '@rushstack/rush-daemon': + specifier: workspace:* + version: link:../../libraries/rush-daemon + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../../libraries/rush-daemon-protocol + '@rushstack/rush-daemon-transport': + specifier: workspace:* + version: link:../../libraries/rush-daemon-transport + '@rushstack/rush-terminal-renderer': + specifier: workspace:* + version: link:../../libraries/rush-terminal-renderer + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../apps/rush-mcp-server: dependencies: '@modelcontextprotocol/sdk': @@ -4102,6 +4142,28 @@ importers: specifier: ~9.37.0 version: 9.37.0 + ../../../libraries/rush-client-core: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../node-core-library + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../rush-daemon-protocol + '@rushstack/rush-daemon-transport': + specifier: workspace:* + version: link:../rush-daemon-transport + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../libraries/rush-daemon: dependencies: '@microsoft/rush-lib': @@ -4110,12 +4172,18 @@ importers: '@rushstack/node-core-library': specifier: workspace:* version: link:../node-core-library + '@rushstack/rush-client-core': + specifier: workspace:* + version: link:../rush-client-core '@rushstack/rush-daemon-protocol': specifier: workspace:* version: link:../rush-daemon-protocol '@rushstack/rush-daemon-transport': specifier: workspace:* version: link:../rush-daemon-transport + '@rushstack/rush-terminal-renderer': + specifier: workspace:* + version: link:../rush-terminal-renderer '@rushstack/terminal': specifier: workspace:* version: link:../terminal diff --git a/common/reviews/api/operation-graph.api.md b/common/reviews/api/operation-graph.api.md index 9f42710c67..2356792283 100644 --- a/common/reviews/api/operation-graph.api.md +++ b/common/reviews/api/operation-graph.api.md @@ -18,6 +18,7 @@ export type EventMessageFromClient = IRequestRunEventMessage | IAfterExecuteEven export interface IAfterExecuteEventMessage { // (undocumented) event: 'after-execute'; + residentMemoryBytes?: number; // (undocumented) status: OperationStatus; } diff --git a/common/reviews/api/rush-client-core.api.md b/common/reviews/api/rush-client-core.api.md new file mode 100644 index 0000000000..2305d7bad2 --- /dev/null +++ b/common/reviews/api/rush-client-core.api.md @@ -0,0 +1,133 @@ +## API Report File for "@rushstack/rush-client-core" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { IDaemonClientCaps } from '@rushstack/rush-daemon-protocol'; +import { IDaemonCommandResult } from '@rushstack/rush-daemon-protocol'; +import { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; +import { IDaemonLockfile } from '@rushstack/rush-daemon-transport'; +import { IDaemonPaths } from '@rushstack/rush-daemon-transport'; +import { IDaemonPongMessage } from '@rushstack/rush-daemon-protocol'; +import { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; +import { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; +import { IDaemonRequestRejectedMessage } from '@rushstack/rush-daemon-protocol'; +import type { Readable } from 'node:stream'; + +// @beta +export function captureDaemonRequest(options: ICaptureDaemonRequestOptions): IDaemonRequestEnvelope; + +// @beta +export function connectOrStartDaemonAsync(options: IConnectOrStartDaemonOptions): Promise; + +// @beta +export class DaemonClient { + // (undocumented) + closeAsync(): Promise; + // (undocumented) + static connectAsync(options: IDaemonClientConnectOptions): Promise; + executeAsync(options: IDaemonClientExecuteOptions): Promise; + get protocolVersion(): IDaemonProtocolVersion; + shutdownAsync(timeoutMs?: number): Promise; + get status(): Promise; +} + +// @beta +export class DaemonClientError extends Error { + constructor(code: DaemonClientErrorCode, message: string, options?: ErrorOptions); + // (undocumented) + readonly code: DaemonClientErrorCode; +} + +// @beta +export type DaemonClientErrorCode = 'timeout' | 'versionMismatch' | 'disconnected' | 'startupFailed'; + +// @beta +export type DaemonClientOutcome = { + readonly kind: 'result'; + readonly result: IDaemonCommandResult; +} | { + readonly kind: 'fallback'; + readonly reason: 'unsupported' | 'controllingTerminalRequired' | 'stdinEndUnsupported'; + readonly message?: string; +} | { + readonly kind: 'rejected'; + readonly rejection: IDaemonRequestRejectedMessage['payload']; +}; + +// @beta +export function executeWithDaemonRestartAsync(client: DaemonClient, connection: IConnectOrStartDaemonOptions, execution: IDaemonClientExecuteOptions): Promise; + +// @beta +export function getDaemonLogFilePath(paths: IDaemonPaths): string; + +// @beta +export interface ICaptureDaemonRequestOptions extends Omit { + // (undocumented) + readonly environment: Readonly>; + // (undocumented) + readonly requestId?: string; +} + +// @beta +export interface IConnectOrStartDaemonOptions extends Omit { + readonly abortSignal?: AbortSignal; + // (undocumented) + readonly paths: IDaemonPaths; + readonly previousDaemon?: Pick; + readonly startCommand?: IDaemonStartCommand; + readonly startupTimeoutMs?: number; +} + +// @beta +export interface IDaemonClientConnectOptions { + // (undocumented) + readonly capabilities?: IDaemonClientCaps; + // (undocumented) + readonly expectedDaemonVersion?: string; + // (undocumented) + readonly socketPath: string; + readonly timeoutMs?: number; +} + +// @beta +export interface IDaemonClientExecuteOptions { + // (undocumented) + readonly abortSignal?: AbortSignal; + readonly cancellationTimeoutMs?: number; + readonly cancelOnCtrlC?: boolean; + // (undocumented) + readonly initialRawMode?: boolean; + // (undocumented) + readonly onEventAsync?: (event: IDaemonEventEnvelope) => Promise; + // (undocumented) + readonly onQueuePositionAsync?: (position: number) => Promise; + // (undocumented) + readonly onStderrAsync?: (bytes: Uint8Array, operationId: string) => Promise; + // (undocumented) + readonly onStdoutAsync?: (bytes: Uint8Array, operationId: string) => Promise; + // (undocumented) + readonly request: IDaemonRequestEnvelope; + readonly requiresStdinEnd?: boolean; + // (undocumented) + readonly setRawMode?: (enabled: boolean) => void; + readonly stdin?: Readable; +} + +// @beta +export interface IDaemonStartCommand { + // (undocumented) + readonly args: ReadonlyArray; + // (undocumented) + readonly command: string; + // (undocumented) + readonly cwd: string; + // (undocumented) + readonly environment: Readonly>; +} + +// @beta +export function requestDaemonShutdownAsync(client: DaemonClient, paths: IDaemonPaths, timeoutMs?: number): Promise>; + +``` diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md index b5f298d236..409700cfaf 100644 --- a/common/reviews/api/rush-daemon-protocol.api.md +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -29,7 +29,11 @@ export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [ 'requestStart', 'requestCancel', 'requestRejected', -'requestResult' +'requestResult', +'shutdown', +'shutdownAck', +'stdinReady', +'stdinEnd' ]; // @beta @@ -51,9 +55,21 @@ export const DAEMON_EVENT_TYPES: readonly [ 'extension' ]; +// @beta +export const DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR: number; + +// @beta +export const DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR: number; + // @beta export const DAEMON_INTERACTIVE_IO_PROTOCOL_MINOR: number; +// @beta +export const DAEMON_INVOCATION_KIND_PROTOCOL_MINOR: number; + +// @beta +export const DAEMON_LIFECYCLE_PROTOCOL_MINOR: number; + // @beta export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion; @@ -63,11 +79,14 @@ export const DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR: number; // @beta export const DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR: number; +// @beta +export const DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR: number; + // @beta export type DaemonCommandOutcome = 'success' | 'success-with-warning' | 'failure' | 'aborted'; // @beta -export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonErrorMessage | IDaemonSetRawModeMessage | IDaemonRawModeChangedMessage | IDaemonTerminalPolicyMessage | IDaemonRequestQueuePositionMessage | IDaemonRequestStartMessage | IDaemonRequestCancelMessage | IDaemonRequestRejectedMessage | IDaemonRequestResultMessage; +export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonShutdownMessage | IDaemonShutdownAckMessage | IDaemonErrorMessage | IDaemonSetRawModeMessage | IDaemonStdinEndMessage | IDaemonStdinReadyMessage | IDaemonRawModeChangedMessage | IDaemonTerminalPolicyMessage | IDaemonRequestQueuePositionMessage | IDaemonRequestStartMessage | IDaemonRequestCancelMessage | IDaemonRequestRejectedMessage | IDaemonRequestResultMessage; // @beta export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; @@ -112,6 +131,9 @@ export type DaemonHandshakeOutcome = { readonly error: ProtocolVersionMismatchError; }; +// @beta +export type DaemonInvocationKind = 'rush' | 'rushx'; + // @beta export type DaemonJsonNull = null; @@ -200,6 +222,7 @@ export interface IDaemonClientCaps { readonly colorLevel?: number; readonly columns?: number; readonly isTTY: boolean; + readonly supportsInputLifecycle?: boolean; readonly supportsInteractiveIO?: boolean; readonly supportsRequestAdmission?: boolean; readonly supportsRequestLifecycle?: boolean; @@ -214,6 +237,7 @@ export interface IDaemonCommandResult { readonly exitCode: number; readonly outcome: DaemonCommandOutcome; readonly requestId: string; + readonly retryAfterRestart?: true; } // @beta @@ -286,6 +310,41 @@ export interface IDaemonFrameDecoderOptions { readonly maxPayloadBytes?: number; } +// @beta +export interface IDaemonGraphInvalidations { + // (undocumented) + readonly changedPathCount: number; + // (undocumented) + readonly hasUnknownChanges: boolean; + // (undocumented) + readonly isWatcherHealthy: boolean; + // (undocumented) + readonly sequence: number; +} + +// @beta +export interface IDaemonGraphOperation { + // (undocumented) + readonly dependencyIds: ReadonlyArray; + // (undocumented) + readonly enabled: false | DaemonPhasedOperationEnabledState; + // (undocumented) + readonly operationId: string; + // (undocumented) + readonly phaseName: string; + // (undocumented) + readonly projectName: string; + readonly status: string | null; +} + +// @beta +export interface IDaemonGraphSnapshotPayload { + // (undocumented) + readonly requestId: string; + // (undocumented) + readonly snapshot: IDaemonInitializedGraphSnapshot | IDaemonUninitializedGraphSnapshot; +} + // @beta export interface IDaemonHelloAckMessage { // (undocumented) @@ -307,6 +366,22 @@ export interface IDaemonHelloMessage { }; } +// @beta +export interface IDaemonInitializedGraphSnapshot { + // (undocumented) + readonly hasScheduledIteration: boolean; + // (undocumented) + readonly initialized: true; + // (undocumented) + readonly invalidations: IDaemonGraphInvalidations; + // (undocumented) + readonly operations: ReadonlyArray; + readonly pauseNextIteration: boolean; + // (undocumented) + readonly status: string; + readonly workspaceGeneration?: string; +} + // @beta export interface IDaemonLogChunk { readonly chunk: Uint8Array; @@ -393,6 +468,9 @@ export interface IDaemonPongMessage { readonly payload: { readonly daemonVersion?: string; readonly protocolVersion?: IDaemonProtocolVersion; + readonly pid?: number; + readonly residentMemoryBytes?: number; + readonly workspace?: IDaemonWorkspaceStatus; readonly uptimeMs: number; }; } @@ -443,6 +521,8 @@ export interface IDaemonRequestEnvelope { readonly commandOrigin: DaemonRushCommandOrigin; readonly cwd: string; readonly environment: Readonly>; + readonly expectedWorkspaceGeneration?: string; + readonly invocationKind?: DaemonInvocationKind; readonly requestId: string; readonly terminal: IDaemonRequestTerminal; } @@ -506,12 +586,48 @@ export interface IDaemonSetRawModeMessage { }; } +// @beta +export interface IDaemonShutdownAckMessage { + // (undocumented) + readonly kind: 'shutdownAck'; + // (undocumented) + readonly payload: Record; +} + +// @beta +export interface IDaemonShutdownMessage { + // (undocumented) + readonly kind: 'shutdown'; + // (undocumented) + readonly payload: Record; +} + // @beta export interface IDaemonStdinChunk { readonly chunk: Uint8Array; readonly requestId: string; } +// @beta +export interface IDaemonStdinEndMessage { + // (undocumented) + readonly kind: 'stdinEnd'; + // (undocumented) + readonly payload: { + readonly requestId: string; + }; +} + +// @beta +export interface IDaemonStdinReadyMessage { + // (undocumented) + readonly kind: 'stdinReady'; + // (undocumented) + readonly payload: { + readonly requestId: string; + }; +} + // @beta export interface IDaemonSubscribeMessage { // (undocumented) @@ -538,6 +654,15 @@ export interface IDaemonTerminalPolicyResult { readonly requestId: string; } +// @beta +export interface IDaemonUninitializedGraphSnapshot { + // (undocumented) + readonly initialized: false; + // (undocumented) + readonly invalidations: IDaemonGraphInvalidations; + readonly workspaceGeneration?: string; +} + // @beta export interface IDaemonUnsubscribeMessage { // (undocumented) @@ -546,6 +671,67 @@ export interface IDaemonUnsubscribeMessage { readonly payload: DaemonEmptyPayload; } +// @beta +export interface IDaemonWarmProjectRank { + // (undocumented) + readonly frequency: number; + readonly lastUsed: number; + readonly measuredRunnerMemoryBytes?: number; + // (undocumented) + readonly projectName: string; + // (undocumented) + readonly timeSavedMs?: number; +} + +// @beta +export interface IDaemonWarmSetConfiguration { + // (undocumented) + readonly autoWarmByTelemetry: boolean; + // (undocumented) + readonly warmIdleTimeoutSeconds: number; + // (undocumented) + readonly warmMemoryBudgetMB: number; + // (undocumented) + readonly warmSetMaxProjects: number; + readonly watch?: boolean; +} + +// @beta +export interface IDaemonWarmSetStatus { + // (undocumented) + readonly cleanupFailures: ReadonlyArray; + // (undocumented) + readonly configuration: IDaemonWarmSetConfiguration; + // (undocumented) + readonly daemonResidentMemoryBytes: number; + // (undocumented) + readonly deferredReason: 'workspace-busy' | 'native-busy' | 'graph-busy' | 'disposed' | undefined; + // (undocumented) + readonly maintenanceFailure?: string; + readonly maintenanceState: 'running' | 'quiescing' | 'stopped' | 'failed'; + readonly measuredRunnerMemoryBytes: number; + // (undocumented) + readonly overMemoryBudget: boolean; + // (undocumented) + readonly overProjectLimit: boolean; + readonly projectRanks?: ReadonlyArray; + // (undocumented) + readonly protectedProjectNames: ReadonlyArray; + readonly retainedProjectNames: ReadonlyArray; + readonly unmeasuredRunnerCount: number; + readonly watchedProjectNames: ReadonlyArray; +} + +// @beta +export interface IDaemonWorkspaceStatus { + // (undocumented) + readonly generation: number; + readonly generationToken?: string; + readonly graphInitialized: boolean; + readonly lastReloadTier?: number; + readonly warmSet?: IDaemonWarmSetStatus; +} + // @beta export function isDaemonControlMessageKind(value: unknown): value is DaemonControlMessageKind; @@ -616,6 +802,9 @@ export const REQUEST_ID_LENGTH_OFFSET: number; // @beta export const RUSHD_EXTENSION_NAMESPACE: 'rushd'; +// @beta +export const RUSHD_GRAPH_SNAPSHOT: 'rushd.graph-snapshot'; + // @beta export const RUSHD_OPERATION_HEADER: 'rushd.operation-header'; diff --git a/common/reviews/api/rush-daemon-transport.api.md b/common/reviews/api/rush-daemon-transport.api.md index 74c745e869..adb1942b0d 100644 --- a/common/reviews/api/rush-daemon-transport.api.md +++ b/common/reviews/api/rush-daemon-transport.api.md @@ -31,6 +31,7 @@ export class DaemonFrameConnection { export class DaemonFrameListener { closeAsync(): Promise; static listenAsync(paths: IDaemonPaths, options: IDaemonListenerOptions): Promise; + stopAcceptingAsync(): Promise; } // @beta diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md index d32db16524..40892a8409 100644 --- a/common/reviews/api/rush-daemon.api.md +++ b/common/reviews/api/rush-daemon.api.md @@ -9,8 +9,10 @@ import * as childProcess from 'node:child_process'; import type { DaemonRushCommandOrigin } from '@rushstack/rush-daemon-protocol'; import type { DaemonTerminalRequirement } from '@rushstack/rush-daemon-protocol'; +import * as fs from 'node:fs'; import type { GetInputsSnapshotAsyncFn } from '@microsoft/rush-lib'; import type { IDaemonCommandResult } from '@rushstack/rush-daemon-protocol'; +import { IDaemonConfigurationJson } from '@microsoft/rush-lib'; import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; import type { IDaemonPhasedRequest } from '@rushstack/rush-daemon-protocol'; @@ -19,11 +21,15 @@ import type { IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-prot import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; import type { IDaemonRequestQueuePositionMessage } from '@rushstack/rush-daemon-protocol'; import type { IDaemonSetRawModeMessage } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonStartCommand } from '@rushstack/rush-client-core'; import type { IDaemonTerminalPolicyResult } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonWarmSetStatus } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; import type { IInputsSnapshot } from '@microsoft/rush-lib'; -import type { IOperationGraph } from '@microsoft/rush-lib'; +import { IOperationGraph } from '@microsoft/rush-lib'; import type { ITerminal } from '@rushstack/terminal'; -import type { Operation } from '@microsoft/rush-lib'; +import type { LockFile } from '@rushstack/node-core-library'; +import { Operation } from '@microsoft/rush-lib'; import { RushConfiguration } from '@microsoft/rush-lib'; import type { RushConfigurationProject } from '@microsoft/rush-lib'; import type { RushSession } from '@microsoft/rush-lib'; @@ -38,7 +44,7 @@ export type CreateWorkspaceSessionComponentsAsync = (options: ICreateWorkspaceSe export class DaemonRequestDispatcher implements AsyncDisposable { // (undocumented) [Symbol.asyncDispose](): Promise; - constructor(workspaceSession: IWorkspaceSession, resolver?: IDaemonRequestResolver); + constructor(workspaceSession: IWorkspaceSession, resolver?: IDaemonRequestResolver, lifecycle?: IDaemonRequestLifecycle); // (undocumented) dispatchAsync(envelope: IDaemonRequestEnvelope, client: IDaemonRequestDispatchClient): Promise; } @@ -60,9 +66,18 @@ export class DaemonRequiresInProcessError extends Error { readonly policy: IDaemonTerminalPolicyResult; } +// @beta +export type DispatchWorkspaceRequestAsync = (options: IDispatchWorkspaceRequestOptions) => Promise; + // @beta export function evaluateDaemonTerminalPolicy(requestId: string, requirement?: DaemonTerminalRequirement): IDaemonTerminalPolicyResult; +// @beta +export function getWorkspaceGenerationToken(session: IWorkspaceSession): string; + +// @beta +export type GetWorkspaceSuccessorLaunchAsync = (context: IWorkspaceProcessRestartContext) => Promise; + // @beta export type GlobalCommandExecutor = (context: IGlobalCommandExecutionContext) => Promise; @@ -146,12 +161,34 @@ export interface IDaemonRequestDispatchClient { writeTerminalPolicyAsync(result: IDaemonTerminalPolicyResult): Promise; } +// @beta +export interface IDaemonRequestLifecycle extends AsyncDisposable { + // (undocumented) + dispatchAsync(envelope: IDaemonRequestEnvelope, client: IDaemonRequestDispatchClient, dispatchAsync: DispatchWorkspaceRequestAsync): Promise; +} + // @beta export interface IDaemonRequestResolver { // (undocumented) readonly [Symbol.asyncDispose]?: () => Promise; // (undocumented) resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise; + // (undocumented) + readonly workspaceLifecycle?: IWorkspaceResolverLifecycle; +} + +// @beta +export interface IDispatchWorkspaceRequestOptions { + // (undocumented) + readonly client: IDaemonRequestDispatchClient; + // (undocumented) + readonly envelope: IDaemonRequestEnvelope; + // (undocumented) + readonly onExecutionStarting?: () => void; + // (undocumented) + readonly resolver: IDaemonRequestResolver | undefined; + // (undocumented) + readonly workspaceSession: IWorkspaceSession; } // @beta @@ -183,6 +220,7 @@ export interface IGlobalCommandExecutionContext { readonly terminalProperties: IGlobalCommandTerminalProperties; // (undocumented) readonly workspaceSession: IWorkspaceSession; + writeOutput(stream: 'stdout' | 'stderr', chunk: Uint8Array): void; } // @beta @@ -207,6 +245,8 @@ export type IGlobalCommandRequestResult = IDaemonCommandResult; // @beta export interface IGlobalCommandSpawnOptions { + readonly cwd?: string; + readonly environment?: Readonly; // (undocumented) readonly environmentOverlay?: Readonly; // (undocumented) @@ -237,11 +277,13 @@ export interface IGlobalCommandTerminalProperties { export interface IInteractiveRequestControlClient { // (undocumented) readonly abortSignal: AbortSignal; + writeInputReadyAsync?(requestId: string): Promise; writeRawModeControlAsync(message: IDaemonSetRawModeMessage): Promise; } // @beta export interface IInteractiveRequestInputSink { + endInputAsync?(): Promise; // (undocumented) writeInputAsync(chunk: Uint8Array): Promise; } @@ -290,7 +332,7 @@ export class InteractiveInputRoutingError extends Error { } // @beta -export type InteractiveInputRoutingErrorCode = 'duplicateRequest' | 'unknownRequest' | 'completedRequest' | 'nonInteractiveRequest' | 'requestLimitExceeded'; +export type InteractiveInputRoutingErrorCode = 'duplicateRequest' | 'unknownRequest' | 'completedRequest' | 'nonInteractiveRequest' | 'inputEnded' | 'requestLimitExceeded'; // @beta export class InteractiveRequestInputRouter { @@ -298,6 +340,7 @@ export class InteractiveRequestInputRouter { markRequestCompleted(requestId: string): void; // (undocumented) register(options: IInteractiveRequestRegistrationOptions): IInteractiveRequestSession; + routeStdinEndAsync(requestId: string): Promise; // (undocumented) routeStdinFrameAsync(payload: Uint8Array): Promise; } @@ -353,6 +396,7 @@ export interface IResolvedDaemonGlobalRequest { // @beta export interface IResolvedDaemonPhasedRequest { + readonly exactSelection?: boolean; // (undocumented) readonly kind: 'phased'; // (undocumented) @@ -399,6 +443,8 @@ export interface IResolveGlobalCommandRequestOptions { export interface IRushDaemonHostOptions { readonly createWorkspaceSessionAsync?: WorkspaceSessionFactory; readonly daemonVersion: string; + readonly getSuccessorLaunchAsync?: GetWorkspaceSuccessorLaunchAsync; + readonly idleTimeoutSeconds?: number; readonly onError?: (error: Error) => void; readonly onInteractiveConnection?: (connection: IDaemonInteractiveConnection) => void; readonly repoRoot: string; @@ -413,6 +459,9 @@ export interface IRushDaemonServeOptions extends IRushDaemonHostOptions { readonly shutdownSignal?: AbortSignal; } +// @beta +export function isRushxInvocation(envelope: IDaemonRequestEnvelope): boolean; + // @beta export type IsWorkspaceEngineRecreationRequiredAsync = (options: IClassifyWorkspaceInvalidationsOptions) => Promise; @@ -424,13 +473,16 @@ export interface IWorkspaceEngineComponentFactoryOptions { readonly isEngineRecreationRequiredAsync?: IsWorkspaceEngineRecreationRequiredAsync; // (undocumented) readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync; + readonly refreshInputsOnEveryRequest?: boolean; // (undocumented) readonly shape: IWorkspaceEngineShape; + readonly validateGraphInputsAsync?: () => Promise; } // @beta export interface IWorkspaceEngineComponents extends AsyncDisposable { [Symbol.asyncDispose](): Promise; + readonly acquireExecutionLeaseAsync?: () => Promise; // (undocumented) readonly getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; // (undocumented) @@ -475,10 +527,41 @@ export interface IWorkspaceInvalidationWatcher extends AsyncDisposable { startAsync(onInvalidation: (changedPath?: string) => void): Promise; } +// @beta +export interface IWorkspaceProcessRestartContext { + // (undocumented) + readonly environment: Readonly>; + // (undocumented) + readonly reason: 'hard-input-change' | 'native-mutation'; + // (undocumented) + readonly repoRoot: string; + // (undocumented) + readonly rushVersion: string; +} + +// @beta +export interface IWorkspaceProcessRestartResult { + // (undocumented) + readonly pid: number; + // (undocumented) + readonly rushVersion: string; +} + +// @beta +export interface IWorkspaceResolverLifecycle { + // (undocumented) + createForSession(preparationLock?: LockFile, validateGraphInputsAsync?: () => Promise): IDaemonRequestResolver; + // (undocumented) + getCommandParameterIdentityAsync(options: IResolveDaemonRequestOptions): Promise; +} + // @beta export interface IWorkspaceSession extends AsyncDisposable { + acquireExecutionLeaseAsync?(): Promise; + assertActive?(): void; // (undocumented) readonly engineShape: IWorkspaceEngineShape | undefined; + initializeEngineAsync?(factory: CreateWorkspaceSessionComponentsAsync): Promise; // (undocumented) readonly inputsSnapshot: IInputsSnapshot | undefined; // (undocumented) @@ -487,16 +570,21 @@ export interface IWorkspaceSession extends AsyncDisposable { readonly metadata: IWorkspaceSessionMetadata; // (undocumented) readonly operationGraph: IOperationGraph | undefined; + quiesceWarmSetAsync?(): Promise; // (undocumented) reconcileInvalidationsAsync(): Promise; + retire?(): void; // (undocumented) readonly rushConfiguration: RushConfiguration; // (undocumented) readonly rushSession: RushSession | undefined; + // (undocumented) + readonly warmSetStatus?: IWorkspaceWarmSetStatus; } // @beta export interface IWorkspaceSessionComponents extends AsyncDisposable { + readonly acquireExecutionLeaseAsync?: () => Promise; // (undocumented) readonly engineShape?: IWorkspaceEngineShape; // (undocumented) @@ -510,8 +598,20 @@ export interface IWorkspaceSessionComponents extends AsyncDisposable { readonly rushSession?: RushSession; } +// @beta +export interface IWorkspaceSessionFileWatcherOptions { + // (undocumented) + readonly onError?: (error: Error) => void; + readonly projectNames?: Iterable; + // (undocumented) + readonly rushConfiguration: RushConfiguration; + // (undocumented) + readonly watchFactory?: WorkspaceWatchFactory; +} + // @beta export interface IWorkspaceSessionMetadata { + readonly generation?: number; // (undocumented) readonly projectCount: number; // (undocumented) @@ -529,6 +629,8 @@ export interface IWorkspaceSessionOptions { // (undocumented) readonly createComponentsAsync?: CreateWorkspaceSessionComponentsAsync; // (undocumented) + readonly generation?: number; + // (undocumented) readonly onError?: (error: Error) => void; // (undocumented) readonly repoRoot: string; @@ -536,13 +638,51 @@ export interface IWorkspaceSessionOptions { readonly rushVersion: string; } +// @beta +export interface IWorkspaceSuccessorLaunch { + // (undocumented) + readonly daemonVersion: string; + // (undocumented) + readonly startCommand: IDaemonStartCommand; +} + +// @beta +export interface IWorkspaceWarmSetOptions { + readonly acquireExecutionLeaseAsync: () => Promise; + // (undocumented) + readonly configuration: WorkspaceWarmSetConfiguration; + readonly getProtectedOperations?: () => ReadonlySet; + readonly onDiagnostic?: (error: Error) => void; + // (undocumented) + readonly operationGraph: IOperationGraph; + readonly scheduler: RequestScheduler; + readonly watcher: WorkspaceSessionFileWatcher; +} + +// @beta +export type IWorkspaceWarmSetStatus = IDaemonWarmSetStatus; + // @beta export type MapWorkspaceInvalidationsToOperationsAsync = (options: IMapWorkspaceInvalidationsOptions) => Promise>; // @beta export class PhasedRequestRouter { constructor(workspaceSession: IWorkspaceSession); - executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient): Promise; + executeAsync(request: IDaemonPhasedRequest, client: IPhasedRequestClient, exactSelection?: boolean, onExecutionStarting?: () => void): Promise; +} + +// @beta +export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { + constructor(options?: { + readonly preparationLock?: LockFile; + readonly validateGraphInputsAsync?: () => Promise; + }); + createForSession(preparationLock?: LockFile, validateGraphInputsAsync?: () => Promise): ProductionDaemonRequestResolver; + getCommandParameterIdentityAsync(options: IResolveDaemonRequestOptions): Promise; + // (undocumented) + resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise; + // (undocumented) + get workspaceLifecycle(): IWorkspaceResolverLifecycle; } // @public @@ -559,6 +699,7 @@ export enum RequestExclusivityClass { export class RequestScheduler { acquireAsync(options: IRequestSchedulerAcquireOptions): Promise; get activeRequestCount(): number; + downgradeExclusiveLease(lease: IRequestLease, target: RequestExclusivityClass.SharedBuild | RequestExclusivityClass.SharedRead): void; get queuedRequestCount(): number; } @@ -585,10 +726,31 @@ export type ResolvedDaemonRequest = IResolvedDaemonPhasedRequest | IResolvedDaem // @beta export class RushDaemonHost { closeAsync(): Promise; + readonly closed: Promise; getWorkspaceSessionAsync(): Promise; // (undocumented) readonly paths: IDaemonPaths; + readonly restartCompleted: Promise; static startAsync(options: IRushDaemonHostOptions): Promise; + get workspaceGeneration(): number; + get workspaceStatus(): IDaemonWorkspaceStatus; +} + +// @beta +export class RushDaemonRequestResolver implements IDaemonRequestResolver { + // (undocumented) + [Symbol.asyncDispose](): Promise; + constructor(rushResolver: IDaemonRequestResolver); + // (undocumented) + resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise; + // (undocumented) + readonly workspaceLifecycle: IWorkspaceResolverLifecycle | undefined; +} + +// @beta +export class RushXDaemonRequestResolver implements IDaemonRequestResolver { + // (undocumented) + resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise; } // @beta @@ -618,14 +780,20 @@ export class WorkspaceInvalidationTracker { // @internal (undocumented) invalidateForInitialization(): void; markWatcherUnhealthy(): void; + subscribe(notify: () => void): () => void; } // @beta export class WorkspaceSession implements IWorkspaceSession { [Symbol.asyncDispose](): Promise; + // (undocumented) + acquireExecutionLeaseAsync(): Promise; + // (undocumented) + assertActive(): void; static createAsync(options: IWorkspaceSessionOptions): Promise; // (undocumented) get engineShape(): IWorkspaceEngineShape | undefined; + initializeEngineAsync(factory: CreateWorkspaceSessionComponentsAsync): Promise; // (undocumented) get inputsSnapshot(): IInputsSnapshot | undefined; // (undocumented) @@ -633,17 +801,58 @@ export class WorkspaceSession implements IWorkspaceSession { // (undocumented) readonly metadata: IWorkspaceSessionMetadata; // (undocumented) - readonly operationGraph: IOperationGraph | undefined; + get operationGraph(): IOperationGraph | undefined; + // (undocumented) + quiesceWarmSetAsync(): Promise; reconcileInvalidationsAsync(): Promise; // (undocumented) + retire(): void; + // (undocumented) readonly rushConfiguration: RushConfiguration; // (undocumented) - readonly rushSession: RushSession | undefined; + get rushSession(): RushSession | undefined; + // (undocumented) + get warmSetStatus(): IWorkspaceWarmSetStatus | undefined; } // @beta export type WorkspaceSessionFactory = (options: IWorkspaceSessionOptions) => Promise; +// @beta +export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatcher { + // (undocumented) + [Symbol.asyncDispose](): Promise; + constructor(options: IWorkspaceSessionFileWatcherOptions); + // (undocumented) + startAsync(onInvalidation: (changedPath?: string) => void): Promise; + unwatchProjectsAsync(projectNames: Iterable): Promise; + get watchedProjectNames(): ReadonlySet; + watchProjects(projectNames: Iterable): void; +} + +// @beta +export class WorkspaceWarmSet implements AsyncDisposable { + // (undocumented) + [Symbol.asyncDispose](): Promise; + static attach(options: IWorkspaceWarmSetOptions): WorkspaceWarmSet; + static getAttached(graph: IOperationGraph): WorkspaceWarmSet | undefined; + getStatus(): IWorkspaceWarmSetStatus; + maintainAsync(): Promise; + updateConfiguration(configuration: WorkspaceWarmSetConfiguration): void; +} + +// @beta +export type WorkspaceWarmSetConfiguration = Pick; + +// @beta +export type WorkspaceWatchFactory = (folderPath: string, options: { + encoding: 'utf8'; + recursive: boolean; +}, listener: fs.WatchListener) => fs.FSWatcher; + +// @beta +export function wrapWorkspaceResolverLifecycle(resolver: IDaemonRequestResolver, wrap: (resolver: IDaemonRequestResolver) => IDaemonRequestResolver): IWorkspaceResolverLifecycle | undefined; + // (No @packageDocumentation comment for this package) ``` diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 847ef6ab16..4ceb0c9784 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -10,6 +10,7 @@ import { AsyncParallelHook } from 'tapable'; import { AsyncSeriesBailHook } from 'tapable'; import { AsyncSeriesHook } from 'tapable'; import { AsyncSeriesWaterfallHook } from 'tapable'; +import type * as child_process from 'node:child_process'; import type { CollatedWriter } from '@rushstack/stream-collator'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; @@ -23,6 +24,8 @@ import { IFileDiffStatus } from '@rushstack/package-deps-hash'; import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; +import { IReporterChildContext } from '@rushstack/rush-reporter'; +import { IReporterEventEnvelope } from '@rushstack/rush-reporter'; import { IReporterEventScope } from '@rushstack/rush-reporter'; import { IReporterEventSink } from '@rushstack/rush-reporter'; import { IRushDiagnostic } from '@rushstack/rush-reporter'; @@ -35,6 +38,7 @@ import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; +import { LockFile } from '@rushstack/node-core-library'; import { LookupByPath } from '@rushstack/lookup-by-path'; import { PackageNameParser } from '@rushstack/node-core-library'; import { parseReporterExtensionEventName } from '@rushstack/rush-reporter'; @@ -113,11 +117,20 @@ export enum BumpType { 'prerelease' = 1 } +// @alpha +export function captureProjectConfigurationFingerprintAsync(rushConfiguration: RushConfiguration, terminal: ITerminal): Promise; + +// @alpha +export function captureWorkspaceInputFingerprintAsync(options: IWorkspaceInputFingerprintOptions): Promise; + // @public export class ChangeManager { static createEmptyChangeFiles(rushConfiguration: RushConfiguration, projectName: string, emailAddress: string): string | undefined; } +// @alpha +export function classifyWorkspaceInputChange(current: IWorkspaceInputFingerprint, next: IWorkspaceInputFingerprint): WorkspaceInputChangeTier; + // Warning: (ae-forgotten-export) The symbol "IBuildCacheJson" needs to be exported by the entry point index.d.ts // // @beta (undocumented) @@ -223,6 +236,9 @@ export enum CustomTipType { rush = "rush" } +// @beta +export const daemonEnvironmentVariables: Readonly>; + // @public (undocumented) export enum DependencyType { // (undocumented) @@ -274,6 +290,8 @@ export class EnvironmentConfiguration { export const EnvironmentVariableNames: { readonly RUSH_TEMP_FOLDER: "RUSH_TEMP_FOLDER"; readonly RUSH_PREVIEW_VERSION: "RUSH_PREVIEW_VERSION"; + readonly RUSH_REPORTER: "RUSH_REPORTER"; + readonly RUSH_LOG_LEVEL: "RUSH_LOG_LEVEL"; readonly RUSH_ALLOW_UNSUPPORTED_NODEJS: "RUSH_ALLOW_UNSUPPORTED_NODEJS"; readonly RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD: "RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD"; readonly RUSH_VARIANT: "RUSH_VARIANT"; @@ -298,6 +316,17 @@ export const EnvironmentVariableNames: { readonly RUSH_INVOKED_FOLDER: "RUSH_INVOKED_FOLDER"; readonly RUSH_INVOKED_ARGS: "RUSH_INVOKED_ARGS"; readonly RUSH_QUIET_MODE: "RUSH_QUIET_MODE"; + readonly RUSH_DAEMON: "RUSH_DAEMON"; + readonly RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: "RUSH_DAEMON_IDLE_TIMEOUT_SECONDS"; + readonly RUSH_DAEMON_AUTO_START: "RUSH_DAEMON_AUTO_START"; + readonly RUSH_DAEMON_WATCH: "RUSH_DAEMON_WATCH"; + readonly RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS: "RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS"; + readonly RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS: "RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS"; + readonly RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS: "RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS"; + readonly RUSH_DAEMON_WARM_MEMORY_BUDGET_MB: "RUSH_DAEMON_WARM_MEMORY_BUDGET_MB"; + readonly RUSH_DAEMON_WARM_SET_MAX_PROJECTS: "RUSH_DAEMON_WARM_SET_MAX_PROJECTS"; + readonly RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY: "RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY"; + readonly RUSH_DAEMON_EXPERIMENTAL: "RUSH_DAEMON_EXPERIMENTAL"; }; // @beta @@ -482,6 +511,26 @@ export interface ICustomTipsJson { customTips?: ICustomTipItemJson[]; } +// @beta +export interface IDaemonConfigurationJson { + readonly autoStart?: boolean; + readonly autoWarmByTelemetry?: boolean; + readonly enabled?: boolean; + readonly idleTimeoutSeconds?: number; + readonly queueTimeoutSeconds?: number; + readonly usePersistentIpcRunners?: boolean; + readonly warmIdleTimeoutSeconds?: number; + readonly warmMemoryBudgetMB?: number; + readonly warmSetMaxProjects?: number; + readonly watch?: boolean; +} + +// @alpha +export interface IDaemonIpcConfiguration { + args?: string[]; + entryPoint: string; +} + // @beta (undocumented) export interface IEnvironmentConfigurationInitializeOptions { // (undocumented) @@ -538,6 +587,7 @@ export interface IGenerateCacheEntryIdOptions { export interface IGetChangedProjectsOptions { enableFiltering: boolean; excludeVersionOnlyChanges?: boolean; + getIncrementalBuildIgnoredGlobsAsync?: (project: RushConfigurationProject) => Promise | undefined>; includeExternalDependencies: boolean; // (undocumented) shouldFetch?: boolean; @@ -638,6 +688,18 @@ export interface _IOperationBuildCacheOptions { useDirectFileTransfersForBuildCache: boolean; } +// @internal +export interface _IOperationChildProcessReporter { + // (undocumented) + attachAsync(child: child_process.ChildProcess, structuredOutputTerminalProvider: ITerminalProvider): Promise; + // (undocumented) + readonly environment: Readonly>; + // (undocumented) + readonly hasWarningOrError: boolean; + // (undocumented) + readonly stdio: child_process.StdioOptions; +} + // @alpha export interface IOperationExecutionResult extends IBaseOperationExecutionResult, IOperationLastState { readonly enabled: boolean; @@ -661,6 +723,8 @@ export interface IOperationGraph { allowOversubscription: boolean; closeRunnersAsync(operations?: Iterable): Promise; debugMode: boolean; + deleteResults?(operations: Iterable): void; + discardScheduledIteration(): boolean; executeScheduledIterationAsync(): Promise; readonly hasScheduledIteration: boolean; readonly hooks: OperationGraphHooks; @@ -685,6 +749,7 @@ export interface IOperationGraphContext extends ICreateOperationsContext { // @internal export interface _IOperationGraphEventSink { + createChildProcessReporter?(operationId: string, iterationId: number): _IOperationChildProcessReporter | undefined; onActivity?(text: string, options?: _IOperationActivityOptions): void; onOperationChunk?(operationId: string, chunk: ITerminalChunk, result?: IOperationExecutionResult, iterationId?: number): void; onOperationCompleted?(result: IOperationExecutionResult): void; @@ -748,6 +813,7 @@ export interface IOperationRunner { readonly isNoOp?: boolean; readonly name: string; reportTiming: boolean; + readonly residentMemoryBytes?: number; silent: boolean; warningsAreAllowed: boolean; } @@ -755,6 +821,8 @@ export interface IOperationRunner { // @beta export interface IOperationRunnerContext { collatedWriter: CollatedWriter; + // @internal + createChildProcessReporter(): _IOperationChildProcessReporter | undefined; debugMode: boolean; environment: IEnvironment | undefined; error?: Error; @@ -762,7 +830,7 @@ export interface IOperationRunnerContext { // @internal _operationMetadataManager: _OperationMetadataManager; quietMode: boolean; - runWithTerminalAsync(callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, options: { + runWithTerminalAsync(callback: (terminal: ITerminal, terminalProvider: ITerminalProvider, structuredChildOutputTerminalProvider: ITerminalProvider) => Promise, options: { createLogFile: boolean; logFileSuffix?: string; }): Promise; @@ -774,6 +842,7 @@ export interface IOperationRunnerContext { // @alpha (undocumented) export interface IOperationSettings { allowCobuildWithoutCache?: boolean; + daemonIpc?: IDaemonIpcConfiguration; dependsOnAdditionalFiles?: string[]; dependsOnEnvVars?: string[]; dependsOnNodeVersion?: boolean | NodeVersionGranularity; @@ -822,6 +891,18 @@ export interface IParallelismScalar { readonly scalar: number; } +// @alpha +export interface IParsePhasedCommandOptions { + // (undocumented) + readonly argv: ReadonlyArray; + // (undocumented) + readonly cwd: string; + // (undocumented) + readonly rushConfiguration: RushConfiguration; + // (undocumented) + readonly terminalProvider: ITerminalProvider; +} + // @alpha export interface IPhase { allowWarningsOnSuccess: boolean; @@ -848,6 +929,27 @@ export interface IPhasedCommand extends IRushCommand { readonly sessionAbortController: AbortController; } +// @alpha +export interface IPhasedCommandEngine extends AsyncDisposable { + // (undocumented) + [Symbol.asyncDispose](): Promise; + readonly acquireExecutionLeaseAsync?: () => Promise; + // (undocumented) + readonly getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; + // (undocumented) + readonly inputsSnapshot: IInputsSnapshot; + // (undocumented) + readonly isIncremental: boolean; + // (undocumented) + readonly operationGraph: IOperationGraph; + // (undocumented) + readonly phaseNames: ReadonlyArray; + // (undocumented) + readonly pluginNames: ReadonlyArray; + // (undocumented) + readonly rushSession: RushSession; +} + // @alpha export interface IPhasedCommandPlugin { apply(hooks: PhasedCommandHooks): void; @@ -1019,6 +1121,12 @@ export interface IRushSessionOptions { // @beta export interface IRushSessionReporterOptions { + // @internal + readonly childProcessReporter?: { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + }; readonly eventSink: IReporterEventSink; // @internal readonly flushAsync?: () => Promise; @@ -1027,6 +1135,36 @@ export interface IRushSessionReporterOptions { readonly sessionId: string; } +// @beta +export interface IRushXCommandLineArguments { + commandArgs: string[]; + commandName: string; + help: boolean; + ignoreHooks: boolean; + isDebug: boolean; + quiet: boolean; +} + +// @beta +export interface IRushXCommandOptions { + // (undocumented) + readonly abortSignal?: AbortSignal; + readonly arguments: IRushXCommandLineArguments; + readonly consoleTerminal: ITerminal; + // (undocumented) + readonly cwd: string; + // (undocumented) + readonly environment: Readonly; + // (undocumented) + readonly launchOptions: ILaunchOptions; + // (undocumented) + readonly rushConfiguration: RushConfiguration | undefined; + readonly rushJsonFilePath?: string; + readonly spawn?: (command: string, args: ReadonlyArray, options: child_process.SpawnOptions) => child_process.ChildProcess; + // (undocumented) + readonly terminal: ITerminal; +} + export { IScopedLogger } export { IScopedMessageOptions } @@ -1100,6 +1238,30 @@ export interface IVersionPolicyJson { policyName: string; } +// @alpha +export interface IWorkspaceInputFingerprint { + // (undocumented) + readonly configurationHash: string; + // (undocumented) + readonly environmentHash: string; + // (undocumented) + readonly installationHash: string; + // (undocumented) + readonly runtimeHash: string; + // (undocumented) + readonly selectedRushVersion: string; +} + +// @alpha +export interface IWorkspaceInputFingerprintOptions { + // (undocumented) + readonly environment: Readonly>; + readonly runtimeCache?: WorkspaceRuntimeFingerprintCache; + readonly runtimePaths?: ReadonlyArray; + // (undocumented) + readonly rushConfiguration: RushConfiguration; +} + // @internal export interface _IYarnOptionsJson extends IPackageManagerOptionsJsonBase { ignoreEngines?: boolean; @@ -1176,6 +1338,7 @@ export class OperationGraphHooks { readonly afterExecuteOperationAsync: AsyncSeriesHook<[ IOperationRunnerContext & IOperationExecutionResult ]>; + readonly beforeDeleteResults: SyncHook<[ReadonlySet]>; readonly beforeExecuteIterationAsync: AsyncSeriesBailHook<[ ReadonlyMap, IOperationGraphIterationOptions @@ -1337,6 +1500,28 @@ export type Parallelism = number | IParallelismScalar; export { parseReporterExtensionEventName } +// @alpha +export class PhasedCommandEngine { + // (undocumented) + readonly commandName: string; + createEngineAsync(preparationLock?: LockFile): Promise; + // (undocumented) + readonly parameterIdentity: string; + // (undocumented) + static parseAsync(options: IParsePhasedCommandOptions): Promise; + selectOperationsAsync(graph: IOperationGraph): Promise>; +} + +// @alpha +export class PhasedCommandEngineBusyError extends Error { + constructor(); +} + +// @alpha +export class PhasedCommandEngineConfigurationChangedError extends Error { + constructor(); +} + // @alpha export class PhasedCommandHooks { readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ @@ -1406,7 +1591,7 @@ export type PnpmTrustPolicy = 'no-downgrade' | 'off'; export class ProjectChangeAnalyzer { constructor(rushConfiguration: RushConfiguration); // @internal (undocumented) - _filterProjectDataAsync(project: RushConfigurationProject, unfilteredProjectData: Map, rootDir: string, terminal: ITerminal): Promise>; + _filterProjectDataAsync(project: RushConfigurationProject, unfilteredProjectData: Map, rootDir: string, terminal: ITerminal, getIgnoreGlobsAsync?: IGetChangedProjectsOptions['getIncrementalBuildIgnoredGlobsAsync']): Promise>; getChangedProjectsAsync(options: IGetChangedProjectsOptions): Promise>; // (undocumented) protected getChangesByProject(lookup: LookupByPath, changedFiles: Map): Map>; @@ -1432,6 +1617,9 @@ export class RepoStateFile { refreshState(rushConfiguration: RushConfiguration, subspace: Subspace | undefined, variant?: string): boolean; } +// @beta +export function resolveDaemonConfiguration(json?: IDaemonConfigurationJson, environment?: Readonly>): Readonly>; + // @public export class Rush { static launch(launcherVersion: string, options: ILaunchOptions): void; @@ -1471,6 +1659,8 @@ export class RushConfiguration { readonly customTipsConfiguration: CustomTipsConfiguration; // @beta readonly customTipsConfigurationFilePath: string; + // @beta + readonly daemon: Readonly>; // @beta (undocumented) get defaultSubspace(): Subspace; // @deprecated @@ -1737,6 +1927,8 @@ export class RushProjectConfiguration { phaseName: string; isNoOp: boolean; }): string | undefined; + // @internal (undocumented) + _getJsonForFingerprint(): string; readonly incrementalBuildIgnoredGlobs: ReadonlyArray; // (undocumented) readonly operationSettingsByOperationName: ReadonlyMap>; @@ -1744,6 +1936,8 @@ export class RushProjectConfiguration { readonly project: RushConfigurationProject; static tryLoadForProjectAsync(project: RushConfigurationProject, terminal: ITerminal): Promise; static tryLoadForProjectsAsync(projects: Iterable, terminal: ITerminal): Promise>; + // @internal + static _tryLoadForProjectsUncachedAsync(projects: Iterable, terminal: ITerminal): Promise>; static tryLoadIgnoreGlobsForProjectAsync(project: RushConfigurationProject, terminal: ITerminal): Promise | undefined>; validatePhaseConfiguration(phases: Iterable, terminal: ITerminal): void; } @@ -1778,6 +1972,18 @@ export class RushUserConfiguration { static initializeAsync(): Promise; } +// @beta +export class RushXCommand { + // (undocumented) + static executeAsync(options: IRushXCommandOptions): Promise; + static getInProcessReason(args: IRushXCommandLineArguments, environment: Readonly, configuration: RushConfiguration): string | undefined; + // (undocumented) + static getPackageFolder(cwd: string): string; + // (undocumented) + static parseArguments(argv: ReadonlyArray, environment: Readonly): IRushXCommandLineArguments; + static prepareEnvironment(cwd: string, environment: Readonly, rushJsonFilePath: string): NodeJS.ProcessEnv; +} + // @public export class Subspace { // Warning: (ae-forgotten-export) The symbol "ISubspaceOptions" needs to be exported by the entry point index.d.ts @@ -1888,6 +2094,24 @@ export enum VersionPolicyDefinitionName { 'lockStepVersion' = 0 } +// @alpha +export enum WorkspaceInputChangeTier { + // (undocumented) + Reload = 1, + // (undocumented) + Restart = 2, + // (undocumented) + Reuse = 0 +} + +// @alpha +export class WorkspaceRuntimeFingerprintCache { + // @internal (undocumented) + get changedPaths(): ReadonlyArray; + // @internal (undocumented) + _hashPaths(paths: ReadonlyArray): string; +} + // @public export class YarnOptionsConfiguration extends PackageManagerOptionsConfigurationBase { // @internal diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index a11c6d1b87..442ad4dffa 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -7,6 +7,12 @@ import type { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; +// @beta +export const AI_REPORTER_QUALIFICATION_SCHEMA_VERSION: '1.0'; + +// @beta +export const AI_REPORTER_QUALIFICATION_THRESHOLDS: IAiReporterQualificationThresholds; + // @beta export class AiReporter implements IReporter { constructor(options: IAiReporterOptions); @@ -23,7 +29,7 @@ export class AiReporter implements IReporter { } // @beta -export function allocateChildDescriptor(fdNumber?: number): IChildDescriptorPlan; +export function allocateChildDescriptor(fdNumber?: number, ackFdNumber?: number): IChildDescriptorPlan; // @beta export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; @@ -117,7 +123,7 @@ export class DefaultInteractiveReporter implements IReporter { // (undocumented) flushAsync(): Promise; // (undocumented) - initializeAsync(): Promise; + initializeAsync(context?: IReporterContext): Promise; // (undocumented) readonly name: string; // (undocumented) @@ -139,6 +145,9 @@ export function detectAgent(env: Record, configuredV // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; +// @beta +export function evaluateAiReporterQualification(cases: readonly IAiReporterQualificationCaseResult[], thresholds?: IAiReporterQualificationThresholds): IAiReporterQualificationResult; + // @beta export function evaluatePluginApplyGate(manifests: readonly IRushPluginManifest[], options: IPluginApplyGateOptions): IPluginApplyDecision[]; @@ -170,6 +179,9 @@ export class FileReporter implements IReporter { // @beta export function filterEventsForLogLevel(logLevel: ReporterLogLevel, events: readonly IReporterEventEnvelope[]): IReporterEventEnvelope[]; +// @beta +export function formatAiReporterQualificationFailures(result: IAiReporterQualificationResult): string; + // @beta export function getBlockedPlugins(decisions: readonly IPluginApplyDecision[]): IPluginApplyDecision[]; @@ -182,6 +194,9 @@ export function getLogLevelRank(level: ReporterLogLevel): number; // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; +// @beta +export function getQualifiedAiReporterDecision(env: Record, configuredAgentEnvironmentVariables: readonly string[], qualification: IAiReporterQualificationResult | undefined, privacyPrerequisiteAccepted?: boolean): IQualifiedAiReporterDecision; + // @beta export function getReporterMigrationPhase(id: ReporterMigrationPhaseId): IReporterMigrationPhase; @@ -191,14 +206,18 @@ export function getSignalExitCode(signal: NodeJS.Signals): number; // @beta export class HeftChildEmitter { constructor(options: IHeftChildEmitterOptions); + acceptHelloAck(value: unknown): boolean; + get context(): IReporterChildContext | undefined; emitEvent(input: IHeftChildEventInput): string | undefined; - readonly mode: HeftChildReporterMode; + emitOutput(stream: 'stdout' | 'stderr', text: string, scope?: IReporterEventScope): readonly string[]; + handleAckDescriptorClose(): void; + get mode(): HeftChildReporterMode; sendHello(): boolean; writeRaw(stream: 'stdout' | 'stderr', text: string): void; } // @beta -export type HeftChildReporterMode = 'structured' | 'raw-fallback'; +export type HeftChildReporterMode = 'negotiation-pending' | 'structured' | 'raw-fallback'; // @beta export class HeftDescriptorHost { @@ -219,11 +238,18 @@ export interface IAiDiagnostic { // (undocumented) readonly code: string; // (undocumented) + readonly context?: Readonly>; + // (undocumented) + readonly detailKey?: string; + // (undocumented) + readonly diagnosticId?: string; + // (undocumented) readonly remediation?: readonly IRushRemediationAction[]; // (undocumented) readonly severity: string; // (undocumented) readonly summary?: string; + readonly summaryKey?: string; } // @beta @@ -280,6 +306,98 @@ export interface IAiReporterOptions { readonly write: (text: string) => void; } +// @beta +export interface IAiReporterQualificationCaseResult { + // (undocumented) + readonly actionable: boolean; + // (undocumented) + readonly aiOutputBytes: number; + // (undocumented) + readonly deterministic: boolean; + // (undocumented) + readonly expectedResult: 'succeeded' | 'failed'; + // (undocumented) + readonly failures: readonly string[]; + // (undocumented) + readonly fullLogValid: boolean; + // (undocumented) + readonly legacyOutputBytes: number; + // (undocumented) + readonly name: string; + // (undocumented) + readonly normalizedAiOutputSha256: string; + // (undocumented) + readonly plaintextOutputBytes: number; + // (undocumented) + readonly privacySafe: boolean; + // (undocumented) + readonly scenario: string; + // (undocumented) + readonly stdoutContractValid: boolean; + // (undocumented) + readonly warningContractValid: boolean; +} + +// @beta +export interface IAiReporterQualificationGateResult { + // (undocumented) + readonly actual: number; + // (undocumented) + readonly failedCases: readonly string[]; + // (undocumented) + readonly id: string; + // (undocumented) + readonly passed: boolean; + // (undocumented) + readonly threshold: string; +} + +// @beta +export interface IAiReporterQualificationResult { + // (undocumented) + readonly cases: readonly IAiReporterQualificationCaseResult[]; + // (undocumented) + readonly gates: readonly IAiReporterQualificationGateResult[]; + // (undocumented) + readonly passed: boolean; + // (undocumented) + readonly schemaVersion: typeof AI_REPORTER_QUALIFICATION_SCHEMA_VERSION; + // (undocumented) + readonly thresholds: IAiReporterQualificationThresholds; +} + +// @beta +export interface IAiReporterQualificationThresholds { + // (undocumented) + readonly deterministicRunCount: number; + // (undocumented) + readonly maximumAggregateAiToLegacyPercent: number; + // (undocumented) + readonly maximumAggregateAiToPlaintextPercent: number; + // (undocumented) + readonly maximumCompactCaseAiOutputBytes: number; + // (undocumented) + readonly maximumOutputBytesPerCase: number; + // (undocumented) + readonly maximumPerCaseAiToBaselinePercent: number; + // (undocumented) + readonly minimumActionableFailurePercent: number; + // (undocumented) + readonly minimumComparableBaselineBytes: number; + // (undocumented) + readonly minimumControlCases: number; + // (undocumented) + readonly minimumFailureCases: number; + // (undocumented) + readonly minimumFullLogPassPercent: number; + // (undocumented) + readonly minimumPrivacyPassPercent: number; + // (undocumented) + readonly minimumStdoutContractPassPercent: number; + // (undocumented) + readonly minimumWarningContractPassPercent: number; +} + // @beta export interface IAutomaticReporterPlan { readonly emergencyDestination: 'stderr'; @@ -332,14 +450,21 @@ export interface IBootstrapHandoffWriteResult { readonly nonce: string; } +// @beta +export interface IBootstrapLegacyOutput { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'unsupported-required-event' | 'incompatible-protocol'; } // @beta @@ -353,6 +478,7 @@ export interface IBootstrapTruncation { // @beta export interface IChildDescriptorPlan { + readonly ackFdNumber: number; readonly env: Record; readonly fdNumber: number; readonly stdio: (string | number)[]; @@ -530,12 +656,17 @@ export interface IHeftChildResult { // @beta export interface IHeftDescriptorHostOptions { + readonly context?: IReporterChildContext; readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; readonly onNegotiation?: (result: IReporterHandshakeResult) => void; readonly parentOperationId?: string; + readonly parentRequestId?: string; readonly parentSessionId: string; - readonly supportedCapabilities?: readonly string[]; + readonly sendHelloAck?: (ack: IReporterHelloAck) => void; + readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; + readonly trustedPrivacy?: ReporterPrivacyClassification; + readonly trustedSource?: IReporterEventSource; } // @beta @@ -595,6 +726,11 @@ export interface INdjsonOptions { readonly maxRecordBytes?: number; } +// @beta +export class InvalidReporterHelloAckError extends Error { + constructor(reason: string); +} + // @beta export class InvalidReporterHelloError extends Error { constructor(reason: string); @@ -702,6 +838,18 @@ export interface IProblemMatcherResult { readonly unmatchedLineCount: number; } +// @beta +export interface IQualifiedAiReporterDecision { + // (undocumented) + readonly agentDetected: boolean; + // (undocumented) + readonly eligible: boolean; + // (undocumented) + readonly reason: 'RUSH_REPORTER=legacy' | 'agent not detected' | 'qualification unavailable' | 'qualification failed' | 'privacy prerequisite unavailable' | 'qualified'; + // (undocumented) + readonly reporter?: 'ai'; +} + // @beta export interface IRenderLiveRegionOptions { readonly color: IColorizer; @@ -718,6 +866,14 @@ export interface IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export interface IReporterChildContext { + readonly color: boolean; + readonly logLevel: ReporterLogLevel; + readonly reporter: ReporterName; + readonly terminalWidth: number; +} + // @beta export interface IReporterCompatibilityDecision { readonly engineRendersLegacy: boolean; @@ -729,8 +885,10 @@ export interface IReporterCompatibilityDecision { // @beta export interface IReporterContext { + readonly abortSignal?: AbortSignal; readonly destination?: string; readonly protocolVersion: IReporterProtocolVersion; + readonly runWithErrorHandling?: (action: () => void) => void; } // @beta @@ -746,6 +904,7 @@ export interface IReporterEngineDescriptor { export interface IReporterEventEnvelope { readonly eventId: string; readonly parentOperationId?: string; + readonly parentRequestId?: string; readonly parentSessionId?: string; readonly payload: TPayload; readonly privacy: ReporterPrivacyClassification; @@ -794,6 +953,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { + readonly context?: IReporterChildContext; readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -817,6 +977,7 @@ export interface IReporterHello { // @beta export interface IReporterHelloAck { readonly acceptedCapabilities: readonly string[]; + readonly context?: IReporterChildContext; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; readonly rejectedRequiredFeatures: readonly string[]; @@ -877,6 +1038,10 @@ export interface IReporterPerformanceBudgets { readonly maxAiDetailedDiagnostics: number; readonly maxAiOutputBytes: number; readonly maxInteractiveRefreshHz: number; + readonly maxTelemetryDiagnosticCategories: number; + readonly maxTelemetryDiagnosticCodes: number; + readonly maxTelemetryProducerVersionLength: number; + readonly maxTelemetryProducerVersions: number; readonly maxWallTimeRegressionPercent: number; } @@ -942,6 +1107,7 @@ export interface IResolveExitStatusOptions { // @beta export interface IRunProblemMatchersOptions { readonly maxDuplicates?: number; + readonly maxPartialLineBytes?: number; } // @beta @@ -1267,7 +1433,7 @@ export function normalizeAnsi(text: string): string; // @beta export class OldEngineOutputAdapter { constructor(options: IOldEngineOutputAdapterOptions); - capture(stream: 'stdout' | 'stderr', text: string): string[]; + capture(stream: 'stdout' | 'stderr', text: string, wasRendered?: boolean): string[]; } // @beta @@ -1301,6 +1467,9 @@ export function parseReporterExtensionEventName(name: string): ReporterExtension // @beta export function parseReporterHello(value: unknown): IReporterHello; +// @beta +export function parseReporterHelloAck(value: unknown): IReporterHelloAck; + // @beta export class PlaintextReporter implements IReporter { constructor(options: IPlaintextReporterOptions); @@ -1310,7 +1479,7 @@ export class PlaintextReporter implements IReporter { // (undocumented) flushAsync(): Promise; // (undocumented) - initializeAsync(): Promise; + initializeAsync(context?: IReporterContext): Promise; // (undocumented) readonly name: string; // (undocumented) @@ -1332,6 +1501,18 @@ export class ProblemMatcherRegistry { register(matcher: IProblemMatcher): void; } +// @beta +export class ProblemMatcherRunner { + constructor(matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions); + flush(): readonly IRushDiagnostic[]; + get matchedLineCount(): number; + get result(): IProblemMatcherResult; + get suppressedDuplicateCount(): number; + get unmatchedLineCount(): number; + write(event: IReporterEventEnvelope): readonly IRushDiagnostic[]; + writeOutput(text: string, operationId?: string, stream?: string): readonly IRushDiagnostic[]; +} + // @beta export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ header: IBootstrapHandoffHeader | undefined; @@ -1339,6 +1520,9 @@ export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ discardedRecordCount: number; }>; +// @beta +export function readChildAckDescriptorFd(env: Record): number | undefined; + // @beta export function readChildDescriptorFd(env: Record): number | undefined; @@ -1361,7 +1545,7 @@ export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRe export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension", "operationStreamClosed", "operationCompleted"]; // @beta -export const REPORTER_KNOWN_CAPABILITIES: readonly []; +export const REPORTER_KNOWN_CAPABILITIES: readonly ["heft-child-events-v1", "reporter-context-v1"]; // @beta export const REPORTER_MIGRATION_PHASES: readonly IReporterMigrationPhase[]; @@ -1396,6 +1580,7 @@ export type ReporterExtensionEventName = `${string}.${string}` & { export class ReporterHost { constructor(options?: IReporterHostOptions); cleanAbandonedHandoffFilesAsync(): Promise; + discardBootstrapHandoffAsync(): Promise; getSink(): IReporterEventSink; get manager(): ReporterManager; replayBootstrapHandoffAsync(): Promise; @@ -1417,6 +1602,8 @@ export class ReporterManager implements IReporterEventSink { constructor(options?: IReporterManagerOptions); addReporter(reporter: IReporter, options?: IReporterRegistrationOptions): void; closeAsync(timeoutMs?: number): Promise; + // @internal + _disposeInitializedReportersAsync(failure?: unknown): Promise; emit(event: IReporterEmitEventInput): string; // @internal _flushAndConfirmAsync(timeoutMs?: number): Promise; @@ -1468,6 +1655,9 @@ export function resolveReporterCompatibility(frontend: IReporterFrontendDescript // @beta export function resolveReporterSelection(input: IReporterSelectionInput): IReporterSelection; +// @beta +export function runAiReporterQualificationCorpusAsync(): Promise; + // @beta export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; @@ -1564,6 +1754,9 @@ export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_ // @beta export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; +// @beta +export const RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD'; + // @beta export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; diff --git a/common/reviews/api/rush-terminal-renderer.api.md b/common/reviews/api/rush-terminal-renderer.api.md index c136aa1aed..8cc3c26da2 100644 --- a/common/reviews/api/rush-terminal-renderer.api.md +++ b/common/reviews/api/rush-terminal-renderer.api.md @@ -9,6 +9,7 @@ import type { IDaemonClientCaps } from '@rushstack/rush-daemon-protocol'; import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; import type { IDaemonOperationHeaderPayload } from '@rushstack/rush-daemon-protocol'; import { ITerminalChunk } from '@rushstack/terminal'; +import type { TerminalChunkKind } from '@rushstack/terminal'; import { TerminalWritable } from '@rushstack/terminal'; // @beta @@ -84,6 +85,7 @@ export class OperationStreamRegistry { closeOperation(operationId: string): void; registerOperation(): void; setOperationHeader(header: IDaemonOperationHeaderPayload): void; + writeBytes(operationId: string, kind: TerminalChunkKind, bytes: Uint8Array): void; writeChunk(operationId: string, chunk: ITerminalChunk): void; } diff --git a/docs/rush/reporter.md b/docs/rush/reporter.md index 7977e4328d..b092835ae9 100644 --- a/docs/rush/reporter.md +++ b/docs/rush/reporter.md @@ -156,6 +156,11 @@ The Rush temp folder is normally `/common/temp`. If `RUSH_TEMP_FOLDER` is set, Rush applies its normal path resolution and uses that same folder for reporter logs and purge behavior. +This automatic invocation log always retains `debug` detail, including in +`--reporter=file` mode. Selecting a quieter primary `--log-level` does not +reduce the full-log artifact. For an additional filtered file, use a destination +such as `--output=file://./summary.jsonl?logLevel=normal`. + `/rush-logs/latest.log` points to, or copies, the current or latest successfully opened invocation log. It can be incomplete while Rush is still running; after the command exits, it contains the finalized log when diff --git a/libraries/node-core-library/src/LockFile.ts b/libraries/node-core-library/src/LockFile.ts index 511a5e8649..8594717a60 100644 --- a/libraries/node-core-library/src/LockFile.ts +++ b/libraries/node-core-library/src/LockFile.ts @@ -7,6 +7,7 @@ import * as child_process from 'node:child_process'; import { FileSystem } from './FileSystem'; import { FileWriter } from './FileWriter'; import { Async } from './Async'; +import { tryAcquireWindowsLockFile, type ILockFileHandle } from './WindowsLockFile'; /** * http://man7.org/linux/man-pages/man5/proc.5.html @@ -153,7 +154,7 @@ export function _setLockFileGetProcessStartTime(fn: (pid: number) => string | un } interface ITryAcquireResult { - fileWriter: FileWriter | undefined; + fileWriter: ILockFileHandle | undefined; filePath: string; dirtyWhenAcquired: boolean; } @@ -164,16 +165,18 @@ interface ITryAcquireResult { * a single Node.js process. * @remarks * The implementation works on Windows, Mac, and Linux without requiring any native helpers. + * Windows uses native exclusive file sharing and a `.dirty` companion to retain interrupted-owner + * state across close/delete handoffs. A clean release removes the companion before relinquishing ownership. * On non-Windows systems, the algorithm requires access to the `ps` shell command. On Linux, * it requires access the `/proc/${pidString}/stat` filesystem. * @public */ export class LockFile { - private _fileWriter: FileWriter | undefined; + private _fileWriter: ILockFileHandle | undefined; private _filePath: string; private _dirtyWhenAcquired: boolean; - private constructor(fileWriter: FileWriter | undefined, filePath: string, dirtyWhenAcquired: boolean) { + private constructor(fileWriter: ILockFileHandle | undefined, filePath: string, dirtyWhenAcquired: boolean) { this._fileWriter = fileWriter; this._filePath = filePath; this._dirtyWhenAcquired = dirtyWhenAcquired; @@ -296,14 +299,21 @@ export class LockFile { throw new Error(`The lock for file "${path.basename(this._filePath)}" has already been released.`); } - IN_PROC_LOCKS.delete(this._filePath); - + this._fileWriter!.prepareForRelease?.(deleteFile); this._fileWriter!.close(); + this._fileWriter = undefined; + IN_PROC_LOCKS.delete(this._filePath); if (deleteFile) { - FileSystem.deleteFile(this._filePath); + try { + FileSystem.deleteFile(this._filePath, { throwIfNotExists: false }); + } catch (error) { + // A new Windows owner may acquire the file between close and unlink. Never remove its lock. + if ( + process.platform !== 'win32' || + typeof error !== 'object' || error === null || !('code' in error) || error.code !== 'EBUSY' + ) throw error; + } } - - this._fileWriter = undefined; } /** @@ -337,7 +347,7 @@ function _tryAcquireInner( if (!IN_PROC_LOCKS.has(lockFilePath)) { switch (process.platform) { case 'win32': { - return _tryAcquireWindows(lockFilePath); + return tryAcquireWindowsLockFile(lockFilePath); } case 'linux': @@ -525,46 +535,3 @@ function _tryAcquireMacOrLinux( } return result; } - -/** - * Attempts to acquire the lock using Windows - * This algorithm is much simpler since we can rely on the operating system - */ -function _tryAcquireWindows(lockFilePath: string): ITryAcquireResult | undefined { - let dirtyWhenAcquired: boolean = false; - - let fileHandle: FileWriter | undefined; - let result: ITryAcquireResult | undefined; - - try { - if (FileSystem.exists(lockFilePath)) { - dirtyWhenAcquired = true; - - // If the lockfile is held by an process with an exclusive lock, then removing it will - // silently fail. OpenSync() below will then fail and we will be unable to create a lock. - - // Otherwise, the lockfile is sitting on disk, but nothing is holding it, implying that - // the last process to hold it died. - FileSystem.deleteFile(lockFilePath); - } - - try { - // Attempt to open an exclusive lockfile - fileHandle = FileWriter.open(lockFilePath, { exclusive: true }); - } catch (error) { - // we tried to delete the lock, but something else is holding it, - // (probably an active process), therefore we are unable to create a lock - return undefined; - } - - // Ensure we can hand off the file descriptor to the lockfile - result = { fileWriter: fileHandle, filePath: lockFilePath, dirtyWhenAcquired }; - fileHandle = undefined; - } finally { - if (fileHandle) { - fileHandle.close(); - } - } - - return result; -} diff --git a/libraries/node-core-library/src/WindowsLockFile.ts b/libraries/node-core-library/src/WindowsLockFile.ts new file mode 100644 index 0000000000..8c1bf45a22 --- /dev/null +++ b/libraries/node-core-library/src/WindowsLockFile.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +export interface ILockFileHandle { + prepareForRelease?(deleteFile: boolean): void; + close(): void; +} + +export interface IWindowsLockFileResult { + readonly fileWriter: ILockFileHandle; + readonly filePath: string; + readonly dirtyWhenAcquired: boolean; +} + +// Public libuv Windows flag (supported since libuv 1.17), not exposed in fs.constants. +// https://docs.libuv.org/en/v1.x/fs.html#c.UV_FS_O_EXLOCK +const UV_FS_O_EXLOCK: number = 0x10000000; +const CLEAN_MARKER: Buffer = Buffer.from('rushstack-lockfile-clean-v1\n'); + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} + +/** Uses Windows sharing exclusion; O_EXCL alone only guarantees exclusive creation. */ +export function tryAcquireWindowsLockFile(filePath: string): IWindowsLockFileResult | undefined { + if (process.platform !== 'win32' || !process.versions.uv.startsWith('1.')) { + throw new Error('Native Windows file locking requires the supported libuv 1.x flag ABI.'); + } + const flags: number = fs.constants.O_RDWR + UV_FS_O_EXLOCK; + let descriptor: number | undefined; + let created: boolean = false; + try { + descriptor = fs.openSync(filePath, flags + fs.constants.O_CREAT + fs.constants.O_EXCL); + created = true; + } catch (error) { + if (hasCode(error, 'EBUSY')) return undefined; + if (!hasCode(error, 'EEXIST')) throw error; + try { + descriptor = fs.openSync(filePath, flags); + } catch (openError) { + if (hasCode(openError, 'EBUSY') || hasCode(openError, 'ENOENT')) return undefined; + throw openError; + } + } + + try { + assertExclusiveSharing(filePath); + const stats: fs.BigIntStats = fs.fstatSync(descriptor, { bigint: true }); + const pathStats: fs.BigIntStats = fs.lstatSync(filePath, { bigint: true }); + if ( + !stats.isFile() || !pathStats.isFile() || stats.nlink !== 1n || + stats.dev !== pathStats.dev || stats.ino !== pathStats.ino + ) { + throw new Error(`The lock path must identify one unshared regular file: ${filePath}`); + } + + // A previous release may delete the main pathname after a subsequent owner has already exited. + // This companion survives that close/delete race and is cleared only under exclusive ownership. + const dirtyPath: string = `${filePath}.dirty`; + const dirtyMarkerExists: boolean = fs.lstatSync(dirtyPath, { throwIfNoEntry: false }) !== undefined; + const dirtyWhenAcquired: boolean = + dirtyMarkerExists || (!created && !hasCleanMarker(descriptor, stats.size)); + try { + fs.writeFileSync(dirtyPath, '', { flag: 'wx', mode: 0o600 }); + } catch (error) { + if (!hasCode(error, 'EEXIST')) throw error; + } + + let heldDescriptor: number | undefined = descriptor; + const fileWriter: ILockFileHandle = { + prepareForRelease: (deleteFile) => { + if (!deleteFile) return; + if (heldDescriptor === undefined) throw new Error(`The lock is already closed: ${filePath}`); + fs.ftruncateSync(heldDescriptor, 0); + if (fs.writeSync(heldDescriptor, CLEAN_MARKER, 0, CLEAN_MARKER.length, 0) !== CLEAN_MARKER.length) { + throw new Error(`Could not record the clean lock release: ${filePath}`); + } + try { + fs.unlinkSync(dirtyPath); + } catch (error) { + if (!hasCode(error, 'ENOENT')) throw error; + } + }, + close: () => { + if (heldDescriptor !== undefined) { + fs.closeSync(heldDescriptor); + heldDescriptor = undefined; + } + } + }; + descriptor = undefined; + return { fileWriter, filePath, dirtyWhenAcquired }; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function assertExclusiveSharing(filePath: string): void { + let sharedDescriptor: number | undefined; + try { + sharedDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY); + } catch (error) { + if (hasCode(error, 'EBUSY')) return; + throw error; + } finally { + if (sharedDescriptor !== undefined) fs.closeSync(sharedDescriptor); + } + throw new Error(`The runtime did not enforce native exclusive file sharing: ${filePath}`); +} + +function hasCleanMarker(descriptor: number, size: bigint): boolean { + if (size !== BigInt(CLEAN_MARKER.length)) return false; + const contents: Buffer = Buffer.alloc(CLEAN_MARKER.length); + return ( + fs.readSync(descriptor, contents, 0, contents.length, 0) === contents.length && + contents.equals(CLEAN_MARKER) + ); +} diff --git a/libraries/node-core-library/src/test/WindowsLockFile.test.ts b/libraries/node-core-library/src/test/WindowsLockFile.test.ts new file mode 100644 index 0000000000..1be9ebaedf --- /dev/null +++ b/libraries/node-core-library/src/test/WindowsLockFile.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; + +import { LockFile } from '../LockFile'; +import { FileSystem } from '../FileSystem'; + +(process.platform === 'win32' ? describe : describe.skip)('native Windows file mutex', () => { + let folder: string; + let filename: string; + + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-native-lock-')); + filename = LockFile.getLockFilePath(folder, 'resource'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(folder, { recursive: true, force: true }); + }); + + it('excludes a second live process and prevents deletion while held', () => { + const owner: LockFile = LockFile.tryAcquire(folder, 'resource')!; + try { + const output: string = execFileSync(process.execPath, ['-e', ` + const { LockFile } = require(${JSON.stringify(require.resolve('../LockFile'))}); + const lock = LockFile.tryAcquire(${JSON.stringify(fs.realpathSync.native(folder))}, 'resource'); + process.stdout.write(JSON.stringify({ acquired: !!lock })); + if (lock) lock.release(false); + `], { cwd: os.tmpdir(), encoding: 'utf8' }); + expect(JSON.parse(output)).toEqual({ acquired: false }); + expect(() => fs.unlinkSync(filename)).toThrow(expect.objectContaining({ code: 'EBUSY' })); + expect(owner.isReleased).toBe(false); + } finally { + owner.release(); + } + }); + + it('does not remove a legacy writer that still holds the file', () => { + const descriptor: number = fs.openSync(filename, 'wx'); + try { + expect(LockFile.tryAcquire(folder, 'resource')).toBeUndefined(); + expect(fs.existsSync(filename)).toBe(true); + expect(fs.existsSync(`${filename}.dirty`)).toBe(false); + } finally { + fs.closeSync(descriptor); + } + const recovered: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(recovered.dirtyWhenAcquired).toBe(true); + recovered.release(); + }); + + it('distinguishes clean release from a deliberately retained dirty lock', () => { + const first: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(first.dirtyWhenAcquired).toBe(false); + first.release(); + expect(fs.existsSync(filename)).toBe(false); + expect(fs.existsSync(`${filename}.dirty`)).toBe(false); + const second: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(second.dirtyWhenAcquired).toBe(false); + second.release(false); + expect(fs.existsSync(filename)).toBe(true); + expect(fs.existsSync(`${filename}.dirty`)).toBe(true); + const recovered: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(recovered.dirtyWhenAcquired).toBe(true); + recovered.release(); + expect(fs.readdirSync(folder)).toEqual([]); + }); + + it('retains interrupted-owner dirtiness after actual process termination', async () => { + const child = spawn(process.execPath, ['-e', ` + const { LockFile } = require(${JSON.stringify(require.resolve('../LockFile'))}); + const lock = LockFile.tryAcquire(${JSON.stringify(folder)}, 'resource'); + if (!lock) throw new Error('Fixture could not acquire the lock.'); + process.send({ acquired: true }); + setInterval(() => {}, 1000); + `], { cwd: os.tmpdir(), stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); + const closed = once(child, 'close'); + try { + expect((await once(child, 'message'))[0]).toEqual({ acquired: true }); + expect(LockFile.tryAcquire(folder, 'resource')).toBeUndefined(); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + } + const recovered: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(recovered.dirtyWhenAcquired).toBe(true); + recovered.release(); + }, 15000); + + it('does not delete a live successor that acquires during close/unlink', () => { + const owner: LockFile = LockFile.tryAcquire(folder, 'resource')!; + const remove: typeof FileSystem.deleteFile = FileSystem.deleteFile; + let successor: LockFile | undefined; + const deletion = jest.spyOn(FileSystem, 'deleteFile').mockImplementation((file, options) => { + if (file === filename && !successor) successor = LockFile.tryAcquire(folder, 'resource'); + remove(file, options); + }); + try { + owner.release(); + expect(owner.isReleased).toBe(true); + expect(successor).toBeDefined(); + expect(successor!.dirtyWhenAcquired).toBe(false); + expect(successor!.isReleased).toBe(false); + expect(fs.existsSync(filename)).toBe(true); + } finally { + deletion.mockRestore(); + if (!owner.isReleased) owner.release(); + successor?.release(); + } + }); + + it('preserves dirtiness if a successor exits before the predecessor finishes unlinking', () => { + const owner: LockFile = LockFile.tryAcquire(folder, 'resource')!; + const remove: typeof FileSystem.deleteFile = FileSystem.deleteFile; + let raced: boolean = false; + const deletion = jest.spyOn(FileSystem, 'deleteFile').mockImplementation((file, options) => { + if (file === filename && !raced) { + raced = true; + const successor: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(successor.dirtyWhenAcquired).toBe(false); + successor.release(false); + } + remove(file, options); + }); + try { + owner.release(); + } finally { + deletion.mockRestore(); + } + expect(raced).toBe(true); + expect(fs.existsSync(filename)).toBe(false); + expect(fs.existsSync(`${filename}.dirty`)).toBe(true); + const recovered: LockFile = LockFile.tryAcquire(folder, 'resource')!; + expect(recovered.dirtyWhenAcquired).toBe(true); + recovered.release(); + }); + + it('refuses a shared file instead of modifying its other link', () => { + const other: string = path.join(folder, 'not-a-lock.txt'); + fs.writeFileSync(other, 'unchanged'); + fs.linkSync(other, filename); + expect(() => LockFile.tryAcquire(folder, 'resource')).toThrow('unshared regular file'); + expect(fs.readFileSync(other, 'utf8')).toBe('unchanged'); + expect(fs.existsSync(`${filename}.dirty`)).toBe(false); + }); + + it('surfaces unexpected open failures instead of pretending another process owns the lock', () => { + const failure: NodeJS.ErrnoException = Object.assign(new Error('fixture IO failure'), { code: 'EIO' }); + const nativeFs: typeof fs = jest.requireActual('node:fs'); + jest.spyOn(nativeFs, 'openSync').mockImplementation(() => { throw failure; }); + expect(() => LockFile.tryAcquire(folder, 'resource')).toThrow(failure); + }); + + it('fails closed if the runtime does not honor exclusive sharing', () => { + const nativeFs: typeof fs = jest.requireActual('node:fs'); + const open: typeof fs.openSync = nativeFs.openSync; + const exclusiveSharing: number = 0x10000000; + jest.spyOn(nativeFs, 'openSync').mockImplementation((file, flags, mode) => + open(file, typeof flags === 'number' && flags >= exclusiveSharing ? flags - exclusiveSharing : flags, mode) + ); + expect(() => LockFile.tryAcquire(folder, 'resource')).toThrow('did not enforce native exclusive'); + expect(fs.existsSync(`${filename}.dirty`)).toBe(false); + }); +}); diff --git a/libraries/operation-graph/src/WatchLoop.ts b/libraries/operation-graph/src/WatchLoop.ts index dec3f0698c..302aa21fcd 100644 --- a/libraries/operation-graph/src/WatchLoop.ts +++ b/libraries/operation-graph/src/WatchLoop.ts @@ -209,7 +209,8 @@ export class WatchLoop implements IWatchLoopState { } finally { const afterExecuteMessage: IAfterExecuteEventMessage = { event: 'after-execute', - status + status, + residentMemoryBytes: process.memoryUsage().rss }; tryMessageHost(afterExecuteMessage); } diff --git a/libraries/operation-graph/src/protocol.types.ts b/libraries/operation-graph/src/protocol.types.ts index f178ea84eb..ed4e96727b 100644 --- a/libraries/operation-graph/src/protocol.types.ts +++ b/libraries/operation-graph/src/protocol.types.ts @@ -28,6 +28,8 @@ export interface IRequestRunEventMessage { export interface IAfterExecuteEventMessage { event: 'after-execute'; status: OperationStatus; + /** The child's measured process RSS at completion, in bytes. Absent for older producers. */ + residentMemoryBytes?: number; } /** diff --git a/libraries/operation-graph/src/test/WatchLoop.test.ts b/libraries/operation-graph/src/test/WatchLoop.test.ts index 2a8c44b3ce..2afdf48fdb 100644 --- a/libraries/operation-graph/src/test/WatchLoop.test.ts +++ b/libraries/operation-graph/src/test/WatchLoop.test.ts @@ -309,11 +309,13 @@ describe(WatchLoop.name, () => { const successMessage: IAfterExecuteEventMessage = { event: 'after-execute', - status: OperationStatus.Success + status: OperationStatus.Success, + residentMemoryBytes: expect.any(Number) }; expect(sendMock).toHaveBeenCalledWith(syncMessage); expect(sendMock).toHaveBeenLastCalledWith(successMessage); + expect(sendMock.mock.calls[1][0].residentMemoryBytes).toBeGreaterThan(0); }); }); }); diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index 0e869805a8..ee5ac9894f 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -8,14 +8,68 @@ Rush 5 keeps legacy terminal output by default. See the [experimental Rush reporter guide](../../docs/rush/reporter.md) for opt-in controls, reporter behavior, privacy boundaries, full-detail logs, bootstrap compatibility, and the reproducible repository demo. -## Full-detail log completion +Bootstrap initialization failures close every destination whose initialization was attempted, including +partially initialized reporters, before propagating the original failure. Abandoned handoff cleanup applies +the 14-day retention window and a 20-session cap to files verifiably owned by the current user whose producer +process has exited. Live/current handoffs, foreign files, and entries without verifiable ownership are not +removed; timestamp ties are resolved by filename. +Each registration shares a cached manager-owned close operation across normal shutdown and initialization +disposal, including rejected closes. The frontend's eager full-log close for artifact publication remains +unchanged. +Failed initialization aborts every attempted reporter synchronously before waiting for its lifecycle lane. +Abort signals retain the startup failure as their reason; non-Error failures are retained as the cause of an +Error used for cancellation, while the frontend rethrows the original failure unchanged. -The frontend reports an invocation log as complete only after its accepted events and grouped -output have been persisted and the file has closed successfully. Its final `artifactAvailable` -notification is delivered to the remaining reporters after that close; it is not appended to the -same closed log. The log retains command results and session completion, including failed commands. -Flush or close failures leave the artifact incomplete or unavailable and produce an emergency warning -without replacing the command's native exit result. +## AI reporter qualification + +The network-free qualification corpus runs representative bootstrap/version, configuration, input, +dependency-tool, operation, cache, network/auth, plugin, cancellation, and internal failures plus +successful and warning-only controls through the AI, detailed plaintext, legacy, and full-log reporters. +Scenario-specific external output is included only where the real failure or control would produce it. + +| Gate | Blocking threshold | +| --- | --- | +| Failure/control coverage | At least 10 failure cases and 2 successful controls | +| Actionability | 100% of failures retain stable code, category, context, and remediation | +| Output size | At most 64 KiB per case; compact cases at most 2 KiB; AI no larger than comparable per-case baselines; aggregate AI bytes at most 50% of legacy and plaintext | +| Determinism | Byte-identical normalized AI output across 3 runs | +| Privacy | 100% secret redaction and no private producer identity leakage | +| Full log | 100% absolute, existing, owner-only where supported, complete, and failure-correlated | +| Stdout/warnings | 100% payload-only NDJSON and warning suppression/detail compliance | + +Run `rushx build && node scripts/runAiReporterQualification.js` from this project to print the +machine-readable result. Byte gates measure the actual emitted UTF-8 strings, including absolute paths and +NDJSON delimiters. Paths are normalized only for deterministic comparison/hashing and are not stored. +Separate near-limit and sustained-watch probes enforce the invocation budget without adding artificial +baseline volume to the comparison corpus. Passing +these gates only produces a reusable qualification decision; it does not enable environment-based automatic +reporter selection. That decision also requires the separate telemetry privacy prerequisite to be accepted. +The pre-major Rush frontend remains explicit/repository-opt-in, and `RUSH_REPORTER=legacy` remains +authoritative. +The Jest setup hook has a bounded 15-second allowance for the three file-backed corpus passes, matching +the integration test setup policy. This allowance does not change any quality gate or production deadline. + +AI output reserves final-record space, including its supplied log reference, before emitting progress. +Progress is buffered within the invocation byte limit until the primary log reservation is known, or until +close if no log is supplied. Excess progress/details set `truncated`; the final result remains valid JSON. +An unrendered start acknowledgement is coalesced into a known final result. Ongoing commands still expose +buffered status at the next non-terminal event or explicit flush; watch history and every final field, +including the supplied log reference, are retained. No path shortening or measurement normalization is used. +The final scope carries the command name, and standard `diagnostic..summary` keys are implicit rather +than repeated alongside the same code. Custom summary keys are preserved. + +AI fallback message text is emitted only for public envelopes. Its context retains the known command, and +the usage-review action invokes that command's help (or `rush --help` when the command is unavailable). +Qualification checks exact expected context, remediation commands/URLs, descriptions, and execution safety; +summary-only failures and unrelated actions do not qualify. Non-public fallback errors remain countable +and refer to the protected full-detail log. JSON oversized-record markers preserve the original privacy +classification and omit non-public source and scope metadata. + +Secret envelopes retain only protocol, event identity, ordering, timing, type, privacy, and fully redacted +source and payload fields. Contextual parent, command, operation, project, phase, and scope metadata is +removed. +Human diagnostic source labels that duplicate an explicitly secret parameter are also redacted. Unrelated +local-sensitive tool names and locations remain available in local human output. ## Shadow lifecycle compatibility @@ -38,6 +92,19 @@ The recorded cancellation state is reset when a new command starts. Operation output parity tests compare raw terminal chunks, including stream identity and unnormalized ANSI text, as well as the actual bytes on each stdout/stderr stream. +## Full-detail log completion + +The automatic invocation log retains debug detail regardless of the primary log level, including +in `--reporter=file` mode. An additional `--output=file://...` destination can independently select +its level with `?logLevel=...`; it does not replace or reduce the canonical full-detail artifact. + +The frontend reports an invocation log as complete only after its accepted events and grouped +output have been persisted and the file has closed successfully. Its final `artifactAvailable` +notification is delivered to the remaining reporters after that close; it is not appended to the +same closed log. The log retains command results and session completion, including failed commands. +Flush or close failures leave the artifact incomplete or unavailable and produce an emergency warning +without replacing the command's native exit result. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out diff --git a/libraries/reporter/scripts/runAiReporterQualification.js b/libraries/reporter/scripts/runAiReporterQualification.js new file mode 100644 index 0000000000..53e714d6fa --- /dev/null +++ b/libraries/reporter/scripts/runAiReporterQualification.js @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const { + formatAiReporterQualificationFailures, + runAiReporterQualificationCorpusAsync +} = require('../lib-commonjs'); + +runAiReporterQualificationCorpusAsync() + .then((result) => { + process.stdout.write(`${JSON.stringify(result, undefined, 2)}\n`); + if (!result.passed) { + process.stderr.write(`${formatAiReporterQualificationFailures(result)}\n`); + process.exitCode = 1; + } + }) + .catch((error) => { + process.stderr.write(`${error.stack || error.message}\n`); + process.exitCode = 1; + }); diff --git a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts index 84b72c09b7..7edcf861f8 100644 --- a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts +++ b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts @@ -74,8 +74,6 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string }); } -// END GENERATED BOOTSTRAP PROTOCOL - /** * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). * @@ -120,3 +118,5 @@ export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NO */ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = 'rush.reporter.buffer-truncated'; + +// END GENERATED BOOTSTRAP PROTOCOL diff --git a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts index 06d6e3ecee..fddfa856d5 100644 --- a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts +++ b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts @@ -74,7 +74,7 @@ export class OldEngineOutputAdapter { * @param stream - the originating stream * @param text - the raw output text */ - public capture(stream: 'stdout' | 'stderr', text: string): string[] { + public capture(stream: 'stdout' | 'stderr', text: string, wasRendered: boolean = true): string[] { const eventIds: string[] = []; for (const chunk of chunkUtf8Text(text, this._maxChunkBytes)) { eventIds.push( @@ -84,7 +84,7 @@ export class OldEngineOutputAdapter { source: this._source, privacy: 'local-sensitive', type: 'externalOutput', - payload: { stream, text: chunk } + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } }) ); } diff --git a/libraries/reporter/src/events/IReporterEventEnvelope.ts b/libraries/reporter/src/events/IReporterEventEnvelope.ts index f0cd5495f7..8b548a59d7 100644 --- a/libraries/reporter/src/events/IReporterEventEnvelope.ts +++ b/libraries/reporter/src/events/IReporterEventEnvelope.ts @@ -96,6 +96,11 @@ export interface IReporterEventEnvelope { */ readonly parentSessionId?: string; + /** + * The identifier of the parent request that spawned this child session. + */ + readonly parentRequestId?: string; + /** * The identifier of the parent operation that spawned the child session, when applicable. */ diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index be139470bf..a311466096 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -30,6 +30,17 @@ import { */ export const DEFAULT_HANDOFF_RETENTION_MS: number = 14 * 24 * 60 * 60 * 1000; +const MAX_ABANDONED_HANDOFF_SESSIONS: number = 20; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + /** * Options for constructing a {@link ReporterHost}. * @@ -108,7 +119,31 @@ export interface IBootstrapReplayResult { | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' + | 'unsupported-required-event' | 'incompatible-protocol'; + + /** + * Ordered raw output that a legacy fallback can render when the handoff + * protocol is incompatible. + */ + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; +} + +/** + * A raw bootstrap write retained for legacy-visible fallback. + * + * @beta + */ +export interface IBootstrapLegacyOutput { + /** + * The original output stream. + */ + readonly stream: 'stdout' | 'stderr'; + + /** + * The unmodified output text. + */ + readonly text: string; } function isRecord(value: unknown): value is Record { @@ -150,6 +185,26 @@ function isReporterEventEnvelope(value: unknown): value is IReporterEventEnvelop ); } +function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOutput[] { + const output: IBootstrapLegacyOutput[] = []; + for (const event of events) { + if (!isRecord(event) || !isRecord(event.payload)) { + continue; + } + if ( + event.type === 'externalOutput' && + (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && + typeof event.payload.text === 'string' && + event.payload.wasRendered !== true + ) { + output.push({ stream: event.payload.stream, text: event.payload.text }); + } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { + output.push({ stream: 'stdout', text: `${event.payload.text}\n` }); + } + } + return output; +} + /** * Hosts the authoritative {@link ReporterManager} in the frontend, before Rush * version selection. @@ -257,24 +312,28 @@ export class ReporterHost { for (const event of events) { const protocolVersion: IReporterProtocolVersion | undefined = getProtocolVersion(event); if (protocolVersion && !isReporterProtocolCompatible(this._supportedProtocolVersion, protocolVersion)) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'incompatible-protocol' + skipReason: 'incompatible-protocol', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } if (!isReporterEventEnvelope(event)) { if (isRecord(event) && event.required === true) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'invalid-event' + skipReason: 'unsupported-required-event', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } skippedEventCount++; @@ -312,7 +371,36 @@ export class ReporterHost { } /** - * Deletes abandoned handoff files older than the retention window. + * Deletes the current authenticated bootstrap handoff without replaying it. + * + * @remarks + * This is used when frontend initialization fails before replay can begin. + * Paths outside the configured handoff directory and nonce mismatches are + * rejected without deleting the referenced file. + * + */ + public async discardBootstrapHandoffAsync(): Promise { + const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + const expectedNonce: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + if (!handoffPath || !expectedNonce || !this._isOwnedHandoffPath(handoffPath)) { + return; + } + + try { + const { header } = await readBootstrapHandoffFileAsync(handoffPath); + if (header?.nonce !== expectedNonce) { + return; + } + } catch { + // Match replay behavior for an unreadable file at an authenticated private path. + } + + await deleteBootstrapHandoffFileAsync(handoffPath); + } + + /** + * Deletes expired abandoned handoffs and retains at most 20 recent abandoned sessions per user. + * Live processes, the current handoff, and files without verifiable ownership are protected. * * @returns the paths of the deleted files */ @@ -325,22 +413,67 @@ export class ReporterHost { return deleted; } + const uid: number = process.getuid?.() ?? os.userInfo().uid; + if (uid < 0) { + return deleted; + } + const currentHandoff: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; const cutoff: number = this._nowMs() - this._retentionMs; + const abandoned: Array<{ path: string; pid: number; stats: fs.Stats }> = []; for (const fileName of fileNames) { - if (!isBootstrapHandoffFileName(fileName)) { + const match: RegExpExecArray | null = /^rush-reporter-bootstrap-([1-9]\d*)-.+\.ndjson$/.exec(fileName); + if (!match) { continue; } + const pid: number = Number(match[1]); const filePath: string = path.join(this._handoffDirectory, fileName); + if ( + !Number.isSafeInteger(pid) || + pid > 0x7fffffff || + pid === process.pid || + (currentHandoff !== undefined && path.resolve(filePath) === path.resolve(currentHandoff)) + ) { + continue; + } try { - const stats: fs.Stats = await fs.promises.stat(filePath); - if (stats.mtimeMs < cutoff) { - await fs.promises.rm(filePath, { force: true }); - deleted.push(filePath); + const stats: fs.Stats = await fs.promises.lstat(filePath); + if (stats.isFile() && stats.uid === uid && !isProcessAlive(pid)) { + abandoned.push({ path: filePath, pid, stats }); } } catch { // Ignore files that vanish or cannot be inspected. } } + abandoned.sort( + (left, right) => + right.stats.mtimeMs - left.stats.mtimeMs || + (left.path < right.path ? -1 : left.path > right.path ? 1 : 0) + ); + for (const [index, candidate] of abandoned.entries()) { + if (index < MAX_ABANDONED_HANDOFF_SESSIONS && candidate.stats.mtimeMs >= cutoff) { + continue; + } + try { + const stats: fs.Stats = await fs.promises.lstat(candidate.path); + if ( + stats.isFile() && + stats.uid === uid && + stats.dev === candidate.stats.dev && + stats.ino === candidate.stats.ino && + stats.mtimeMs === candidate.stats.mtimeMs && + !isProcessAlive(candidate.pid) + ) { + await fs.promises.unlink(candidate.path); + deleted.push(candidate.path); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + process.stderr.write( + `[reporter] Unable to remove an abandoned bootstrap handoff: ${String(error)}\n` + ); + } + } + } return deleted; } diff --git a/libraries/reporter/src/heft/HeftChildEmitter.ts b/libraries/reporter/src/heft/HeftChildEmitter.ts index 0178e5cefa..fc8e704c30 100644 --- a/libraries/reporter/src/heft/HeftChildEmitter.ts +++ b/libraries/reporter/src/heft/HeftChildEmitter.ts @@ -5,16 +5,32 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; import { isReporterEventRequired, type ReporterEventType } from '../events/ReporterEventType'; import { encodeNdjsonRecord } from '../protocol/Ndjson'; -import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; -import type { IReporterHello } from '../protocol/ReporterHandshake'; -import { readChildDescriptorFd, RUSH_REPORTER_CHILD_FD_ENV_VAR } from './HeftDescriptor'; +import { + isReporterProtocolCompatible, + REPORTER_PROTOCOL_LIMITS, + REPORTER_PROTOCOL_VERSION +} from '../protocol/ReporterProtocol'; +import { + parseReporterHelloAck, + REPORTER_KNOWN_CAPABILITIES, + type IReporterChildContext, + type IReporterHello, + type IReporterHelloAck +} from '../protocol/ReporterHandshake'; +import { chunkUtf8Text } from '../utilities/chunkUtf8Text'; +import { + readChildAckDescriptorFd, + readChildDescriptorFd, + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, + RUSH_REPORTER_CHILD_FD_ENV_VAR +} from './HeftDescriptor'; /** * The mode a Heft child reporter operates in. * * @beta */ -export type HeftChildReporterMode = 'structured' | 'raw-fallback'; +export type HeftChildReporterMode = 'negotiation-pending' | 'structured' | 'raw-fallback'; /** * An event a Heft child emits. @@ -105,9 +121,18 @@ export interface IHeftChildEmitterOptions { */ export class HeftChildEmitter { /** - * Whether the child emits structured events or falls back to raw streams. + * Whether the child is negotiating, emitting structured events, or using raw streams. */ - public readonly mode: HeftChildReporterMode; + public get mode(): HeftChildReporterMode { + return this._mode; + } + + /** + * Parent-owned rendering and filtering context accepted during negotiation. + */ + public get context(): IReporterChildContext | undefined { + return this._context; + } private readonly _writeDescriptor: ((text: string) => void) | undefined; private readonly _writeStdout: ((text: string) => void) | undefined; @@ -119,13 +144,21 @@ export class HeftChildEmitter { private readonly _capabilities: readonly string[]; private readonly _requiredFeatures: readonly string[]; private readonly _now: () => string; + private _mode: HeftChildReporterMode; + private _context: IReporterChildContext | undefined; + private _helloSent: boolean; private _sequence: number; private _nextEventId: number; public constructor(options: IHeftChildEmitterOptions) { const fd: number | undefined = readChildDescriptorFd(options.env); + const ackFd: number | undefined = readChildAckDescriptorFd(options.env); delete options.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]; - this.mode = fd !== undefined && options.writeDescriptor !== undefined ? 'structured' : 'raw-fallback'; + delete options.env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]; + this._mode = + fd !== undefined && ackFd !== undefined && fd !== ackFd && options.writeDescriptor !== undefined + ? 'negotiation-pending' + : 'raw-fallback'; this._writeDescriptor = options.writeDescriptor; this._writeStdout = options.writeStdout; @@ -134,18 +167,24 @@ export class HeftChildEmitter { this._source = options.source; this._producerVersion = options.producerVersion; this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; - this._capabilities = options.capabilities ?? []; - this._requiredFeatures = options.requiredFeatures ?? []; + this._capabilities = [...(options.capabilities ?? REPORTER_KNOWN_CAPABILITIES)]; + this._requiredFeatures = [...(options.requiredFeatures ?? [])]; this._now = options.now ?? (() => new Date().toISOString()); + this._helloSent = false; this._sequence = 1; this._nextEventId = 1; } /** - * Sends the hello handshake over the descriptor. Returns `false` in fallback mode. + * Sends the hello handshake over the descriptor. + * + * @remarks + * Structured events remain disabled until {@link HeftChildEmitter.acceptHelloAck} + * accepts a compatible acknowledgement containing `heft-child-events-v1`. + * Returns `false` in fallback mode or after the hello was already sent. */ public sendHello(): boolean { - if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + if (this._mode !== 'negotiation-pending' || this._writeDescriptor === undefined || this._helloSent) { return false; } const hello: IReporterHello = { @@ -155,16 +194,70 @@ export class HeftChildEmitter { capabilities: [...this._capabilities], requiredFeatures: [...this._requiredFeatures] }; - this._writeDescriptor(encodeNdjsonRecord(hello)); + try { + this._writeDescriptor(encodeNdjsonRecord(hello)); + this._helloSent = true; + return true; + } catch { + this._switchToRawFallback(); + return false; + } + } + + /** + * Accepts a decoded hello acknowledgement from the parent. + * + * @remarks + * Unsupported, rejected, malformed, or missing acknowledgements fail closed + * to raw output. This method never throws for untrusted acknowledgement data. + */ + public acceptHelloAck(value: unknown): boolean { + if (this._mode !== 'negotiation-pending' || !this._helloSent) { + return false; + } + + let ack: IReporterHelloAck; + try { + ack = parseReporterHelloAck(value); + } catch { + this._switchToRawFallback(); + return false; + } + + const advertisedCapabilities: ReadonlySet = new Set(this._capabilities); + const acceptedCapabilitiesAreValid: boolean = ack.acceptedCapabilities.every((capability: string) => + advertisedCapabilities.has(capability) + ); + if ( + !isReporterProtocolCompatible(this._protocolVersion, ack.protocolVersion) || + ack.rejectedRequiredFeatures.length > 0 || + !acceptedCapabilitiesAreValid || + !ack.acceptedCapabilities.includes('heft-child-events-v1') + ) { + this._switchToRawFallback(); + return false; + } + + this._context = ack.context === undefined ? undefined : Object.freeze({ ...ack.context }); + this._mode = 'structured'; return true; } + /** + * Reports that the acknowledgement descriptor closed without an accepted acknowledgement. + */ + public handleAckDescriptorClose(): void { + if (this._mode === 'negotiation-pending') { + this._switchToRawFallback(); + } + } + /** * Emits a structured event over the descriptor. Returns the event id, or * `undefined` in fallback mode. */ public emitEvent(input: IHeftChildEventInput): string | undefined { - if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + if (this._mode !== 'structured' || this._writeDescriptor === undefined) { return undefined; } const eventId: string = `child_${this._nextEventId++}`; @@ -185,6 +278,36 @@ export class HeftChildEmitter { return eventId; } + /** + * Emits raw output as bounded structured events, or writes it to the raw fallback stream. + * + * @returns the structured event ids, or an empty array in raw fallback mode + */ + public emitOutput( + stream: 'stdout' | 'stderr', + text: string, + scope?: IReporterEventScope + ): readonly string[] { + if (this._mode !== 'structured') { + this.writeRaw(stream, text); + return []; + } + + const eventIds: string[] = []; + for (const chunk of chunkUtf8Text(text, REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes)) { + const eventId: string | undefined = this.emitEvent({ + type: 'externalOutput', + privacy: 'local-sensitive', + scope, + payload: { stream, text: chunk } + }); + if (eventId !== undefined) { + eventIds.push(eventId); + } + } + return eventIds; + } + /** * Writes raw output to stdout or stderr, preserved for problem matchers. */ @@ -195,4 +318,9 @@ export class HeftChildEmitter { this._writeStdout?.(text); } } + + private _switchToRawFallback(): void { + this._mode = 'raw-fallback'; + this._context = undefined; + } } diff --git a/libraries/reporter/src/heft/HeftDescriptor.ts b/libraries/reporter/src/heft/HeftDescriptor.ts index 2e6e9b921c..23cf9001eb 100644 --- a/libraries/reporter/src/heft/HeftDescriptor.ts +++ b/libraries/reporter/src/heft/HeftDescriptor.ts @@ -11,6 +11,15 @@ import type { Readable, Writable } from 'node:stream'; */ export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; +/** + * The private environment variable that communicates the inherited reporter + * acknowledgement file descriptor number to a child process. + * + * @beta + */ +export const RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_ACK_FD' = + '_RUSH_REPORTER_CHILD_ACK_FD'; + /** * A plan for launching a child with an inherited reporter descriptor. * @@ -22,6 +31,11 @@ export interface IChildDescriptorPlan { */ readonly fdNumber: number; + /** + * The inherited file descriptor number the child reads the hello acknowledgement from. + */ + readonly ackFdNumber: number; + /** * The environment additions that communicate the descriptor to the child. */ @@ -76,28 +90,47 @@ export interface IHeftChildOutputTargets { * @remarks * stdout and stderr are piped so the parent can preserve and inspect old-Heft * fallback output before relaying it to the normal output streams. The reporter - * descriptor is an additional pipe at `fdNumber`, whose number is communicated - * through the private environment variable. + * descriptors are additional pipes at `fdNumber` and `ackFdNumber`, whose + * numbers are communicated through private environment variables. * * @param fdNumber - the descriptor number; defaults to 3 + * @param ackFdNumber - the acknowledgement descriptor number; defaults to the + * next descriptor after `fdNumber` * * @beta */ -export function allocateChildDescriptor(fdNumber: number = 3): IChildDescriptorPlan { +export function allocateChildDescriptor( + fdNumber: number = 3, + ackFdNumber: number = fdNumber + 1 +): IChildDescriptorPlan { if (!Number.isSafeInteger(fdNumber) || fdNumber < 3) { throw new RangeError( 'The reporter file descriptor number must be an integer greater than or equal to 3.' ); } + if (!Number.isSafeInteger(ackFdNumber) || ackFdNumber < 3) { + throw new RangeError( + 'The reporter acknowledgement file descriptor number must be an integer greater than or equal to 3.' + ); + } + if (fdNumber === ackFdNumber) { + throw new RangeError('The reporter event and acknowledgement file descriptors must be different.'); + } const stdio: (string | number)[] = ['inherit', 'pipe', 'pipe']; - while (stdio.length < fdNumber) { + const highestFdNumber: number = Math.max(fdNumber, ackFdNumber); + while (stdio.length <= highestFdNumber) { stdio.push('ignore'); } stdio[fdNumber] = 'pipe'; + stdio[ackFdNumber] = 'pipe'; return { fdNumber, - env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber) }, + ackFdNumber, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber), + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: String(ackFdNumber) + }, stdio }; } @@ -138,3 +171,22 @@ export function readChildDescriptorFd(env: Record): const parsed: number = Number(raw); return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; } + +/** + * Reads the inherited reporter acknowledgement descriptor number from the environment. + * + * @remarks + * Returns `undefined` when acknowledgement negotiation is unavailable. + * + * @param env - the environment variables + * + * @beta + */ +export function readChildAckDescriptorFd(env: Record): number | undefined { + const raw: string | undefined = env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]; + if (raw === undefined || !/^\d+$/.test(raw)) { + return undefined; + } + const parsed: number = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 3 ? parsed : undefined; +} diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index 52ad8feb4a..89320a5da5 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -2,7 +2,12 @@ // See LICENSE in the project root for license information. import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; -import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { + IReporterEventEnvelope, + IReporterEventScope, + IReporterEventSource +} from '../events/IReporterEventEnvelope'; +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; import { REPORTER_EVENT_TYPES, isReporterEventRequired, @@ -10,15 +15,25 @@ import { } from '../events/ReporterEventType'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; -import { NdjsonDecoder } from '../protocol/Ndjson'; +import { isValidRushDiagnosticCode } from '../diagnostics/RushDiagnosticCode'; +import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; import { + InvalidReporterHelloError, negotiateReporterHello, - type IReporterHello, + REPORTER_KNOWN_CAPABILITIES, + validateReporterChildContext, + type ReporterCapability, + type IReporterChildContext, type IReporterHelloAck, type IReporterHandshakeResult } from '../protocol/ReporterHandshake'; const REPORTER_EVENT_TYPE_SET: ReadonlySet = new Set(REPORTER_EVENT_TYPES); +const HEFT_CHILD_EVENT_TYPES: ReadonlySet = new Set([ + 'diagnosticEmitted', + 'externalOutput' +]); type IWireReporterEventEnvelope = Omit, 'type'> & { readonly type: string; @@ -39,23 +54,6 @@ function isProtocolVersion(value: unknown): value is IReporterProtocolVersion { return isNonNegativeInteger(value.major) && isNonNegativeInteger(value.minor); } -function isStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); -} - -function isReporterHello(value: unknown): value is IReporterHello { - if (!isObjectRecord(value)) { - return false; - } - return ( - value.kind === 'hello' && - isProtocolVersion(value.protocolVersion) && - typeof value.producerVersion === 'string' && - isStringArray(value.capabilities) && - isStringArray(value.requiredFeatures) - ); -} - function isReporterEventType(value: string): value is ReporterEventType { return REPORTER_EVENT_TYPE_SET.has(value); } @@ -101,6 +99,161 @@ function isReporterEventRecord(value: unknown): value is IWireReporterEventEnvel ); } +function isDiagnosticRecord(value: unknown): boolean { + if (!isObjectRecord(value)) { + return false; + } + if ( + typeof value.diagnosticId !== 'string' || + value.diagnosticId.length === 0 || + typeof value.code !== 'string' || + !isValidRushDiagnosticCode(value.code) || + typeof value.category !== 'string' || + value.category.length === 0 || + (value.severity !== 'warning' && value.severity !== 'error') || + typeof value.summaryKey !== 'string' || + value.summaryKey.length === 0 || + (value.detailKey !== undefined && typeof value.detailKey !== 'string') || + (value.retryable !== undefined && typeof value.retryable !== 'boolean') + ) { + return false; + } + if (value.parameters !== undefined) { + if (!isObjectRecord(value.parameters)) { + return false; + } + for (const parameter of Object.values(value.parameters)) { + if ( + !isObjectRecord(parameter) || + !Object.prototype.hasOwnProperty.call(parameter, 'value') || + (parameter.privacy !== 'public' && + parameter.privacy !== 'local-sensitive' && + parameter.privacy !== 'secret') + ) { + return false; + } + } + } + for (const key of ['causeDiagnosticIds', 'relatedArtifactIds']) { + const identifiers: unknown = value[key]; + if ( + identifiers !== undefined && + (!Array.isArray(identifiers) || + !identifiers.every((identifier: unknown) => typeof identifier === 'string')) + ) { + return false; + } + } + if (value.remediation !== undefined) { + if (!Array.isArray(value.remediation)) { + return false; + } + for (const action of value.remediation) { + if ( + !isObjectRecord(action) || + typeof action.descriptionKey !== 'string' || + action.descriptionKey.length === 0 || + (action.command !== undefined && typeof action.command !== 'string') || + (action.documentationUrl !== undefined && typeof action.documentationUrl !== 'string') || + (action.automatedExecutionSafety !== 'safe' && + action.automatedExecutionSafety !== 'requires-confirmation' && + action.automatedExecutionSafety !== 'unsafe') + ) { + return false; + } + } + } + if (value.source !== undefined) { + if (!isObjectRecord(value.source)) { + return false; + } + if (value.source.kind === 'file') { + if ( + typeof value.source.file !== 'string' || + (value.source.line !== undefined && !isNonNegativeInteger(value.source.line)) || + (value.source.column !== undefined && !isNonNegativeInteger(value.source.column)) || + (value.source.toolName !== undefined && typeof value.source.toolName !== 'string') + ) { + return false; + } + } else if (value.source.kind === 'tool') { + if (typeof value.source.toolName !== 'string') { + return false; + } + } else { + return false; + } + } + return true; +} + +function sanitizeDiagnosticRecord( + value: Record, + envelopePrivacy: ReporterPrivacyClassification +): Record { + const parameters: Record | undefined = isObjectRecord(value.parameters) + ? Object.fromEntries( + Object.entries(value.parameters).map(([name, parameter]: [string, unknown]) => { + const classified: Record = parameter as Record; + const redact: boolean = envelopePrivacy === 'secret' || classified.privacy === 'secret'; + return [ + name, + { + privacy: classified.privacy, + value: redact ? '[secret]' : classified.value + } + ]; + }) + ) + : undefined; + let source: Record | undefined; + if (envelopePrivacy !== 'secret' && isObjectRecord(value.source)) { + source = + value.source.kind === 'file' + ? { + kind: 'file', + file: value.source.file, + ...(value.source.line === undefined ? {} : { line: value.source.line }), + ...(value.source.column === undefined ? {} : { column: value.source.column }), + ...(value.source.toolName === undefined ? {} : { toolName: value.source.toolName }) + } + : { + kind: 'tool', + toolName: value.source.toolName + }; + } + return { + diagnosticId: value.diagnosticId, + code: value.code, + category: value.category, + severity: value.severity, + summaryKey: value.summaryKey, + ...(value.detailKey === undefined ? {} : { detailKey: value.detailKey }), + ...(parameters === undefined ? {} : { parameters }), + ...(source === undefined ? {} : { source }), + ...(value.causeDiagnosticIds === undefined + ? {} + : { causeDiagnosticIds: [...(value.causeDiagnosticIds as string[])] }), + ...(value.retryable === undefined ? {} : { retryable: value.retryable }), + ...(value.relatedArtifactIds === undefined + ? {} + : { relatedArtifactIds: [...(value.relatedArtifactIds as string[])] }) + }; +} + +function applyPrivacyFloor( + privacy: ReporterPrivacyClassification, + floor: ReporterPrivacyClassification | undefined +): ReporterPrivacyClassification { + if (floor === undefined || privacy === 'secret' || privacy === floor) { + return privacy; + } + if (floor === 'secret' || privacy === 'public') { + return floor; + } + return privacy; +} + /** * Options for constructing a {@link HeftDescriptorHost}. * @@ -112,6 +265,11 @@ export interface IHeftDescriptorHostOptions { */ readonly parentSessionId: string; + /** + * The parent request id used to correlate child events. + */ + readonly parentRequestId?: string; + /** * The parent operation id used to correlate child events. */ @@ -125,7 +283,23 @@ export interface IHeftDescriptorHostOptions { /** * The capabilities the parent supports. */ - readonly supportedCapabilities?: readonly string[]; + readonly supportedCapabilities?: readonly ReporterCapability[]; + + /** + * Parent-owned rendering and filtering context offered to the child. + */ + readonly context?: IReporterChildContext; + + /** + * A parent-trusted source identity that replaces the child-provided source. + */ + readonly trustedSource?: IReporterEventSource; + + /** + * A parent-trusted privacy floor that prevents child events from claiming a less restrictive + * classification. + */ + readonly trustedPrivacy?: ReporterPrivacyClassification; /** * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. @@ -137,6 +311,11 @@ export interface IHeftDescriptorHostOptions { * Called once when the first record is accepted or rejected. */ readonly onNegotiation?: (result: IReporterHandshakeResult) => void; + + /** + * Sends the acknowledgement to the child immediately after processing its hello. + */ + readonly sendHelloAck?: (ack: IReporterHelloAck) => void; } /** @@ -184,23 +363,36 @@ export interface IHeftChildResult { */ export class HeftDescriptorHost { private readonly _parentSessionId: string; + private readonly _parentRequestId: string | undefined; private readonly _parentOperationId: string | undefined; private readonly _supportedProtocolVersion: IReporterProtocolVersion; - private readonly _supportedCapabilities: readonly string[] | undefined; + private readonly _supportedCapabilities: readonly ReporterCapability[]; + private readonly _reporterContext: IReporterChildContext | undefined; + private readonly _trustedSource: IReporterEventSource | undefined; + private readonly _trustedPrivacy: ReporterPrivacyClassification | undefined; private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; private readonly _onNegotiation: ((result: IReporterHandshakeResult) => void) | undefined; + private readonly _sendHelloAck: ((ack: IReporterHelloAck) => void) | undefined; private _negotiation: IReporterHandshakeResult | undefined; private _protocolFailure: IRushDiagnostic | undefined; private _eventCount: number = 0; + private _childSessionId: string | undefined; + private _lastSourceSequence: number = -1; public constructor(options: IHeftDescriptorHostOptions) { this._parentSessionId = options.parentSessionId; + this._parentRequestId = options.parentRequestId; this._parentOperationId = options.parentOperationId; this._supportedProtocolVersion = options.supportedProtocolVersion; - this._supportedCapabilities = options.supportedCapabilities; + this._supportedCapabilities = options.supportedCapabilities ?? REPORTER_KNOWN_CAPABILITIES; + this._reporterContext = + options.context === undefined ? undefined : validateReporterChildContext(options.context); + this._trustedSource = options.trustedSource; + this._trustedPrivacy = options.trustedPrivacy; this._forwardEnvelope = options.forwardEnvelope; this._onNegotiation = options.onNegotiation; + this._sendHelloAck = options.sendHelloAck; } /** @@ -218,15 +410,20 @@ export class HeftDescriptorHost { } if (this._negotiation === undefined) { - if (!isReporterHello(record)) { - return this._rejectMalformedStream('the first record was not a valid hello'); + let result: IReporterHandshakeResult; + try { + result = negotiateReporterHello(record, { + supportedProtocolVersion: this._supportedProtocolVersion, + supportedCapabilities: this._supportedCapabilities, + context: this._reporterContext + }); + } catch (error) { + if (error instanceof InvalidReporterHelloError) { + return this._rejectMalformedStream('the first record was not a valid hello'); + } + throw error; } - const result: IReporterHandshakeResult = negotiateReporterHello(record, { - supportedProtocolVersion: this._supportedProtocolVersion, - supportedCapabilities: this._supportedCapabilities - }); - this._negotiation = result; - this._onNegotiation?.(result); + this._setNegotiation(result); return result.accepted; } if (!this._negotiation.accepted) { @@ -236,24 +433,104 @@ export class HeftDescriptorHost { if (!isReporterEventRecord(record)) { return this._rejectMalformedStream('an event record did not contain a valid reporter envelope'); } + if (!this._negotiation.ack.acceptedCapabilities.includes('heft-child-events-v1')) { + return this._rejectMalformedStream( + 'an event record was received without negotiating "heft-child-events-v1"' + ); + } if (record.protocolVersion.major !== this._negotiation.ack.protocolVersion.major) { return this._rejectMalformedStream( 'an event record used a protocol major different from the negotiated stream' ); } + if (this._childSessionId !== undefined && record.sessionId !== this._childSessionId) { + return this._rejectMalformedStream('the child session id changed within one reporter stream'); + } + if (record.sequence <= this._lastSourceSequence) { + return this._rejectMalformedStream('the child event sequence was not strictly increasing'); + } + this._childSessionId ??= record.sessionId; + this._lastSourceSequence = record.sequence; if (!isReporterEventType(record.type)) { if (record.required) { return this._rejectMalformedStream('a required event type was not recognized'); } return true; } + if (!HEFT_CHILD_EVENT_TYPES.has(record.type)) { + if (isReporterEventRequired(record.type)) { + return this._rejectMalformedStream('a required event type is not permitted from a Heft child'); + } + return true; + } + if (record.type === 'externalOutput') { + if ( + !isObjectRecord(record.payload) || + (record.payload.stream !== 'stdout' && record.payload.stream !== 'stderr') || + typeof record.payload.text !== 'string' + ) { + return this._rejectMalformedStream('an external output event contained an invalid payload'); + } + if ( + Buffer.byteLength(record.payload.text, 'utf8') > REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + ) { + return this._rejectMalformedStream('an external output event exceeded the protocol chunk limit'); + } + } else if (record.type === 'diagnosticEmitted' && !isDiagnosticRecord(record.payload)) { + return this._rejectMalformedStream('a diagnostic event contained an invalid payload'); + } + const privacy: ReporterPrivacyClassification = applyPrivacyFloor(record.privacy, this._trustedPrivacy); + const payload: unknown = + record.type === 'externalOutput' + ? { + stream: (record.payload as Record).stream, + text: + privacy === 'secret' + ? '[secret child output omitted]' + : (record.payload as Record).text + } + : record.type === 'diagnosticEmitted' + ? sanitizeDiagnosticRecord(record.payload as Record, privacy) + : record.payload; + const source: IReporterEventSource = this._trustedSource + ? { ...this._trustedSource } + : { + packageName: record.source.packageName, + packageVersion: record.source.packageVersion, + ...(record.source.component === undefined ? {} : { component: record.source.component }) + }; + const scope: IReporterEventScope | undefined = + record.scope === undefined && this._parentOperationId === undefined + ? undefined + : { + ...(record.scope?.commandName === undefined ? {} : { commandName: record.scope.commandName }), + ...(record.scope?.projectName === undefined ? {} : { projectName: record.scope.projectName }), + ...(record.scope?.phaseName === undefined ? {} : { phaseName: record.scope.phaseName }), + ...(this._parentOperationId !== undefined + ? { operationId: this._parentOperationId } + : record.scope?.operationId === undefined + ? {} + : { operationId: record.scope.operationId }) + }; const correlated: IReporterEventEnvelope = { - ...record, + protocolVersion: { + major: record.protocolVersion.major, + minor: record.protocolVersion.minor + }, + eventId: record.eventId, + sessionId: record.sessionId, parentSessionId: this._parentSessionId, + parentRequestId: this._parentRequestId, parentOperationId: this._parentOperationId, + sequence: record.sequence, + timestamp: record.timestamp, + source, + scope, + privacy, required: isReporterEventRequired(record.type), - type: record.type + type: record.type, + payload }; this._forwardEnvelope(correlated); this._eventCount++; @@ -279,7 +556,16 @@ export class HeftDescriptorHost { let records: unknown[]; try { records = decoder.decode(chunk); - } catch { + } catch (error) { + const decodedRecords: readonly unknown[] = + error instanceof NdjsonInvalidRecordError || error instanceof NdjsonRecordTooLargeError + ? error.decodedRecords + : []; + for (const record of decodedRecords) { + if (!this.processChildRecord(record)) { + break; + } + } this._rejectMalformedStream('its NDJSON could not be decoded within the protocol limits'); return; } @@ -292,7 +578,16 @@ export class HeftDescriptorHost { let records: unknown[]; try { records = decoder.flush(); - } catch { + } catch (error) { + const decodedRecords: readonly unknown[] = + error instanceof NdjsonInvalidRecordError || error instanceof NdjsonRecordTooLargeError + ? error.decodedRecords + : []; + for (const record of decodedRecords) { + if (!this.processChildRecord(record)) { + break; + } + } this._rejectMalformedStream('its trailing NDJSON record was invalid'); return this._result(); } @@ -366,9 +661,14 @@ export class HeftDescriptorHost { }, diagnostic: this._protocolFailure }; - this._negotiation = result; - this._onNegotiation?.(result); + this._setNegotiation(result); } return false; } + + private _setNegotiation(result: IReporterHandshakeResult): void { + this._negotiation = result; + this._sendHelloAck?.(result.ack); + this._onNegotiation?.(result); + } } diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 2167262849..44dfd07f4c 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -84,6 +84,7 @@ export { NdjsonDecoder } from './protocol/Ndjson'; export type { + IReporterChildContext, IReporterHello, IReporterHelloAck, IReporterHandshakeOptions, @@ -91,8 +92,10 @@ export type { ReporterCapability } from './protocol/ReporterHandshake'; export { + InvalidReporterHelloAckError, InvalidReporterHelloError, negotiateReporterHello, + parseReporterHelloAck, parseReporterHello, REPORTER_KNOWN_CAPABILITIES } from './protocol/ReporterHandshake'; @@ -138,7 +141,11 @@ export { export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; -export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; +export type { + IReporterHostOptions, + IBootstrapReplayResult, + IBootstrapLegacyOutput +} from './frontend/ReporterHost'; export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; export type { @@ -295,7 +302,7 @@ export type { IProblemMatch, IProblemMatcher } from './matchers/ProblemMatcher'; export type { IGetMatchersOptions } from './matchers/ProblemMatcherRegistry'; export { ProblemMatcherRegistry } from './matchers/ProblemMatcherRegistry'; export type { IRunProblemMatchersOptions, IProblemMatcherResult } from './matchers/ProblemMatcherRunner'; -export { runProblemMatchers } from './matchers/ProblemMatcherRunner'; +export { ProblemMatcherRunner, runProblemMatchers } from './matchers/ProblemMatcherRunner'; export type { IChildDescriptorPlan, @@ -303,8 +310,10 @@ export type { IHeftChildOutputTargets } from './heft/HeftDescriptor'; export { + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, RUSH_REPORTER_CHILD_FD_ENV_VAR, allocateChildDescriptor, + readChildAckDescriptorFd, readChildDescriptorFd, relayHeftChildOutput } from './heft/HeftDescriptor'; @@ -337,6 +346,22 @@ export { isWithinMemoryBudget } from './perf/PerformanceBudgets'; +export type { + IAiReporterQualificationThresholds, + IAiReporterQualificationCaseResult, + IAiReporterQualificationGateResult, + IAiReporterQualificationResult, + IQualifiedAiReporterDecision +} from './qualification/AiReporterQualification'; +export { + AI_REPORTER_QUALIFICATION_SCHEMA_VERSION, + AI_REPORTER_QUALIFICATION_THRESHOLDS, + evaluateAiReporterQualification, + formatAiReporterQualificationFailures, + getQualifiedAiReporterDecision +} from './qualification/AiReporterQualification'; +export { runAiReporterQualificationCorpusAsync } from './qualification/AiReporterQualificationCorpus'; + export type { ReporterMigrationPhaseId, IReporterMigrationPhase } from './migration/MigrationPhase'; export { REPORTER_MIGRATION_PHASES, getReporterMigrationPhase } from './migration/MigrationPhase'; export type { diff --git a/libraries/reporter/src/manager/IReporter.ts b/libraries/reporter/src/manager/IReporter.ts index e648e4be31..3755984940 100644 --- a/libraries/reporter/src/manager/IReporter.ts +++ b/libraries/reporter/src/manager/IReporter.ts @@ -19,6 +19,18 @@ export interface IReporterContext { * The exclusive destination the reporter owns, when one was declared. */ readonly destination?: string; + + /** + * Cancels background work on failure or close. The reason is the runtime error + * on failure, or `null` on normal manager closure. + */ + readonly abortSignal?: AbortSignal; + + /** + * Runs synchronous background work inside the manager's reporter failure boundary. + * Work is ignored after the reporter has failed or the manager has started closing. + */ + readonly runWithErrorHandling?: (action: () => void) => void; } /** diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 2662862921..52a300366a 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -89,12 +89,15 @@ interface IReporterEntry { readonly reporter: IReporter; readonly destination: string | undefined; readonly required: boolean; + initializationStarted: boolean; + readonly abortController: AbortController; disabled: boolean; failureNotified: boolean; readonly queue: IReporterEventEnvelope[]; draining: boolean; drainPromise: Promise; lifecyclePromise: Promise; + closePromise: Promise | undefined; } /** @@ -121,6 +124,7 @@ export class ReporterManager implements IReporterEventSink { private _nextEventId: number; private _initialized: boolean; private _fatalError: Error | undefined; + private _disposalPromise: Promise | undefined; public constructor(options: IReporterManagerOptions = {}) { const { @@ -169,12 +173,15 @@ export class ReporterManager implements IReporterEventSink { reporter, destination, required: options.required ?? false, + initializationStarted: false, + abortController: new AbortController(), disabled: false, failureNotified: false, queue: [], draining: false, drainPromise: Promise.resolve(), - lifecyclePromise: Promise.resolve() + lifecyclePromise: Promise.resolve(), + closePromise: undefined }); } @@ -189,13 +196,91 @@ export class ReporterManager implements IReporterEventSink { for (const entry of this._entries) { const context: IReporterContext = { protocolVersion: this._protocolVersion, - destination: entry.destination + destination: entry.destination, + abortSignal: entry.abortController.signal, + runWithErrorHandling: (action: () => void): void => { + if (entry.disabled || entry.abortController.signal.aborted) { + return; + } + try { + action(); + } catch (error) { + this._handleReporterFailure(entry, error); + } + } }; + entry.initializationStarted = true; await entry.reporter.initializeAsync(context); } this._initialized = true; } + /** + * Joins cleanup of every attempted initialization, including a partially initialized reporter. + * + * @internal + */ + public _disposeInitializedReportersAsync(failure?: unknown): Promise { + if (this._disposalPromise) { + return this._disposalPromise; + } + const attempted: IReporterEntry[] = this._entries.filter( + (entry: IReporterEntry) => entry.initializationStarted + ); + const reason: Error = + failure instanceof Error ? failure : new Error('Reporter initialization failed.', { cause: failure }); + let resolveDisposal!: () => void; + let rejectDisposal!: (error: unknown) => void; + // Publish the promise before abort listeners can reenter disposal. + this._disposalPromise = new Promise((resolve, reject) => { + resolveDisposal = resolve; + rejectDisposal = reject; + }); + const errors: unknown[] = []; + for (const entry of attempted) { + try { + entry.abortController.abort(reason); + } catch (error) { + errors.push(error); + } + } + void this._disposeEntriesAsync(attempted, errors).then(resolveDisposal, rejectDisposal); + return this._disposalPromise; + } + + private async _disposeEntriesAsync(entries: readonly IReporterEntry[], errors: unknown[]): Promise { + const results: PromiseSettledResult[] = await Promise.allSettled( + entries.map((entry: IReporterEntry): Promise => { + const previousLifecycle: Promise = entry.lifecyclePromise; + const disposal: Promise = (async () => { + try { + await previousLifecycle; + await entry.drainPromise; + if (this._canFlushEntry(entry)) { + await entry.reporter.flushAsync(); + } + } finally { + await this._closeEntryAsync(entry); + } + })(); + // Reserve the lifecycle lane without swallowing failures needed by the aggregate. + entry.lifecyclePromise = disposal; + return disposal; + }) + ); + for (const result of results) { + if (result.status === 'rejected') { + errors.push(result.reason); + } + } + if (errors.length > 0) { + throw new AggregateError( + errors, + `Reporter initialization cleanup failed: ${errors.map((error: unknown) => String(error)).join('; ')}` + ); + } + } + /** * Publishes an in-process event, assigning its `eventId`, `sequence`, and * `timestamp`, and returns the assigned `eventId`. @@ -266,7 +351,7 @@ export class ReporterManager implements IReporterEventSink { public async flushAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { await this._settleAsync(async (entry: IReporterEntry): Promise => { await entry.drainPromise; - if (!entry.disabled) { + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } }, timeoutMs); @@ -283,7 +368,7 @@ export class ReporterManager implements IReporterEventSink { public async _flushAndConfirmAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { return await this._settleAndConfirmAsync(async (entry: IReporterEntry): Promise => { await entry.drainPromise; - if (!entry.disabled) { + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } }, timeoutMs); @@ -299,7 +384,7 @@ export class ReporterManager implements IReporterEventSink { public async signalFlushAsync(timeoutMs: number = DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS): Promise { await this._settleAsync(async (entry: IReporterEntry): Promise => { await entry.drainPromise; - if (!entry.disabled) { + if (this._canFlushEntry(entry)) { await entry.reporter.flushAsync(); } }, timeoutMs); @@ -311,6 +396,9 @@ export class ReporterManager implements IReporterEventSink { * @throws the captured fatal error if a required reporter failed */ public async closeAsync(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + for (const entry of this._entries) { + entry.abortController.abort(null); + } let flushError: Error | undefined; try { await this.flushAsync(timeoutMs); @@ -318,7 +406,7 @@ export class ReporterManager implements IReporterEventSink { flushError = error as Error; } await this._settleAsync(async (entry: IReporterEntry): Promise => { - await entry.reporter.closeAsync(); + await this._closeEntryAsync(entry); }, timeoutMs); if (flushError) { throw flushError; @@ -328,6 +416,18 @@ export class ReporterManager implements IReporterEventSink { } } + private _canFlushEntry(entry: IReporterEntry): boolean { + return this._initialized && !entry.disabled && entry.closePromise === undefined; + } + + private _closeEntryAsync(entry: IReporterEntry): Promise { + if (!entry.initializationStarted) { + return Promise.resolve(); + } + entry.closePromise ??= Promise.resolve().then(() => entry.reporter.closeAsync()); + return entry.closePromise; + } + private _fanOut(envelope: IReporterEventEnvelope): void { for (const entry of this._entries) { if (!entry.disabled) { @@ -366,10 +466,14 @@ export class ReporterManager implements IReporterEventSink { entry.queue.push(envelope); } - if (!entry.draining) { - entry.draining = true; - entry.drainPromise = this._drainEntryAsync(entry); + if (entry.draining) { + if (!this._isCoalescibleStatusEvent(envelope)) { + this._drainQueuedEventsSynchronously(entry); + } + return; } + entry.draining = true; + entry.drainPromise = this._drainEntryAsync(entry); } private async _drainEntryAsync(entry: IReporterEntry): Promise { @@ -381,8 +485,11 @@ export class ReporterManager implements IReporterEventSink { entry.queue.length = 0; break; } - // Yield so producers and coalescing can interleave with delivery. - await Promise.resolve(); + // Only replaceable status updates need to yield for coalescing. Protected + // events are delivered synchronously so a hard process exit cannot strand them. + if (this._isCoalescibleStatusEvent(envelope)) { + await Promise.resolve(); + } } } finally { entry.draining = false; @@ -397,11 +504,24 @@ export class ReporterManager implements IReporterEventSink { } } - private _handleReporterFailure(entry: IReporterEntry, error: Error): void { + private _drainQueuedEventsSynchronously(entry: IReporterEntry): void { + while (entry.queue.length > 0) { + const envelope: IReporterEventEnvelope = entry.queue.shift()!; + this._deliverEnvelope(entry, envelope); + if (entry.disabled) { + entry.queue.length = 0; + break; + } + } + } + + private _handleReporterFailure(entry: IReporterEntry, failure: unknown): void { + const error: Error = failure instanceof Error ? failure : new Error(String(failure)); if (entry.required) { if (!this._fatalError) { this._fatalError = error; } + entry.abortController.abort(error); // Write the emergency diagnostic once; a failed required reporter keeps // receiving events until teardown, and a per-event line would spam stderr. if (!entry.failureNotified) { @@ -413,9 +533,13 @@ export class ReporterManager implements IReporterEventSink { return; } entry.disabled = true; - this._emergencyDiagnosticWriter( - `[reporter] Disabling optional reporter ${JSON.stringify(entry.reporter.name)} after failure: ${error.message}` - ); + entry.abortController.abort(error); + if (!entry.failureNotified) { + entry.failureNotified = true; + this._emergencyDiagnosticWriter( + `[reporter] Disabling optional reporter ${JSON.stringify(entry.reporter.name)} after failure: ${error.message}` + ); + } } private _isCoalescibleStatusEvent(envelope: IReporterEventEnvelope): boolean { diff --git a/libraries/reporter/src/matchers/ProblemMatcherRunner.ts b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts index 582b48ead3..63f28057c2 100644 --- a/libraries/reporter/src/matchers/ProblemMatcherRunner.ts +++ b/libraries/reporter/src/matchers/ProblemMatcherRunner.ts @@ -4,14 +4,21 @@ import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; -import { iterateExternalOutput, type IExternalOutputChunk } from '../scheduler/OperationOutputGrouping'; +import { REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; import { normalizeAnsi } from './AnsiNormalization'; import type { IProblemMatcher, IProblemMatch } from './ProblemMatcher'; const DEFAULT_MAX_DUPLICATES: number = 3; +interface IPartialLine { + text: string; + byteLength: number; + overflowed: boolean; + operationId: string | undefined; +} + /** - * Options for {@link runProblemMatchers}. + * Options for {@link ProblemMatcherRunner} and {@link runProblemMatchers}. * * @beta */ @@ -20,6 +27,14 @@ export interface IRunProblemMatchersOptions { * The maximum number of identical diagnostics to emit. Defaults to 3. */ readonly maxDuplicates?: number; + + /** + * The maximum number of bytes retained for a partial line. Longer lines are + * counted as unmatched and discarded through their next newline. + * + * @defaultValue The reporter protocol NDJSON record limit. + */ + readonly maxPartialLineBytes?: number; } /** @@ -50,79 +65,230 @@ export interface IProblemMatcherResult { } /** - * Runs problem matchers over the uncollated external-output stream. + * Incrementally runs problem matchers over uncollated external output. * * @remarks - * The raw output events are never modified: matchers process an ANSI-normalized - * copy, reassembling lines split across chunks per operation. Recovered - * diagnostics link back to the operation and source location without replacing - * the evidence, unmatched text is preserved, and identical diagnostics are - * capped. - * - * @param events - the event stream carrying external output - * @param matchers - the active matchers - * @param options - duplicate cap options + * Only `externalOutput` events are inspected. Raw events are never modified or + * reordered: matchers receive ANSI-normalized copies of complete lines, with + * partial lines retained independently per operation under a fixed byte cap. + * Callers can skip this runner entirely after structured child negotiation. * * @beta */ -export function runProblemMatchers( - events: readonly IReporterEventEnvelope[], - matchers: readonly IProblemMatcher[], - options: IRunProblemMatchersOptions = {} -): IProblemMatcherResult { - const maxDuplicates: number = options.maxDuplicates ?? DEFAULT_MAX_DUPLICATES; - const diagnostics: IRushDiagnostic[] = []; - const duplicateCounts: Map = new Map(); - const partialLines: Map = new Map(); - let matchedLineCount: number = 0; - let unmatchedLineCount: number = 0; - let suppressedDuplicateCount: number = 0; - - const processLine = (line: string, operationId: string | undefined): void => { +export class ProblemMatcherRunner { + private readonly _matchers: readonly IProblemMatcher[]; + private readonly _maxDuplicates: number; + private readonly _maxPartialLineBytes: number; + private readonly _diagnostics: IRushDiagnostic[] = []; + private readonly _duplicateCounts: Map = new Map(); + private readonly _partialLines: Map = new Map(); + private _matchedLineCount: number = 0; + private _unmatchedLineCount: number = 0; + private _suppressedDuplicateCount: number = 0; + private _flushed: boolean = false; + + public constructor(matchers: readonly IProblemMatcher[], options: IRunProblemMatchersOptions = {}) { + const maxDuplicates: number = options.maxDuplicates ?? DEFAULT_MAX_DUPLICATES; + if (!Number.isSafeInteger(maxDuplicates) || maxDuplicates < 0) { + throw new RangeError('maxDuplicates must be a nonnegative safe integer.'); + } + const maxPartialLineBytes: number = + options.maxPartialLineBytes ?? REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes; + if (!Number.isSafeInteger(maxPartialLineBytes) || maxPartialLineBytes < 1) { + throw new RangeError('maxPartialLineBytes must be a positive safe integer.'); + } + + this._matchers = [...matchers]; + this._maxDuplicates = maxDuplicates; + this._maxPartialLineBytes = maxPartialLineBytes; + } + + /** + * The diagnostics and counters observed so far. + */ + public get result(): IProblemMatcherResult { + return { + diagnostics: [...this._diagnostics], + matchedLineCount: this._matchedLineCount, + unmatchedLineCount: this._unmatchedLineCount, + suppressedDuplicateCount: this._suppressedDuplicateCount + }; + } + + /** + * The number of matched lines observed so far. + */ + public get matchedLineCount(): number { + return this._matchedLineCount; + } + + /** + * The number of unmatched lines observed so far. + */ + public get unmatchedLineCount(): number { + return this._unmatchedLineCount; + } + + /** + * The number of duplicate diagnostics suppressed so far. + */ + public get suppressedDuplicateCount(): number { + return this._suppressedDuplicateCount; + } + + /** + * Processes one event and returns diagnostics recovered by that event. + */ + public write(event: IReporterEventEnvelope): readonly IRushDiagnostic[] { + if (this._flushed) { + throw new Error('Cannot write problem matcher events after flush().'); + } + if (event.type !== 'externalOutput') { + return []; + } + + const payload: { stream?: unknown; text?: unknown } = event.payload as { + stream?: unknown; + text?: unknown; + }; + const text: string = typeof payload.text === 'string' ? payload.text : ''; + const stream: string = typeof payload.stream === 'string' ? payload.stream : 'stdout'; + return this.writeOutput(text, event.scope?.operationId, stream); + } + + /** + * Processes one raw source chunk and returns diagnostics recovered by it. + * + * @remarks + * This is the direct integration surface for process runners that publish + * the raw event independently before invoking the matcher. + */ + public writeOutput( + text: string, + operationId?: string, + stream: string = 'stdout' + ): readonly IRushDiagnostic[] { + if (this._flushed) { + throw new Error('Cannot write problem matcher output after flush().'); + } + const key: string = `${operationId ?? ''}\0${stream}`; + const partial: IPartialLine = this._partialLines.get(key) ?? { + text: '', + byteLength: 0, + overflowed: false, + operationId + }; + const diagnosticStart: number = this._diagnostics.length; + + let offset: number = 0; + let newlineIndex: number = text.indexOf('\n'); + while (newlineIndex >= 0) { + this._appendLineFragment(partial, text.slice(offset, newlineIndex)); + this._finishLine(partial); + offset = newlineIndex + 1; + newlineIndex = text.indexOf('\n', offset); + } + this._appendLineFragment(partial, text.slice(offset)); + this._partialLines.set(key, partial); + + return this._diagnostics.slice(diagnosticStart); + } + + /** + * Processes all remaining partial lines and returns diagnostics recovered by them. + */ + public flush(): readonly IRushDiagnostic[] { + if (this._flushed) { + return []; + } + this._flushed = true; + const diagnosticStart: number = this._diagnostics.length; + for (const partial of this._partialLines.values()) { + this._finishLine(partial); + } + this._partialLines.clear(); + return this._diagnostics.slice(diagnosticStart); + } + + private _appendLineFragment(partial: IPartialLine, fragment: string): void { + if (fragment.length === 0 || partial.overflowed) { + return; + } + const fragmentBytes: number = Buffer.byteLength(fragment, 'utf8'); + if (partial.byteLength + fragmentBytes > this._maxPartialLineBytes) { + partial.text = ''; + partial.byteLength = 0; + partial.overflowed = true; + return; + } + partial.text += fragment; + partial.byteLength += fragmentBytes; + } + + private _finishLine(partial: IPartialLine): void { + if (partial.overflowed) { + this._unmatchedLineCount++; + } else { + this._processLine( + partial.text.endsWith('\r') ? partial.text.slice(0, -1) : partial.text, + partial.operationId + ); + } + partial.text = ''; + partial.byteLength = 0; + partial.overflowed = false; + } + + private _processLine(line: string, operationId: string | undefined): void { const normalizedLine: string = normalizeAnsi(line); if (normalizedLine.length === 0) { return; } - for (const matcher of matchers) { + for (const matcher of this._matchers) { const match: RegExpMatchArray | null = normalizedLine.match(matcher.pattern); if (match) { - matchedLineCount++; + this._matchedLineCount++; const problem: IProblemMatch = matcher.extract(match); const key: string = `${operationId ?? ''}|${matcher.tool}|${problem.code ?? ''}|${problem.file ?? ''}|` + `${problem.line ?? ''}|${problem.column ?? ''}|${problem.message}`; - const seen: number = duplicateCounts.get(key) ?? 0; - duplicateCounts.set(key, seen + 1); - if (seen >= maxDuplicates) { - suppressedDuplicateCount++; + const seen: number = this._duplicateCounts.get(key) ?? 0; + this._duplicateCounts.set(key, seen + 1); + if (seen >= this._maxDuplicates) { + this._suppressedDuplicateCount++; return; } - diagnostics.push(buildDiagnostic(matcher, problem, operationId)); + this._diagnostics.push(buildDiagnostic(matcher, problem, operationId)); return; } } - unmatchedLineCount++; - }; - - const chunks: IExternalOutputChunk[] = iterateExternalOutput(events); - for (const chunk of chunks) { - const key: string = chunk.operationId ?? ''; - const buffered: string = (partialLines.get(key) ?? '') + chunk.text; - const lines: string[] = buffered.split('\n'); - const remainder: string = lines.pop() ?? ''; - for (const line of lines) { - processLine(line.endsWith('\r') ? line.slice(0, -1) : line, chunk.operationId); - } - partialLines.set(key, remainder); - } - for (const [key, remainder] of partialLines) { - processLine( - remainder.endsWith('\r') ? remainder.slice(0, -1) : remainder, - key.length > 0 ? key : undefined - ); + this._unmatchedLineCount++; } +} - return { diagnostics, matchedLineCount, unmatchedLineCount, suppressedDuplicateCount }; +/** + * Runs problem matchers over the uncollated external-output stream. + * + * @remarks + * This convenience batch API delegates to {@link ProblemMatcherRunner}. + * + * @param events - the event stream carrying external output + * @param matchers - the active matchers + * @param options - duplicate and buffering cap options + * + * @beta + */ +export function runProblemMatchers( + events: readonly IReporterEventEnvelope[], + matchers: readonly IProblemMatcher[], + options: IRunProblemMatchersOptions = {} +): IProblemMatcherResult { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner(matchers, options); + for (const event of events) { + runner.write(event); + } + runner.flush(); + return runner.result; } function buildDiagnostic( diff --git a/libraries/reporter/src/perf/PerformanceBudgets.ts b/libraries/reporter/src/perf/PerformanceBudgets.ts index ab9a4c4647..ad35b62e78 100644 --- a/libraries/reporter/src/perf/PerformanceBudgets.ts +++ b/libraries/reporter/src/perf/PerformanceBudgets.ts @@ -45,6 +45,30 @@ export interface IReporterPerformanceBudgets { * before summarizing the remainder. Defaults to `20`. */ readonly maxAiDetailedDiagnostics: number; + + /** + * The maximum number of distinct diagnostic codes retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryDiagnosticCodes: number; + + /** + * The maximum number of diagnostic category buckets retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryDiagnosticCategories: number; + + /** + * The maximum number of distinct producer versions retained in a telemetry + * aggregate. Defaults to `20`. + */ + readonly maxTelemetryProducerVersions: number; + + /** + * The maximum character length of one `packageName@packageVersion` telemetry + * entry. Longer entries are omitted. Defaults to `256`. + */ + readonly maxTelemetryProducerVersionLength: number; } /** @@ -68,7 +92,11 @@ export const REPORTER_PERFORMANCE_BUDGETS: IReporterPerformanceBudgets = { maxAdditionalPeakMemoryBytes: 32 * BYTES_PER_MIB, maxInteractiveRefreshHz: 10, maxAiOutputBytes: 64 * BYTES_PER_KIB, - maxAiDetailedDiagnostics: 20 + maxAiDetailedDiagnostics: 20, + maxTelemetryDiagnosticCodes: 20, + maxTelemetryDiagnosticCategories: 20, + maxTelemetryProducerVersions: 20, + maxTelemetryProducerVersionLength: 256 }; /** diff --git a/libraries/reporter/src/protocol/ReporterHandshake.ts b/libraries/reporter/src/protocol/ReporterHandshake.ts index 89f485f543..5dbdab9f50 100644 --- a/libraries/reporter/src/protocol/ReporterHandshake.ts +++ b/libraries/reporter/src/protocol/ReporterHandshake.ts @@ -4,6 +4,8 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; import { createRushDiagnostic } from '../diagnostics/createRushDiagnostic'; +import type { ReporterName, ReporterLogLevel } from '../config/ReporterNames'; +import { isSupportedReporterName, isSupportedLogLevel } from '../config/ReporterNames'; import { isReporterProtocolCompatible } from './ReporterProtocol'; /** @@ -51,6 +53,37 @@ export class InvalidReporterHelloError extends Error { } } +/** + * Parent-owned rendering and filtering context shared with a child producer. + * + * @remarks + * This context is advisory and read-only. A child must not use it to select or + * create the parent's reporters. + * + * @beta + */ +export interface IReporterChildContext { + /** + * The parent's selected primary reporter. + */ + readonly reporter: ReporterName; + + /** + * The parent's selected log level. + */ + readonly logLevel: ReporterLogLevel; + + /** + * Whether the parent renders color. + */ + readonly color: boolean; + + /** + * The parent's terminal width in columns. + */ + readonly terminalWidth: number; +} + /** * The consumer's reply that accepts capabilities and reports unsupported required features. * @@ -76,6 +109,25 @@ export interface IReporterHelloAck { * The producer's required features the consumer does not support. */ readonly rejectedRequiredFeatures: readonly string[]; + + /** + * Parent-owned rendering and filtering context, present only when the + * `reporter-context-v1` capability was accepted. + */ + readonly context?: IReporterChildContext; +} + +/** + * Thrown when an untrusted wire value is not a valid reporter hello acknowledgement. + * + * @beta + */ +export class InvalidReporterHelloAckError extends Error { + public constructor(reason: string) { + super(`Invalid reporter hello acknowledgement: ${reason}`); + this.name = 'InvalidReporterHelloAckError'; + Object.setPrototypeOf(this, InvalidReporterHelloAckError.prototype); + } } /** @@ -88,12 +140,10 @@ export interface IReporterHelloAck { * ignored and unknown required features cause rejection, per protocol rules. * This registry makes the Rush-known set explicit; additions go through API * review like every other contract change. The registry is intentionally - * empty until the first negotiated capability ships. - * * @beta */ // eslint-disable-next-line @typescript-eslint/typedef -- literal inference feeds the derived ReporterCapability type -export const REPORTER_KNOWN_CAPABILITIES = [] as const; +export const REPORTER_KNOWN_CAPABILITIES = ['heft-child-events-v1', 'reporter-context-v1'] as const; /** * A wire capability name. Known members keep autocomplete; unknown members @@ -120,6 +170,11 @@ export interface IReporterHandshakeOptions { * an unknown optional capability and is simply not accepted. */ readonly supportedCapabilities?: readonly ReporterCapability[]; + + /** + * Parent-owned context to include when `reporter-context-v1` is accepted. + */ + readonly context?: IReporterChildContext; } /** @@ -168,6 +223,34 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); } +export function validateReporterChildContext(value: unknown): IReporterChildContext { + if (!isRecord(value)) { + throw new InvalidReporterHelloAckError('context must be an object.'); + } + if (typeof value.reporter !== 'string' || !isSupportedReporterName(value.reporter)) { + throw new InvalidReporterHelloAckError('context.reporter must be a supported reporter name.'); + } + if (typeof value.logLevel !== 'string' || !isSupportedLogLevel(value.logLevel)) { + throw new InvalidReporterHelloAckError('context.logLevel must be a supported reporter log level.'); + } + if (typeof value.color !== 'boolean') { + throw new InvalidReporterHelloAckError('context.color must be a boolean.'); + } + if ( + typeof value.terminalWidth !== 'number' || + !Number.isSafeInteger(value.terminalWidth) || + value.terminalWidth < 1 + ) { + throw new InvalidReporterHelloAckError('context.terminalWidth must be a positive integer.'); + } + return { + reporter: value.reporter, + logLevel: value.logLevel, + color: value.color, + terminalWidth: value.terminalWidth + }; +} + /** * Validates and parses an untrusted wire value as a reporter hello message. * @@ -205,6 +288,49 @@ export function parseReporterHello(value: unknown): IReporterHello { }; } +/** + * Validates and parses an untrusted wire value as a reporter hello acknowledgement. + * + * @param value - the decoded NDJSON value + * @throws {@link InvalidReporterHelloAckError} if the value is malformed + * + * @beta + */ +export function parseReporterHelloAck(value: unknown): IReporterHelloAck { + if (!isRecord(value) || value.kind !== 'helloAck') { + throw new InvalidReporterHelloAckError('expected kind "helloAck".'); + } + if (!isProtocolVersion(value.protocolVersion)) { + throw new InvalidReporterHelloAckError( + 'protocolVersion must contain nonnegative integer major and minor.' + ); + } + if (!isStringArray(value.acceptedCapabilities)) { + throw new InvalidReporterHelloAckError('acceptedCapabilities must be an array of strings.'); + } + if (!isStringArray(value.rejectedRequiredFeatures)) { + throw new InvalidReporterHelloAckError('rejectedRequiredFeatures must be an array of strings.'); + } + const context: IReporterChildContext | undefined = + value.context === undefined ? undefined : validateReporterChildContext(value.context); + if (context !== undefined && !value.acceptedCapabilities.includes('reporter-context-v1')) { + throw new InvalidReporterHelloAckError( + 'context requires the "reporter-context-v1" capability to be accepted.' + ); + } + + return { + kind: 'helloAck', + protocolVersion: { + major: value.protocolVersion.major, + minor: value.protocolVersion.minor + }, + acceptedCapabilities: [...value.acceptedCapabilities], + rejectedRequiredFeatures: [...value.rejectedRequiredFeatures], + ...(context === undefined ? {} : { context }) + }; +} + /** * Negotiates a producer's hello against the consumer's supported protocol. * @@ -237,12 +363,17 @@ export function negotiateReporterHello( const majorSupported: boolean = isReporterProtocolCompatible(consumerVersion, hello.protocolVersion); const accepted: boolean = majorSupported && rejectedRequiredFeatures.length === 0; + const context: IReporterChildContext | undefined = + acceptedCapabilities.includes('reporter-context-v1') && options.context !== undefined + ? validateReporterChildContext(options.context) + : undefined; const ack: IReporterHelloAck = { kind: 'helloAck', protocolVersion: consumerVersion, acceptedCapabilities, - rejectedRequiredFeatures + rejectedRequiredFeatures, + ...(context === undefined ? {} : { context }) }; if (accepted) { diff --git a/libraries/reporter/src/protocol/ReporterProtocol.ts b/libraries/reporter/src/protocol/ReporterProtocol.ts index 15dc630563..a36935eec8 100644 --- a/libraries/reporter/src/protocol/ReporterProtocol.ts +++ b/libraries/reporter/src/protocol/ReporterProtocol.ts @@ -15,7 +15,7 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion */ export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion = { major: 1, - minor: 1 + minor: 2 }; /** diff --git a/libraries/reporter/src/qualification/AiReporterQualification.ts b/libraries/reporter/src/qualification/AiReporterQualification.ts new file mode 100644 index 0000000000..6d659e6a78 --- /dev/null +++ b/libraries/reporter/src/qualification/AiReporterQualification.ts @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { detectAgent } from '../config/AgentDetection'; +import { isLegacyEmergencyFallbackRequested } from '../reporters/LegacyReporter'; +import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; + +/** + * The version of the machine-readable AI reporter qualification result. + * + * @beta + */ +export const AI_REPORTER_QUALIFICATION_SCHEMA_VERSION: '1.0' = '1.0'; + +/** + * The blocking thresholds for the deterministic AI reporter corpus. + * + * @beta + */ +export interface IAiReporterQualificationThresholds { + readonly minimumFailureCases: number; + readonly minimumControlCases: number; + readonly minimumActionableFailurePercent: number; + readonly maximumOutputBytesPerCase: number; + readonly maximumCompactCaseAiOutputBytes: number; + readonly minimumComparableBaselineBytes: number; + readonly maximumPerCaseAiToBaselinePercent: number; + readonly maximumAggregateAiToLegacyPercent: number; + readonly maximumAggregateAiToPlaintextPercent: number; + readonly deterministicRunCount: number; + readonly minimumPrivacyPassPercent: number; + readonly minimumFullLogPassPercent: number; + readonly minimumStdoutContractPassPercent: number; + readonly minimumWarningContractPassPercent: number; +} + +/** + * The frozen qualification thresholds used by CI. + * + * @beta + */ +export const AI_REPORTER_QUALIFICATION_THRESHOLDS: IAiReporterQualificationThresholds = { + minimumFailureCases: 10, + minimumControlCases: 2, + minimumActionableFailurePercent: 100, + maximumOutputBytesPerCase: REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes, + maximumCompactCaseAiOutputBytes: 2 * 1024, + minimumComparableBaselineBytes: 1024, + maximumPerCaseAiToBaselinePercent: 100, + maximumAggregateAiToLegacyPercent: 50, + maximumAggregateAiToPlaintextPercent: 50, + deterministicRunCount: 3, + minimumPrivacyPassPercent: 100, + minimumFullLogPassPercent: 100, + minimumStdoutContractPassPercent: 100, + minimumWarningContractPassPercent: 100 +}; + +/** + * A safe, path-free measurement for one deterministic corpus case. + * + * @beta + */ +export interface IAiReporterQualificationCaseResult { + readonly name: string; + readonly scenario: string; + readonly expectedResult: 'succeeded' | 'failed'; + readonly aiOutputBytes: number; + readonly plaintextOutputBytes: number; + readonly legacyOutputBytes: number; + readonly normalizedAiOutputSha256: string; + readonly actionable: boolean; + readonly deterministic: boolean; + readonly privacySafe: boolean; + readonly fullLogValid: boolean; + readonly stdoutContractValid: boolean; + readonly warningContractValid: boolean; + readonly failures: readonly string[]; +} + +/** + * The result of one blocking qualification gate. + * + * @beta + */ +export interface IAiReporterQualificationGateResult { + readonly id: string; + readonly passed: boolean; + readonly actual: number; + readonly threshold: string; + readonly failedCases: readonly string[]; +} + +/** + * The complete machine-readable AI reporter qualification result. + * + * @beta + */ +export interface IAiReporterQualificationResult { + readonly schemaVersion: typeof AI_REPORTER_QUALIFICATION_SCHEMA_VERSION; + readonly passed: boolean; + readonly thresholds: IAiReporterQualificationThresholds; + readonly cases: readonly IAiReporterQualificationCaseResult[]; + readonly gates: readonly IAiReporterQualificationGateResult[]; +} + +function percent(passing: number, total: number): number { + return total === 0 ? 0 : (passing / total) * 100; +} + +function ratioPercent(numerator: number, denominator: number): number { + return denominator === 0 + ? numerator === 0 + ? 0 + : Number.MAX_SAFE_INTEGER + : (numerator / denominator) * 100; +} + +function getHighestRatioCaseNames( + cases: readonly IAiReporterQualificationCaseResult[], + getDenominator: (testCase: IAiReporterQualificationCaseResult) => number +): string[] { + return [...cases] + .sort( + (left, right) => + ratioPercent(right.aiOutputBytes, getDenominator(right)) - + ratioPercent(left.aiOutputBytes, getDenominator(left)) + ) + .slice(0, 3) + .map(({ name }) => name); +} + +function createPerCaseRatioGate( + id: string, + cases: readonly IAiReporterQualificationCaseResult[], + getDenominator: (testCase: IAiReporterQualificationCaseResult) => number, + minimumComparableBaselineBytes: number, + maximumPercent: number +): IAiReporterQualificationGateResult { + const comparableCases: readonly IAiReporterQualificationCaseResult[] = cases.filter( + (testCase) => getDenominator(testCase) >= minimumComparableBaselineBytes + ); + const failedCases: string[] = comparableCases + .filter((testCase) => ratioPercent(testCase.aiOutputBytes, getDenominator(testCase)) > maximumPercent) + .map(({ name }) => name); + return { + id, + passed: failedCases.length === 0, + actual: Math.max( + 0, + ...comparableCases.map((testCase) => ratioPercent(testCase.aiOutputBytes, getDenominator(testCase))) + ), + threshold: `<= ${maximumPercent}% when baseline >= ${minimumComparableBaselineBytes} bytes`, + failedCases + }; +} + +function createPercentageGate( + id: string, + cases: readonly IAiReporterQualificationCaseResult[], + predicate: (testCase: IAiReporterQualificationCaseResult) => boolean, + minimumPercent: number +): IAiReporterQualificationGateResult { + const failedCases: string[] = cases.filter((testCase) => !predicate(testCase)).map(({ name }) => name); + return { + id, + passed: percent(cases.length - failedCases.length, cases.length) >= minimumPercent, + actual: percent(cases.length - failedCases.length, cases.length), + threshold: `>= ${minimumPercent}%`, + failedCases + }; +} + +/** + * Evaluates safe corpus measurements against the frozen blocking thresholds. + * + * @beta + */ +export function evaluateAiReporterQualification( + cases: readonly IAiReporterQualificationCaseResult[], + thresholds: IAiReporterQualificationThresholds = AI_REPORTER_QUALIFICATION_THRESHOLDS +): IAiReporterQualificationResult { + const failureCases: readonly IAiReporterQualificationCaseResult[] = cases.filter( + ({ expectedResult }) => expectedResult === 'failed' + ); + const controlCases: readonly IAiReporterQualificationCaseResult[] = cases.filter( + ({ expectedResult }) => expectedResult === 'succeeded' + ); + const totalAiBytes: number = cases.reduce((sum, testCase) => sum + testCase.aiOutputBytes, 0); + const totalLegacyBytes: number = cases.reduce((sum, testCase) => sum + testCase.legacyOutputBytes, 0); + const totalPlaintextBytes: number = cases.reduce((sum, testCase) => sum + testCase.plaintextOutputBytes, 0); + const aggregateAiToLegacyPercent: number = ratioPercent(totalAiBytes, totalLegacyBytes); + const aggregateAiToPlaintextPercent: number = ratioPercent(totalAiBytes, totalPlaintextBytes); + + const gates: IAiReporterQualificationGateResult[] = [ + { + id: 'corpus.failure-cases', + passed: failureCases.length >= thresholds.minimumFailureCases, + actual: failureCases.length, + threshold: `>= ${thresholds.minimumFailureCases}`, + failedCases: [] + }, + { + id: 'corpus.control-cases', + passed: controlCases.length >= thresholds.minimumControlCases, + actual: controlCases.length, + threshold: `>= ${thresholds.minimumControlCases}`, + failedCases: [] + }, + createPercentageGate( + 'actionability', + failureCases, + ({ actionable }) => actionable, + thresholds.minimumActionableFailurePercent + ), + { + id: 'size.absolute', + passed: cases.every(({ aiOutputBytes }) => aiOutputBytes <= thresholds.maximumOutputBytesPerCase), + actual: Math.max(0, ...cases.map(({ aiOutputBytes }) => aiOutputBytes)), + threshold: `<= ${thresholds.maximumOutputBytesPerCase} bytes`, + failedCases: cases + .filter(({ aiOutputBytes }) => aiOutputBytes > thresholds.maximumOutputBytesPerCase) + .map(({ name }) => name) + }, + { + id: 'size.compact-case', + passed: cases.every( + ({ aiOutputBytes, legacyOutputBytes, plaintextOutputBytes }) => + Math.max(legacyOutputBytes, plaintextOutputBytes) >= thresholds.minimumComparableBaselineBytes || + aiOutputBytes <= thresholds.maximumCompactCaseAiOutputBytes + ), + actual: Math.max( + 0, + ...cases + .filter( + ({ legacyOutputBytes, plaintextOutputBytes }) => + Math.max(legacyOutputBytes, plaintextOutputBytes) < thresholds.minimumComparableBaselineBytes + ) + .map(({ aiOutputBytes }) => aiOutputBytes) + ), + threshold: + `<= ${thresholds.maximumCompactCaseAiOutputBytes} bytes when both baselines are below ` + + `${thresholds.minimumComparableBaselineBytes} bytes`, + failedCases: cases + .filter( + ({ aiOutputBytes, legacyOutputBytes, plaintextOutputBytes }) => + Math.max(legacyOutputBytes, plaintextOutputBytes) < thresholds.minimumComparableBaselineBytes && + aiOutputBytes > thresholds.maximumCompactCaseAiOutputBytes + ) + .map(({ name }) => name) + }, + createPerCaseRatioGate( + 'size.per-case-vs-legacy', + cases, + ({ legacyOutputBytes }) => legacyOutputBytes, + thresholds.minimumComparableBaselineBytes, + thresholds.maximumPerCaseAiToBaselinePercent + ), + createPerCaseRatioGate( + 'size.per-case-vs-plaintext', + cases, + ({ plaintextOutputBytes }) => plaintextOutputBytes, + thresholds.minimumComparableBaselineBytes, + thresholds.maximumPerCaseAiToBaselinePercent + ), + { + id: 'size.vs-legacy', + passed: aggregateAiToLegacyPercent <= thresholds.maximumAggregateAiToLegacyPercent, + actual: aggregateAiToLegacyPercent, + threshold: `<= ${thresholds.maximumAggregateAiToLegacyPercent}%`, + failedCases: + aggregateAiToLegacyPercent <= thresholds.maximumAggregateAiToLegacyPercent + ? [] + : getHighestRatioCaseNames(cases, ({ legacyOutputBytes }) => legacyOutputBytes) + }, + { + id: 'size.vs-plaintext', + passed: aggregateAiToPlaintextPercent <= thresholds.maximumAggregateAiToPlaintextPercent, + actual: aggregateAiToPlaintextPercent, + threshold: `<= ${thresholds.maximumAggregateAiToPlaintextPercent}%`, + failedCases: + aggregateAiToPlaintextPercent <= thresholds.maximumAggregateAiToPlaintextPercent + ? [] + : getHighestRatioCaseNames(cases, ({ plaintextOutputBytes }) => plaintextOutputBytes) + }, + createPercentageGate('determinism', cases, ({ deterministic }) => deterministic, 100), + createPercentageGate( + 'privacy', + cases, + ({ privacySafe }) => privacySafe, + thresholds.minimumPrivacyPassPercent + ), + createPercentageGate( + 'full-log', + cases, + ({ fullLogValid }) => fullLogValid, + thresholds.minimumFullLogPassPercent + ), + createPercentageGate( + 'stdout-contract', + cases, + ({ stdoutContractValid }) => stdoutContractValid, + thresholds.minimumStdoutContractPassPercent + ), + createPercentageGate( + 'warning-contract', + cases, + ({ warningContractValid }) => warningContractValid, + thresholds.minimumWarningContractPassPercent + ) + ]; + + return { + schemaVersion: AI_REPORTER_QUALIFICATION_SCHEMA_VERSION, + passed: gates.every(({ passed }) => passed), + thresholds, + cases, + gates + }; +} + +/** + * Formats failed gates with actionable case names for CI output. + * + * @beta + */ +export function formatAiReporterQualificationFailures(result: IAiReporterQualificationResult): string { + return result.gates + .filter(({ passed }) => !passed) + .map( + ({ id, actual, threshold, failedCases }) => + `${id}: actual=${Number.isFinite(actual) ? actual.toFixed(2) : String(actual)}, ` + + `required=${threshold}` + + (failedCases.length > 0 ? `; cases=${failedCases.join(', ')}` : '') + ) + .join('\n'); +} + +/** + * The isolated decision produced for a future automatic-selection integration. + * + * @beta + */ +export interface IQualifiedAiReporterDecision { + readonly agentDetected: boolean; + readonly eligible: boolean; + readonly reporter?: 'ai'; + readonly reason: + | 'RUSH_REPORTER=legacy' + | 'agent not detected' + | 'qualification unavailable' + | 'qualification failed' + | 'privacy prerequisite unavailable' + | 'qualified'; +} + +/** + * Resolves whether an agent environment is eligible for a future AI reporter selection. + * + * @remarks + * This helper does not alter reporter selection by itself. The pre-major Rush + * frontend remains opt-in-only until rollout integration explicitly consumes a + * passing result. + * + * @beta + */ +export function getQualifiedAiReporterDecision( + env: Record, + configuredAgentEnvironmentVariables: readonly string[], + qualification: IAiReporterQualificationResult | undefined, + privacyPrerequisiteAccepted: boolean = false +): IQualifiedAiReporterDecision { + const agentDetected: boolean = detectAgent(env, configuredAgentEnvironmentVariables); + if (isLegacyEmergencyFallbackRequested(env)) { + return { agentDetected, eligible: false, reason: 'RUSH_REPORTER=legacy' }; + } + if (!agentDetected) { + return { agentDetected: false, eligible: false, reason: 'agent not detected' }; + } + if (!qualification) { + return { agentDetected: true, eligible: false, reason: 'qualification unavailable' }; + } + if (!qualification.passed) { + return { agentDetected: true, eligible: false, reason: 'qualification failed' }; + } + if (!privacyPrerequisiteAccepted) { + return { agentDetected: true, eligible: false, reason: 'privacy prerequisite unavailable' }; + } + return { agentDetected: true, eligible: true, reporter: 'ai', reason: 'qualified' }; +} diff --git a/libraries/reporter/src/qualification/AiReporterQualificationCorpus.ts b/libraries/reporter/src/qualification/AiReporterQualificationCorpus.ts new file mode 100644 index 0000000000..21a45e6461 --- /dev/null +++ b/libraries/reporter/src/qualification/AiReporterQualificationCorpus.ts @@ -0,0 +1,1131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { ReporterPrivacyClassification } from '../events/ReporterPrivacyClassification'; +import type { IRushRemediationAction } from '../diagnostics/IRushRemediationAction'; +import type { IAiDiagnostic, IAiFinalRecord } from '../reporters/AiReporter'; +import { AiReporter } from '../reporters/AiReporter'; +import { FileReporter, type IFileReporterArtifact } from '../reporters/FileReporter'; +import { JsonReporter } from '../reporters/JsonReporter'; +import { LegacyReporter } from '../reporters/LegacyReporter'; +import { PlaintextReporter } from '../reporters/PlaintextReporter'; +import { + AI_REPORTER_QUALIFICATION_THRESHOLDS, + evaluateAiReporterQualification, + type IAiReporterQualificationCaseResult, + type IAiReporterQualificationGateResult, + type IAiReporterQualificationResult +} from './AiReporterQualification'; + +const FIXED_TIMESTAMP: string = '2026-08-28T08:45:34.000Z'; +const FIXED_TIME_MS: number = Date.parse(FIXED_TIMESTAMP); +const FIXED_PID: number = 4242; +const CLASSIFIED_SECRET: string = 'qualification-fake-secret-token'; +const CLASSIFIED_SECRET_PRODUCER: string = '@secret/qualification-fixture'; +const CLASSIFIED_SECRET_COMPONENT: string = 'SecretQualificationFixture'; +const CLASSIFIED_SECRET_COMMAND: string = 'qualification-secret-command'; +const CLASSIFIED_SECRET_OPERATION: string = 'qualification-secret-operation'; +const CLASSIFIED_SECRET_PROJECT: string = '@private/qualification-secret-project'; +const CLASSIFIED_SECRET_PHASE: string = 'qualification-secret-phase'; +const CLASSIFIED_SECRET_PARENT_SESSION: string = 'qualification-secret-parent-session'; +const CLASSIFIED_SECRET_PARENT_OPERATION: string = 'qualification-secret-parent-operation'; +const CLASSIFIED_SECRET_MESSAGE: string = 'qualification-secret-message-text'; +const CLASSIFIED_SECRET_DIAGNOSTIC: string = 'qualification-secret-diagnostic-summary'; +const PRIVATE_PRODUCER: string = '@private/example-rush-plugin'; +const PRIVATE_COMPONENT: string = 'PrivatePluginImplementation'; +const LOCAL_SENSITIVE_FALLBACK_MESSAGE: string = 'qualification-local-sensitive-fallback-message'; +const OVERSIZED_LOCAL_SENSITIVE_VALUE: string = 'qualification-oversized-local-sensitive-value'; +const OVERSIZED_LOCAL_SENSITIVE_PRODUCER: string = '@private/oversized-qualification-fixture'; +const OVERSIZED_LOCAL_SENSITIVE_COMPONENT: string = 'OversizedPrivateQualificationFixture'; +const OVERSIZED_LOCAL_SENSITIVE_SCOPE: string = '@private/oversized-qualification-project'; + +function createExternalOutput(lines: readonly string[], repetitions: number): string { + const output: string[] = []; + for (let index: number = 0; index < repetitions; index++) { + output.push(lines[index % lines.length].split('{index}').join(String(index + 1))); + } + return `${output.join('\n')}\n`; +} + +interface ICorpusDiagnostic { + readonly code: string; + readonly category: string; + readonly summaryKey: string; + readonly parameters: Readonly< + Record< + string, + { readonly value: string | number | boolean; readonly privacy: ReporterPrivacyClassification } + > + >; + readonly remediation: readonly { + readonly descriptionKey: string; + readonly command?: string; + readonly documentationUrl?: string; + readonly automatedExecutionSafety: 'safe' | 'requires-confirmation' | 'unsafe'; + }[]; + readonly privacy?: ReporterPrivacyClassification; + readonly sourcePackage?: string; + readonly sourceComponent?: string; +} + +interface ICorpusCase { + readonly name: string; + readonly scenario: string; + readonly expectedResult: 'succeeded' | 'failed'; + readonly diagnostic?: ICorpusDiagnostic; + readonly externalOutput?: string; + readonly fallbackMessages?: readonly { + readonly text: string; + readonly privacy: ReporterPrivacyClassification; + }[]; + readonly operationStatus?: 'success' | 'failure' | 'aborted' | 'fromCache'; + readonly warningOnly?: boolean; +} + +interface ICaseRun { + readonly normalizedAiOutput: string; + readonly normalizedPlaintextOutput: string; + readonly normalizedLegacyOutput: string; + readonly result: Omit; +} + +/** @internal */ +export function hasExpectedAiQualificationDiagnostic( + actual: IAiDiagnostic | undefined, + expected: ICorpusDiagnostic +): boolean { + return Boolean( + actual && + actual.code === expected.code && + actual.category === expected.category && + (actual.summaryKey ?? `diagnostic.${actual.code}.summary`) === expected.summaryKey && + Object.keys(actual.context ?? {}).length === Object.keys(expected.parameters).length && + Object.entries(expected.parameters).every(([name, parameter]) => { + const expectedValue: string | number | boolean = + parameter.privacy === 'public' ? parameter.value : `[${parameter.privacy}]`; + return actual.context?.[name] === expectedValue; + }) && + expected.remediation.length > 0 && + actual.remediation?.length === expected.remediation.length && + expected.remediation.every((action, index) => { + const projected: IRushRemediationAction | undefined = actual.remediation?.[index]; + return ( + Boolean(action.command || action.documentationUrl) && + projected?.descriptionKey === action.descriptionKey && + projected.command === action.command && + projected.documentationUrl === action.documentationUrl && + projected.automatedExecutionSafety === action.automatedExecutionSafety + ); + }) + ); +} + +const CORPUS: readonly ICorpusCase[] = [ + { + name: 'bootstrap-unsupported-node', + scenario: 'Rush bootstrap rejects an unsupported Node.js version', + expectedResult: 'failed', + diagnostic: { + code: 'RUSH_ENVIRONMENT_UNSUPPORTED_NODE', + category: 'environment', + summaryKey: 'diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary', + parameters: { + actualVersion: { value: '16.20.0', privacy: 'public' }, + expectedRange: { value: '>=20.0.0', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.install-supported-node', + documentationUrl: 'https://rushjs.io/pages/maintainer/setup_new_repo/', + automatedExecutionSafety: 'requires-confirmation' + } + ] + } + }, + { + name: 'configuration-invalid-json', + scenario: 'rush.json contains invalid JSON', + expectedResult: 'failed', + diagnostic: { + code: 'RUSH_CONFIG_INVALID_JSON', + category: 'configuration', + summaryKey: 'diagnostic.RUSH_CONFIG_INVALID_JSON.summary', + parameters: { + file: { value: 'rush.json', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.fix-rush-json', + command: 'node common/scripts/install-run-rush.js check', + automatedExecutionSafety: 'safe' + } + ] + } + }, + { + name: 'input-unknown-project', + scenario: 'an invalid --only project is rejected', + expectedResult: 'failed', + diagnostic: { + code: 'RUSH_INPUT_UNKNOWN_PROJECT', + category: 'input', + summaryKey: 'diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary', + parameters: { + projectName: { value: '@example/missing', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.list-projects', + command: 'rush list', + automatedExecutionSafety: 'safe' + } + ] + } + }, + { + name: 'dependency-package-manager', + scenario: 'pnpm install exits unsuccessfully', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'ERR_PNPM_FETCH_401 GET https://registry.example.test/@example/pkg: Unauthorized - 401', + 'Progress: resolved {index}, reused 0, downloaded 0, added 0', + 'The authorization header was rejected while resolving @example/pkg.' + ], + 60 + ), + operationStatus: 'failure', + diagnostic: { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + summaryKey: 'diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary', + parameters: { + command: { value: 'pnpm install', privacy: 'public' }, + exitCode: { value: 1, privacy: 'public' }, + logPath: { value: '/private/install.log', privacy: 'local-sensitive' } + }, + remediation: [ + { + descriptionKey: 'remediation.rush-update-purge', + command: 'rush update --purge', + automatedExecutionSafety: 'requires-confirmation' + } + ] + } + }, + { + name: 'operation-build-failure', + scenario: 'a project build operation fails', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'src/example-{index}.ts(12,7): error TS2322: Type string is not assignable to type number.', + 'Found 1 error in src/example-{index}.ts', + 'Project @example/app failed during the _phase:build operation.' + ], + 66 + ), + operationStatus: 'failure', + diagnostic: { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + summaryKey: 'diagnostic.RUSH_OPERATION_FAILED.summary', + parameters: { + projectName: { value: '@example/app', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.rebuild-project', + command: 'rush rebuild --to @example/app', + automatedExecutionSafety: 'safe' + } + ] + } + }, + { + name: 'cache-restore-failure', + scenario: 'a build-cache restore is invalid and requires a local rebuild', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'Build cache entry cache-entry-42 failed integrity validation for @example/cache-consumer.', + 'Expected archive member lib/index.js but the restored file was missing.', + 'Discarding invalid cache entry and requiring a local rebuild ({index}).' + ], + 30 + ), + operationStatus: 'failure', + diagnostic: { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + summaryKey: 'diagnostic.RUSH_OPERATION_FAILED.summary', + parameters: { + cacheKey: { value: 'cache-entry-42', privacy: 'public' }, + projectName: { value: '@example/cache-consumer', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.disable-build-cache', + command: 'rush rebuild --to @example/cache-consumer --disable-build-cache', + automatedExecutionSafety: 'safe' + } + ] + } + }, + { + name: 'network-auth-unauthorized', + scenario: 'the registry returns an authentication-shaped failure', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'GET https://registry.example.test/@example/private returned 401 Unauthorized.', + 'The registry challenge did not include credentials; refresh the configured authentication.', + 'Request attempt {index} failed without exposing an authorization value.' + ], + 24 + ), + diagnostic: { + code: 'RUSH_NETWORK_AUTH_UNAUTHORIZED', + category: 'network-auth', + summaryKey: 'diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary', + parameters: { + registryUrl: { value: 'https://registry.example.test/', privacy: 'public' }, + token: { value: CLASSIFIED_SECRET, privacy: 'secret' } + }, + remediation: [ + { + descriptionKey: 'remediation.refresh-registry-auth', + documentationUrl: 'https://rushjs.io/pages/maintainer/npm_registry_auth/', + automatedExecutionSafety: 'requires-confirmation' + } + ] + } + }, + { + name: 'plugin-api-incompatible', + scenario: 'a private plugin is incompatible with the current Rush API', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'Loading the configured Rush plugin from the repository plugin manifest.', + 'Validating plugin API compatibility before invoking plugin hooks ({index}).', + 'Plugin activation stopped because the declared Rush version range is incompatible.' + ], + 15 + ), + diagnostic: { + code: 'RUSH_PLUGIN_API_INCOMPATIBLE', + category: 'configuration', + summaryKey: 'diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary', + parameters: { + pluginName: { value: PRIVATE_PRODUCER, privacy: 'secret' }, + rushVersion: { value: '5.200.0', privacy: 'public' }, + rushVersionRange: { value: '^5.100.0', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.update-plugin', + command: 'rush update', + automatedExecutionSafety: 'requires-confirmation' + } + ], + privacy: 'local-sensitive', + sourcePackage: PRIVATE_PRODUCER, + sourceComponent: PRIVATE_COMPONENT + } + }, + { + name: 'logical-cancellation', + scenario: 'a command is cancelled and reports an aborted operation', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'Building @example/app: completed work item {index}.', + 'Cancellation requested; waiting for the active child process to stop.', + 'The _phase:build operation exited before producing final outputs.' + ], + 24 + ), + operationStatus: 'aborted', + diagnostic: { + code: 'RUSH_COMMAND_FAILED', + category: 'operation', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + parameters: { + commandName: { value: 'build', privacy: 'public' }, + reason: { value: 'cancelled', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.retry-command', + command: 'rush build', + automatedExecutionSafety: 'safe' + } + ] + } + }, + { + name: 'internal-unexpected-error', + scenario: 'Rush reports an unexpected internal failure', + expectedResult: 'failed', + externalOutput: createExternalOutput( + [ + 'Unexpected internal failure while finalizing the command graph.', + 'Diagnostic incident incident-42 was recorded for correlation.', + 'See the owner-only full-detail log for stack frame {index}.' + ], + 30 + ), + diagnostic: { + code: 'RUSH_INTERNAL_UNEXPECTED', + category: 'internal', + summaryKey: 'diagnostic.RUSH_INTERNAL_UNEXPECTED.summary', + parameters: { + incident: { value: 'incident-42', privacy: 'public' }, + stack: { value: CLASSIFIED_SECRET, privacy: 'secret' } + }, + remediation: [ + { + descriptionKey: 'remediation.report-rush-bug', + documentationUrl: 'https://github.com/microsoft/rushstack/issues/new/choose', + automatedExecutionSafety: 'unsafe' + } + ] + } + }, + { + name: 'fallback-mixed-privacy', + scenario: 'legacy parser errors include public and local-sensitive fallback messages', + expectedResult: 'failed', + fallbackMessages: [ + { + text: 'The requested command could not be parsed.', + privacy: 'public' + }, + { + text: LOCAL_SENSITIVE_FALLBACK_MESSAGE, + privacy: 'local-sensitive' + } + ] + }, + { + name: 'success-no-warning', + scenario: 'a successful operation emits no warnings', + expectedResult: 'succeeded', + externalOutput: createExternalOutput( + [ + 'Building @example/app source file {index}.', + 'Emitted lib/example-{index}.js and lib/example-{index}.d.ts.', + 'Completed incremental compilation work item {index}.' + ], + 42 + ), + operationStatus: 'success' + }, + { + name: 'success-warning-only', + scenario: 'a successful command emits one bounded warning', + expectedResult: 'succeeded', + externalOutput: createExternalOutput( + [ + 'Restored @example/app output group {index} from the local build cache.', + 'Validated cached output metadata for work item {index}.', + 'The deprecated option warning is represented by a structured diagnostic.' + ], + 24 + ), + operationStatus: 'fromCache', + warningOnly: true, + diagnostic: { + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + code: { value: 'W42', privacy: 'public' }, + message: { value: 'deprecated option', privacy: 'public' }, + tool: { value: 'fixture-tool', privacy: 'public' } + }, + remediation: [ + { + descriptionKey: 'remediation.remove-deprecated-option', + command: 'rush check', + automatedExecutionSafety: 'safe' + } + ] + } + } +]; + +export function normalizeAiReporterQualificationOutput( + text: string, + logPath: string, + tempRoot: string +): string { + const replacePath = (value: string, machinePath: string, token: string): string => { + const jsonEscapedPath: string = JSON.stringify(machinePath).slice(1, -1); + return value.split(machinePath).join(token).split(jsonEscapedPath).join(token); + }; + return replacePath(replacePath(text, logPath, ''), tempRoot, '').replace( + /\\/g, + '/' + ); +} + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function parseAiOutput(output: string): { + readonly records: readonly Record[]; + readonly valid: boolean; +} { + try { + const records: Record[] = output + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + return { records, valid: records.length > 0 && output.endsWith('\n') }; + } catch { + return { records: [], valid: false }; + } +} + +async function runInvocationBudgetGateAsync(tempRoot: string): Promise { + const failedCases: string[] = []; + let maximumBytes: number = 0; + for (const name of ['near-limit-diagnostic', 'watch-early-log', 'watch-late-log']) { + let output: string = ''; + let sequence: number = 0; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); + const emit = (type: IReporterEventEnvelope['type'], payload: unknown): void => { + reporter.report({ + protocolVersion: { major: 1, minor: 1 }, + eventId: `budget-${++sequence}`, + sessionId: 'qualification-budget', + sequence, + timestamp: FIXED_TIMESTAMP, + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.200.0' }, + privacy: 'public', + required: true, + type, + payload + }); + }; + const logPath: string | undefined = + name === 'near-limit-diagnostic' + ? undefined + : path.join(tempRoot, ...Array(name === 'watch-late-log' ? 200 : 1).fill('logs'), 'full.log'); + const emitLog = (): void => + emit('artifactAvailable', { role: 'log', path: logPath, format: 'plaintext', complete: true }); + emit('commandStarted', { commandName: 'build' }); + if (name === 'near-limit-diagnostic') { + emit('diagnosticEmitted', { + diagnosticId: 'root', + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + summary: 'x'.repeat(65115) + }); + } else { + if (name === 'watch-early-log') emitLog(); + for (let iterationId: number = 0; iterationId < 1000; iterationId++) { + emit('watchCycleCompleted', { succeeded: true, iterationId }); + } + if (name === 'watch-late-log') emitLog(); + } + emit('commandResult', { succeeded: false, exitCode: 1 }); + await reporter.closeAsync(); + const bytes: number = Buffer.byteLength(output, 'utf8'); + maximumBytes = Math.max(maximumBytes, bytes); + const parsed: ReturnType = parseAiOutput(output); + const final: IAiFinalRecord | undefined = parsed.records.at(-1) as IAiFinalRecord | undefined; + if ( + bytes > AI_REPORTER_QUALIFICATION_THRESHOLDS.maximumOutputBytesPerCase || + !parsed.valid || + final?.kind !== 'ai.final' || + final.result !== 'failed' || + final.exitCode !== 1 || + !Array.isArray(final.diagnostics) || + final.diagnostics.length > 20 || + (logPath !== undefined && (final.log?.path !== logPath || final.log.complete !== true)) + ) { + failedCases.push(name); + } + } + return { + id: 'size.invocation-boundary', + passed: failedCases.length === 0, + actual: maximumBytes, + threshold: + `<= ${AI_REPORTER_QUALIFICATION_THRESHOLDS.maximumOutputBytesPerCase} emitted UTF-8 bytes, ` + + 'including delimiters, with a valid final result and preserved supplied log reference', + failedCases + }; +} + +function createEvents(testCase: ICorpusCase, logPath: string): IReporterEventEnvelope[] { + const events: IReporterEventEnvelope[] = []; + let sequence: number = 0; + const add = ( + type: IReporterEventEnvelope['type'], + payload: unknown, + options: { + readonly privacy?: ReporterPrivacyClassification; + readonly sourcePackage?: string; + readonly sourceComponent?: string; + readonly scope?: IReporterEventEnvelope['scope']; + } = {} + ): void => { + sequence++; + events.push({ + protocolVersion: { major: 1, minor: 1 }, + eventId: `${testCase.name}-event-${sequence}`, + sessionId: `${testCase.name}-session`, + sequence, + timestamp: FIXED_TIMESTAMP, + source: { + packageName: options.sourcePackage ?? '@microsoft/rush-lib', + packageVersion: '5.200.0', + component: options.sourceComponent + }, + scope: options.scope, + privacy: options.privacy ?? 'public', + required: type !== 'activityChanged', + type, + payload + }); + }; + + add('commandStarted', { commandName: 'build' }, { scope: { commandName: 'build' } }); + if (testCase.operationStatus) { + add( + 'operationRegistered', + { operationId: 'fixture#build', projectName: '@example/app', phaseName: '_phase:build' }, + { + scope: { + commandName: 'build', + operationId: 'fixture#build', + projectName: '@example/app', + phaseName: '_phase:build' + } + } + ); + } + if (testCase.externalOutput !== undefined) { + add( + 'externalOutput', + { stream: 'stderr', text: testCase.externalOutput }, + testCase.operationStatus + ? { + privacy: 'local-sensitive', + scope: { + commandName: 'build', + operationId: 'fixture#build', + projectName: '@example/app', + phaseName: '_phase:build' + } + } + : { privacy: 'local-sensitive', scope: { commandName: 'build' } } + ); + } + if (testCase.operationStatus) { + add( + 'operationStatusChanged', + { operationId: 'fixture#build', status: testCase.operationStatus, durationMs: 250 }, + { scope: { commandName: 'build', operationId: 'fixture#build', projectName: '@example/app' } } + ); + add( + 'operationCompleted', + { operationId: 'fixture#build', status: testCase.operationStatus, durationMs: 250 }, + { scope: { commandName: 'build', operationId: 'fixture#build', projectName: '@example/app' } } + ); + } + if (testCase.diagnostic) { + const diagnosticId: string = `${testCase.name}-diagnostic`; + add( + 'diagnosticEmitted', + { + diagnosticId, + code: testCase.diagnostic.code, + category: testCase.diagnostic.category, + severity: testCase.warningOnly ? 'warning' : 'error', + summaryKey: testCase.diagnostic.summaryKey, + parameters: testCase.diagnostic.parameters, + remediation: testCase.diagnostic.remediation, + source: { + kind: 'tool', + toolName: testCase.diagnostic.sourcePackage ?? 'rush' + } + }, + { + privacy: testCase.diagnostic.privacy, + sourcePackage: testCase.diagnostic.sourcePackage, + sourceComponent: testCase.diagnostic.sourceComponent, + scope: { commandName: 'build' } + } + ); + } + for (const message of testCase.fallbackMessages ?? []) { + add( + 'messageEmitted', + { + severity: 'error', + text: message.text + }, + { + privacy: message.privacy, + scope: { commandName: 'build' } + } + ); + } + if (testCase.expectedResult === 'failed') { + add( + 'diagnosticEmitted', + { + diagnosticId: `${testCase.name}-warning`, + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'warning', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: 'fixture-tool', privacy: 'public' }, + code: { value: 'W01', privacy: 'public' }, + message: { value: 'secondary warning', privacy: 'public' } + } + }, + { scope: { commandName: 'build' } } + ); + } + add( + 'extension', + { + name: 'qualification.oversized-local-sensitive', + payload: OVERSIZED_LOCAL_SENSITIVE_VALUE.repeat(128) + }, + { + privacy: 'local-sensitive', + sourcePackage: OVERSIZED_LOCAL_SENSITIVE_PRODUCER, + sourceComponent: OVERSIZED_LOCAL_SENSITIVE_COMPONENT, + scope: { + commandName: 'build', + operationId: 'oversized-local-sensitive-operation', + projectName: OVERSIZED_LOCAL_SENSITIVE_SCOPE + } + } + ); + add( + 'extension', + { name: 'private.fixture.secret', payload: { token: CLASSIFIED_SECRET } }, + { + privacy: 'secret', + sourcePackage: CLASSIFIED_SECRET_PRODUCER, + sourceComponent: CLASSIFIED_SECRET_COMPONENT, + scope: { commandName: 'build' } + } + ); + add( + 'artifactAvailable', + { artifactId: `${testCase.name}-log`, role: 'log', path: logPath, format: 'plaintext', complete: true }, + { privacy: 'local-sensitive', scope: { commandName: 'build' } } + ); + add( + 'commandResult', + { + commandName: 'build', + succeeded: testCase.expectedResult === 'succeeded', + exitCode: testCase.expectedResult === 'succeeded' ? 0 : 1 + }, + { scope: { commandName: 'build' } } + ); + add( + 'sessionCompleted', + { exitCode: testCase.expectedResult === 'succeeded' ? 0 : 1 }, + { scope: { commandName: 'build' } } + ); + return events; +} + +function createSecretProjectionProbeEvents(testCase: ICorpusCase): IReporterEventEnvelope[] { + const createProbe = ( + eventId: string, + sequence: number, + type: IReporterEventEnvelope['type'], + payload: unknown, + scope: IReporterEventEnvelope['scope'] = { + commandName: CLASSIFIED_SECRET_COMMAND, + operationId: CLASSIFIED_SECRET_OPERATION, + projectName: CLASSIFIED_SECRET_PROJECT, + phaseName: CLASSIFIED_SECRET_PHASE + } + ): IReporterEventEnvelope => ({ + protocolVersion: { major: 1, minor: 1 }, + eventId, + sessionId: `${testCase.name}-session`, + parentSessionId: CLASSIFIED_SECRET_PARENT_SESSION, + parentOperationId: CLASSIFIED_SECRET_PARENT_OPERATION, + sequence, + sourceSequence: sequence - 10000, + timestamp: FIXED_TIMESTAMP, + source: { + packageName: CLASSIFIED_SECRET_PRODUCER, + packageVersion: '1.0.0', + component: CLASSIFIED_SECRET_COMPONENT + }, + scope, + privacy: 'secret', + required: true, + type, + payload + }); + + return [ + createProbe( + `${testCase.name}-secret-command`, + 10001, + 'commandStarted', + { + commandName: CLASSIFIED_SECRET_COMMAND + }, + { + commandName: CLASSIFIED_SECRET_COMMAND + } + ), + createProbe(`${testCase.name}-secret-operation-registered`, 10002, 'operationRegistered', { + operationId: CLASSIFIED_SECRET_OPERATION, + projectName: CLASSIFIED_SECRET_PROJECT, + phaseName: CLASSIFIED_SECRET_PHASE + }), + createProbe(`${testCase.name}-secret-operation-completed`, 10003, 'operationCompleted', { + operationId: CLASSIFIED_SECRET_OPERATION, + status: 'failure' + }), + createProbe(`${testCase.name}-secret-message`, 10004, 'messageEmitted', { + severity: 'info', + text: CLASSIFIED_SECRET_MESSAGE + }), + createProbe(`${testCase.name}-secret-diagnostic`, 10005, 'diagnosticEmitted', { + diagnosticId: `${testCase.name}-secret-diagnostic`, + code: 'RUSH_INTERNAL_UNEXPECTED', + category: 'internal', + severity: 'info', + summary: CLASSIFIED_SECRET_DIAGNOSTIC + }) + ]; +} + +async function runCaseAsync( + testCase: ICorpusCase, + caseDirectory: string, + tempRoot: string +): Promise { + let aiOutput: string = ''; + let jsonOutput: string = ''; + let plaintextOutput: string = ''; + let legacyOutput: string = ''; + const fileReporter: FileReporter = new FileReporter({ + commonTempFolder: caseDirectory, + actionName: testCase.name, + pid: FIXED_PID, + nowMs: () => FIXED_TIME_MS + }); + const aiReporter: AiReporter = new AiReporter({ write: (text: string) => (aiOutput += text) }); + const jsonReporter: JsonReporter = new JsonReporter({ + write: (text: string) => (jsonOutput += text), + maxRecordBytes: 1024 + }); + const plaintextReporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => (plaintextOutput += text), + variant: 'detailed', + color: false, + nowMs: () => FIXED_TIME_MS + }); + const legacyReporter: LegacyReporter = new LegacyReporter({ + write: (text: string) => (legacyOutput += text), + maxParallelism: 4 + }); + + await fileReporter.initializeAsync(); + const logPath: string = fileReporter.getArtifact().path!; + const events: readonly IReporterEventEnvelope[] = createEvents(testCase, logPath); + for (const event of events) { + fileReporter.report(event); + aiReporter.report(event); + jsonReporter.report(event); + plaintextReporter.report(event); + legacyReporter.report(event); + } + for (const event of createSecretProjectionProbeEvents(testCase)) { + // Operation grouping is a separate file-sidecar policy. Exercise shared + // file redaction with records that do not enter the grouping path. + if (event.type === 'messageEmitted' || event.type === 'diagnosticEmitted') { + fileReporter.report(event); + } + aiReporter.report(event); + jsonReporter.report(event); + } + await fileReporter.closeAsync(); + await aiReporter.closeAsync(); + await jsonReporter.closeAsync(); + await plaintextReporter.closeAsync(); + await legacyReporter.closeAsync(); + + const normalizedAiOutput: string = normalizeAiReporterQualificationOutput(aiOutput, logPath, tempRoot); + const normalizedPlaintextOutput: string = normalizeAiReporterQualificationOutput( + plaintextOutput, + logPath, + tempRoot + ); + const normalizedLegacyOutput: string = normalizeAiReporterQualificationOutput( + legacyOutput, + logPath, + tempRoot + ); + const parsedAi: { + readonly records: readonly Record[]; + readonly valid: boolean; + } = parseAiOutput(aiOutput); + const parsedJson: { + readonly records: readonly Record[]; + readonly valid: boolean; + } = parseAiOutput(jsonOutput); + const final: IAiFinalRecord | undefined = parsedAi.records.at(-1) as IAiFinalRecord | undefined; + const diagnostic: ICorpusDiagnostic | undefined = testCase.diagnostic; + const matchingDiagnostic: IAiDiagnostic | undefined = diagnostic + ? final?.diagnostics.find(({ code }) => code === diagnostic.code) + : undefined; + const fallbackMessages: readonly { + readonly text: string; + readonly privacy: ReporterPrivacyClassification; + }[] = testCase.fallbackMessages ?? []; + const publicFallbackMessages: readonly string[] = fallbackMessages + .filter(({ privacy }) => privacy === 'public') + .map(({ text }) => text); + const expectedFallbackDiagnostic: ICorpusDiagnostic = { + code: 'RUSH_COMMAND_FAILED', + category: 'command', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + parameters: { commandName: { value: 'build', privacy: 'public' } }, + remediation: [ + { + descriptionKey: 'remediation.review-command-usage', + command: 'rush build --help', + automatedExecutionSafety: 'safe' + } + ] + }; + const actionable: boolean = + testCase.expectedResult === 'succeeded' + ? true + : diagnostic + ? Boolean( + final?.result === 'failed' && + final.errorCodes.includes(diagnostic.code) && + hasExpectedAiQualificationDiagnostic(matchingDiagnostic, diagnostic) + ) + : Boolean( + fallbackMessages.length > 0 && + final?.result === 'failed' && + final.errorCodes.includes('RUSH_COMMAND_FAILED') && + final.errorCount === fallbackMessages.length && + final.diagnosticCategoryCounts.command === fallbackMessages.length && + final.diagnostics.length === publicFallbackMessages.length && + final.diagnostics.every( + (projected, index) => + projected.severity === 'error' && + projected.summary === publicFallbackMessages[index] && + hasExpectedAiQualificationDiagnostic(projected, expectedFallbackDiagnostic) + ) && + final.truncated + ); + + const artifact: IFileReporterArtifact = fileReporter.getArtifact(); + const aiLogPath: string | undefined = final?.log?.path; + const logExists: boolean = aiLogPath !== undefined && fs.existsSync(aiLogPath); + const logContent: string = + logExists && aiLogPath !== undefined ? await fs.promises.readFile(aiLogPath, 'utf8') : ''; + const ownerOnly: boolean = + process.platform === 'win32' || + (logExists && aiLogPath !== undefined && (await fs.promises.stat(aiLogPath)).mode % 0o1000 === 0o600); + const failureCorrelated: boolean = + testCase.expectedResult === 'succeeded' || + (diagnostic + ? logContent.includes(diagnostic.code) && + logContent.includes(`${testCase.name}-diagnostic`) && + logContent.includes(`${testCase.name}-session`) + : fallbackMessages.length > 0 && + fallbackMessages.every(({ text }) => logContent.includes(text)) && + logContent.includes(`${testCase.name}-session`)); + const localSensitiveProducerPreserved: boolean = + diagnostic?.sourcePackage === undefined || + (logContent.includes(diagnostic.sourcePackage) && + (diagnostic.sourceComponent === undefined || logContent.includes(diagnostic.sourceComponent))); + const fullLogValid: boolean = Boolean( + final?.log && + final.log.path === artifact.path && + final.log.format === 'plaintext' && + final.log.complete === artifact.complete && + artifact.available && + artifact.complete && + aiLogPath && + path.isAbsolute(aiLogPath) && + logExists && + ownerOnly && + logContent.includes('"type":"commandStarted"') && + logContent.includes('"type":"commandResult"') && + logContent.includes('"type":"sessionCompleted"') && + (testCase.externalOutput === undefined || logContent.includes(testCase.externalOutput.trim())) && + failureCorrelated && + localSensitiveProducerPreserved && + logContent.includes(OVERSIZED_LOCAL_SENSITIVE_VALUE) && + logContent.includes(OVERSIZED_LOCAL_SENSITIVE_PRODUCER) && + logContent.includes(OVERSIZED_LOCAL_SENSITIVE_COMPONENT) && + logContent.includes(OVERSIZED_LOCAL_SENSITIVE_SCOPE) + ); + const oversizedMarker: Record | undefined = parsedJson.records.find( + ({ payload }) => + ( + payload as + | { readonly name?: string; readonly payload?: { readonly originalType?: string } } + | undefined + )?.name === 'rush.reporter.record-too-large' && + (payload as { readonly payload?: { readonly originalType?: string } }).payload?.originalType === + 'extension' + ); + const oversizedMarkerValid: boolean = + oversizedMarker?.privacy === 'local-sensitive' && + oversizedMarker.scope === undefined && + (oversizedMarker.source as { readonly packageName?: string; readonly packageVersion?: string }) + ?.packageName === '[private-producer]' && + (oversizedMarker.source as { readonly packageName?: string; readonly packageVersion?: string }) + ?.packageVersion === '[private-version]'; + const machinePresentedOutput: string = `${aiOutput}\n${jsonOutput}`; + const humanPresentedOutput: string = `${plaintextOutput}\n${legacyOutput}`; + const allLocalOutput: string = `${machinePresentedOutput}\n${humanPresentedOutput}\n${logContent}`; + const privacySafe: boolean = + !allLocalOutput.includes(CLASSIFIED_SECRET) && + !allLocalOutput.includes(CLASSIFIED_SECRET_PRODUCER) && + !allLocalOutput.includes(CLASSIFIED_SECRET_COMPONENT) && + !allLocalOutput.includes(CLASSIFIED_SECRET_COMMAND) && + !allLocalOutput.includes(CLASSIFIED_SECRET_OPERATION) && + !allLocalOutput.includes(CLASSIFIED_SECRET_PROJECT) && + !allLocalOutput.includes(CLASSIFIED_SECRET_PHASE) && + !allLocalOutput.includes(CLASSIFIED_SECRET_PARENT_SESSION) && + !allLocalOutput.includes(CLASSIFIED_SECRET_PARENT_OPERATION) && + !allLocalOutput.includes(CLASSIFIED_SECRET_MESSAGE) && + !allLocalOutput.includes(CLASSIFIED_SECRET_DIAGNOSTIC) && + !machinePresentedOutput.includes(LOCAL_SENSITIVE_FALLBACK_MESSAGE) && + !machinePresentedOutput.includes(OVERSIZED_LOCAL_SENSITIVE_VALUE) && + !machinePresentedOutput.includes(OVERSIZED_LOCAL_SENSITIVE_PRODUCER) && + !machinePresentedOutput.includes(OVERSIZED_LOCAL_SENSITIVE_COMPONENT) && + !machinePresentedOutput.includes(OVERSIZED_LOCAL_SENSITIVE_SCOPE) && + !aiOutput.includes(PRIVATE_PRODUCER) && + !aiOutput.includes(PRIVATE_COMPONENT) && + !humanPresentedOutput.includes(PRIVATE_PRODUCER) && + !humanPresentedOutput.includes(PRIVATE_COMPONENT); + const warningContractValid: boolean = + testCase.expectedResult === 'failed' + ? final?.warningCount === 1 && final.diagnostics.every(({ severity }) => severity === 'error') + : testCase.warningOnly + ? final?.warningCount === 1 && final.diagnostics.some(({ severity }) => severity === 'warning') + : final?.warningCount === 0; + const failures: string[] = []; + if (!actionable) failures.push('missing stable code/category/context/remediation'); + if (!privacySafe) failures.push('classified secret or private producer identity leaked'); + if (!fullLogValid) failures.push('full log path, permissions, completeness, or correlation invalid'); + if (!parsedAi.valid || !parsedJson.valid || !oversizedMarkerValid) { + failures.push('machine stdout or oversized-record marker contract regressed'); + } + if (!warningContractValid) failures.push('warning suppression/detail contract regressed'); + + return { + normalizedAiOutput, + normalizedPlaintextOutput, + normalizedLegacyOutput, + result: { + name: testCase.name, + scenario: testCase.scenario, + expectedResult: testCase.expectedResult, + aiOutputBytes: Buffer.byteLength(aiOutput, 'utf8'), + plaintextOutputBytes: Buffer.byteLength(plaintextOutput, 'utf8'), + legacyOutputBytes: Buffer.byteLength(legacyOutput, 'utf8'), + actionable, + privacySafe, + fullLogValid, + stdoutContractValid: parsedAi.valid && parsedJson.valid && oversizedMarkerValid, + warningContractValid, + failures + } + }; +} + +/** + * Runs the deterministic, network-free AI reporter qualification corpus. + * + * @remarks + * External package-manager, cache, registry, plugin, cancellation, and internal + * failures are represented by stable canonical event fixtures. Each case runs + * through AI, detailed plaintext, legacy, and full-detail file reporters three + * times. Machine-specific paths are normalized before hashing and are never + * stored in the returned result. + * + * @beta + */ +export async function runAiReporterQualificationCorpusAsync(): Promise { + const tempRoot: string = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'rush-ai-reporter-qualification-') + ); + try { + const runs: ICaseRun[][] = []; + for ( + let runIndex: number = 0; + runIndex < AI_REPORTER_QUALIFICATION_THRESHOLDS.deterministicRunCount; + runIndex++ + ) { + const runDirectory: string = path.join(tempRoot, `run-${runIndex}`); + await fs.promises.mkdir(runDirectory); + const run: ICaseRun[] = []; + for (const testCase of CORPUS) { + const caseDirectory: string = path.join(runDirectory, testCase.name); + await fs.promises.mkdir(caseDirectory); + run.push(await runCaseAsync(testCase, caseDirectory, tempRoot)); + } + runs.push(run); + } + + const results: IAiReporterQualificationCaseResult[] = runs[0].map( + (firstRun: ICaseRun, caseIndex: number) => { + const normalizedOutputs: readonly string[] = runs.map( + (run: readonly ICaseRun[]) => run[caseIndex].normalizedAiOutput + ); + const deterministic: boolean = normalizedOutputs.every( + (output: string) => output === normalizedOutputs[0] + ); + const failures: string[] = [...firstRun.result.failures]; + if (!deterministic) { + failures.push('normalized AI output differed across repeated runs'); + } + return { + ...firstRun.result, + deterministic, + normalizedAiOutputSha256: sha256(firstRun.normalizedAiOutput), + failures + }; + } + ); + const qualification: IAiReporterQualificationResult = evaluateAiReporterQualification(results); + const invocationBudget: IAiReporterQualificationGateResult = await runInvocationBudgetGateAsync(tempRoot); + return { + ...qualification, + passed: qualification.passed && invocationBudget.passed, + gates: [...qualification.gates, invocationBudget] + }; + } finally { + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + } +} diff --git a/libraries/reporter/src/reporters/AiReporter.ts b/libraries/reporter/src/reporters/AiReporter.ts index 818be5c1fd..a3c0995caf 100644 --- a/libraries/reporter/src/reporters/AiReporter.ts +++ b/libraries/reporter/src/reporters/AiReporter.ts @@ -3,8 +3,10 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { ReporterJsonValue } from '../events/ReporterJsonValue'; import type { IReporter } from '../manager/IReporter'; import type { IRushRemediationAction } from '../diagnostics/IRushRemediationAction'; +import type { IClassifiedDiagnosticValue } from '../diagnostics/IClassifiedDiagnosticValue'; import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; @@ -21,10 +23,12 @@ const TERMINAL_STATUSES: ReadonlySet = new Set([ ]); interface IAiDiagnosticState { - readonly errorDiagnostics: IAiDiagnostic[]; - readonly warningDiagnostics: IAiDiagnostic[]; + readonly errorDiagnostics: ICollectedAiDiagnostic[]; + readonly warningDiagnostics: ICollectedAiDiagnostic[]; readonly errorCodes: Set; readonly diagnosticCategoryCounts: { [category: string]: number }; + suppressedSecretErrorCount: number; + suppressedSecretWarningCount: number; errorDiagnosticsTruncated: boolean; warningDiagnosticsTruncated: boolean; errorCount: number; @@ -41,12 +45,19 @@ interface IAiWatchCycleState { watchCompleted: boolean; } +interface IPendingAiProgress { + readonly kind: 'ai.status' | 'ai.watchCycle'; + readonly line: string; +} + function createDiagnosticState(): IAiDiagnosticState { return { errorDiagnostics: [], warningDiagnostics: [], errorCodes: new Set(), diagnosticCategoryCounts: {}, + suppressedSecretErrorCount: 0, + suppressedSecretWarningCount: 0, errorDiagnosticsTruncated: false, warningDiagnosticsTruncated: false, errorCount: 0, @@ -60,13 +71,24 @@ function createDiagnosticState(): IAiDiagnosticState { * @beta */ export interface IAiDiagnostic { + readonly diagnosticId?: string; readonly code: string; readonly category: string; readonly severity: string; readonly summary?: string; + /** + * A nonstandard summary key. The standard `diagnostic.${code}.summary` key is implicit. + */ + readonly summaryKey?: string; + readonly detailKey?: string; + readonly context?: Readonly>; readonly remediation?: readonly IRushRemediationAction[]; } +interface ICollectedAiDiagnostic extends IAiDiagnostic { + readonly causeDiagnosticIds?: readonly string[]; +} + /** * The AI reporter's log reference. * @@ -111,7 +133,8 @@ export interface IAiReporterOptions { readonly write: (text: string) => void; /** - * The maximum size of the final record in bytes. Defaults to 64 KiB. + * The maximum UTF-8 size of the entire invocation, including NDJSON delimiters. + * Defaults to 64 KiB. */ readonly maxBytes?: number; @@ -156,6 +179,10 @@ export class AiReporter implements IReporter { private _pendingResult: { succeeded: boolean; exitCode: number } | undefined; private _legacyIterationId: number; private _latestIterationId: number; + private readonly _pendingProgress: IPendingAiProgress[] = []; + private _pendingProgressBytes: number = 0; + private _writtenBytes: number = 0; + private _progressTruncated: boolean = false; public constructor(options: IAiReporterOptions) { this._write = options.write; @@ -190,17 +217,33 @@ export class AiReporter implements IReporter { } public report(event: IReporterEventEnvelope): void { + if (this._finalEmitted) { + return; + } this._protocolVersion = event.protocolVersion; + if (event.privacy === 'secret') { + switch (event.type) { + case 'diagnosticEmitted': + case 'messageEmitted': + case 'commandResult': + case 'sessionCompleted': + break; + default: + return; + } + } + if ( + this._logPath !== undefined && + event.type !== 'artifactAvailable' && + event.type !== 'commandResult' && + event.type !== 'sessionCompleted' + ) { + this._flushPendingProgress(); + } switch (event.type) { case 'commandStarted': { this._commandName = (event.payload as { commandName: string }).commandName; - this._write( - `${JSON.stringify({ - kind: 'ai.status', - protocolVersion: this._protocolVersion, - commandName: this._commandName - })}\n` - ); + this._writeProgressRecord({ kind: 'ai.status', protocolVersion: this._protocolVersion }); break; } case 'operationRegistered': { @@ -255,11 +298,11 @@ export class AiReporter implements IReporter { break; } case 'diagnosticEmitted': { - const diagnostic: IAiDiagnostic & { iterationId?: number } = event.payload as IAiDiagnostic & { - iterationId?: number; + const diagnostic: { readonly iterationId?: number } = event.payload as { + readonly iterationId?: number; }; this._collectDiagnostic( - diagnostic, + event, diagnostic.iterationId === undefined ? this._globalDiagnostics : this._getWatchCycle(diagnostic.iterationId).diagnostics @@ -273,15 +316,13 @@ export class AiReporter implements IReporter { }; const cycle: IAiWatchCycleState = this._getWatchCycle(payload.iterationId); const succeeded: boolean = payload.succeeded === true; - this._write( - `${JSON.stringify({ - kind: 'ai.watchCycle', - protocolVersion: this._protocolVersion, - succeeded, - operationCounts: { ...cycle.operationCounts }, - failedProjects: [...cycle.failedProjects] - })}\n` - ); + this._writeProgressRecord({ + kind: 'ai.watchCycle', + protocolVersion: this._protocolVersion, + succeeded, + operationCounts: { ...cycle.operationCounts }, + failedProjects: [...cycle.failedProjects] + }); if (payload.iterationId === undefined) { this._legacyIterationId++; } @@ -338,7 +379,9 @@ export class AiReporter implements IReporter { } public async flushAsync(): Promise { - /* no-op */ + if (this._logPath !== undefined) { + this._flushPendingProgress(); + } } public async closeAsync(): Promise { @@ -351,35 +394,51 @@ export class AiReporter implements IReporter { } } - private _collectDiagnostic(diagnostic: IAiDiagnostic, state: IAiDiagnosticState): void { + private _collectDiagnostic(event: IReporterEventEnvelope, state: IAiDiagnosticState): void { + const diagnostic: IAiDiagnostic & { + readonly causeDiagnosticIds?: readonly string[]; + readonly parameters?: Readonly>; + } = event.payload as IAiDiagnostic & { + readonly causeDiagnosticIds?: readonly string[]; + readonly parameters?: Readonly>; + }; + if (event.privacy === 'secret') { + if (diagnostic.severity === 'error') { + state.suppressedSecretErrorCount++; + } else if (diagnostic.severity === 'warning') { + state.suppressedSecretWarningCount++; + } + return; + } if (diagnostic.category !== undefined) { state.diagnosticCategoryCounts[diagnostic.category] = (state.diagnosticCategoryCounts[diagnostic.category] ?? 0) + 1; } + const collected: ICollectedAiDiagnostic = { + diagnosticId: diagnostic.diagnosticId, + code: diagnostic.code, + category: diagnostic.category, + severity: diagnostic.severity, + summary: diagnostic.summary, + summaryKey: + diagnostic.summaryKey === `diagnostic.${diagnostic.code}.summary` ? undefined : diagnostic.summaryKey, + detailKey: diagnostic.detailKey, + context: this._projectDiagnosticContext(diagnostic.parameters), + remediation: diagnostic.remediation, + causeDiagnosticIds: diagnostic.causeDiagnosticIds + }; if (diagnostic.severity === 'error') { state.errorCount++; state.errorCodes.add(diagnostic.code); if (state.errorDiagnostics.length < this._maxDetailedDiagnostics) { - state.errorDiagnostics.push({ - code: diagnostic.code, - category: diagnostic.category, - severity: 'error', - summary: diagnostic.summary, - remediation: diagnostic.remediation - }); + state.errorDiagnostics.push(collected); } else { state.errorDiagnosticsTruncated = true; } } else if (diagnostic.severity === 'warning') { state.warningCount++; if (state.warningDiagnostics.length < this._maxDetailedDiagnostics) { - state.warningDiagnostics.push({ - code: diagnostic.code, - category: diagnostic.category, - severity: 'warning', - summary: diagnostic.summary, - remediation: diagnostic.remediation - }); + state.warningDiagnostics.push(collected); } else { state.warningDiagnosticsTruncated = true; } @@ -418,6 +477,137 @@ export class AiReporter implements IReporter { } } + private _projectDiagnosticContext( + parameters: Readonly> | undefined + ): Readonly> | undefined { + if (!parameters) { + return undefined; + } + const context: Record = {}; + for (const name of Object.keys(parameters).sort()) { + const parameter: IClassifiedDiagnosticValue = parameters[name]; + context[name] = parameter.privacy === 'public' ? parameter.value : (`[${parameter.privacy}]` as const); + } + return Object.keys(context).length > 0 ? context : undefined; + } + + private _orderDiagnostics(diagnostics: readonly ICollectedAiDiagnostic[]): IAiDiagnostic[] { + const byId: Map = new Map(); + for (const diagnostic of diagnostics) { + if (diagnostic.diagnosticId) { + byId.set(diagnostic.diagnosticId, diagnostic); + } + } + + const ordered: IAiDiagnostic[] = []; + const visited: Set = new Set(); + const visiting: Set = new Set(); + const visit = (diagnostic: ICollectedAiDiagnostic): void => { + if (visited.has(diagnostic) || visiting.has(diagnostic)) { + return; + } + visiting.add(diagnostic); + for (const causeId of diagnostic.causeDiagnosticIds ?? []) { + const cause: ICollectedAiDiagnostic | undefined = byId.get(causeId); + if (cause) { + visit(cause); + } + } + visiting.delete(diagnostic); + visited.add(diagnostic); + ordered.push({ + diagnosticId: diagnostic.diagnosticId, + code: diagnostic.code, + category: diagnostic.category, + severity: diagnostic.severity, + summary: diagnostic.summary, + summaryKey: diagnostic.summaryKey, + detailKey: diagnostic.detailKey, + context: diagnostic.context, + remediation: diagnostic.remediation + }); + }; + + for (const diagnostic of diagnostics) { + visit(diagnostic); + } + return ordered; + } + + private _getFinalReserveBytes(): number { + const minimal: IAiFinalRecord = { + kind: 'ai.final', + protocolVersion: this._protocolVersion, + result: 'succeeded', + exitCode: Number.MIN_SAFE_INTEGER, + scope: { failedProjects: [] }, + errorCodes: [], + diagnosticCategoryCounts: {}, + diagnostics: [], + errorCount: Number.MAX_SAFE_INTEGER, + warningCount: Number.MAX_SAFE_INTEGER, + operationCounts: {}, + ...(this._logPath === undefined + ? {} + : { log: { path: this._logPath, format: this._logFormat, complete: false } }), + truncated: true + }; + return Math.max( + Math.min(this._maxBytes, Math.max(MIN_AI_MAX_BYTES, Math.floor(this._maxBytes / 2))), + Buffer.byteLength(JSON.stringify(minimal), 'utf8') + 1 + ); + } + + private _writeProgressRecord( + record: Readonly> & { readonly kind: IPendingAiProgress['kind'] } + ): void { + const line: string = `${JSON.stringify(record)}\n`; + if (this._logPath === undefined) { + // Delay progress until the log reservation is known; the pending queue is itself byte-bounded. + const bytes: number = Buffer.byteLength(line, 'utf8'); + if (this._pendingProgressBytes + bytes <= this._maxBytes) { + this._pendingProgress.push({ kind: record.kind, line }); + this._pendingProgressBytes += bytes; + } else { + this._progressTruncated = true; + } + } else { + this._writeProgressLine(line); + } + } + + private _flushPendingProgress(): void { + const pending: IPendingAiProgress[] = this._pendingProgress.splice(0); + this._pendingProgressBytes = 0; + for (const { kind, line } of pending) { + // A completed result supersedes an unrendered start acknowledgement, not watch history. + if (kind === 'ai.status' && (this._pendingResult !== undefined || this._finalEmitted)) { + continue; + } + this._writeProgressLine(line); + } + } + + private _writeProgressLine(line: string): void { + if ( + this._writtenBytes + Buffer.byteLength(line, 'utf8') + this._getFinalReserveBytes() <= + this._maxBytes + ) { + this._writeLine(line); + } else { + this._progressTruncated = true; + } + } + + private _writeLine(line: string): void { + const bytes: number = Buffer.byteLength(line, 'utf8'); + if (this._writtenBytes + bytes > this._maxBytes) { + throw new Error(`AI reporter output exceeds the invocation budget of ${this._maxBytes} bytes.`); + } + this._writtenBytes += bytes; + this._write(line); + } + private _emitFinal(succeeded: boolean, exitCode: number): void { if (this._finalEmitted) { return; @@ -428,29 +618,51 @@ export class AiReporter implements IReporter { const cycleDiagnostics: IAiDiagnosticState = cycle.diagnostics; const errorCountWithoutFallback: number = this._globalDiagnostics.errorCount + cycleDiagnostics.errorCount; - const warningCount: number = this._globalDiagnostics.warningCount + cycleDiagnostics.warningCount; - const collectedErrorDiagnostics: IAiDiagnostic[] = [ + const suppressedSecretErrorCount: number = + this._globalDiagnostics.suppressedSecretErrorCount + cycleDiagnostics.suppressedSecretErrorCount; + const suppressedSecretWarningCount: number = + this._globalDiagnostics.suppressedSecretWarningCount + cycleDiagnostics.suppressedSecretWarningCount; + const warningCount: number = + this._globalDiagnostics.warningCount + cycleDiagnostics.warningCount + suppressedSecretWarningCount; + const collectedErrorDiagnostics: IAiDiagnostic[] = this._orderDiagnostics([ ...this._globalDiagnostics.errorDiagnostics, ...cycleDiagnostics.errorDiagnostics - ]; - const collectedWarningDiagnostics: IAiDiagnostic[] = [ + ]); + const collectedWarningDiagnostics: IAiDiagnostic[] = this._orderDiagnostics([ ...this._globalDiagnostics.warningDiagnostics, ...cycleDiagnostics.warningDiagnostics - ]; + ]); const fallbackDiagnostics: IAiDiagnostic[] = errorCountWithoutFallback === 0 - ? this._fallbackErrorMessages.map((summary) => ({ - code: 'RUSH_COMMAND_FAILED', - category: 'command', - severity: 'error', - summary - })) + ? this._fallbackErrorMessages.map( + (summary: string): IAiDiagnostic => ({ + code: 'RUSH_COMMAND_FAILED', + category: 'command', + severity: 'error', + summary, + context: + this._commandName === undefined ? { tool: 'rush' } : { commandName: this._commandName }, + remediation: [ + { + descriptionKey: 'remediation.review-command-usage', + command: + this._commandName !== undefined && /^[a-z][a-z0-9:-]*$/i.test(this._commandName) + ? `rush ${this._commandName} --help` + : 'rush --help', + automatedExecutionSafety: 'safe' + } + ] + }) + ) : []; const hasFallbackErrors: boolean = errorCountWithoutFallback === 0 && this._fallbackErrorCount > 0; const errorDiagnostics: IAiDiagnostic[] = hasFallbackErrors ? fallbackDiagnostics : collectedErrorDiagnostics; - const errorCount: number = errorCountWithoutFallback + (hasFallbackErrors ? this._fallbackErrorCount : 0); + const errorCount: number = + errorCountWithoutFallback + + suppressedSecretErrorCount + + (hasFallbackErrors ? this._fallbackErrorCount : 0); const errorCodes: string[] = hasFallbackErrors ? ['RUSH_COMMAND_FAILED'] : [...new Set([...this._globalDiagnostics.errorCodes, ...cycleDiagnostics.errorCodes])].sort(); @@ -497,14 +709,22 @@ export class AiReporter implements IReporter { truncated: hasFailures ? this._globalDiagnostics.errorDiagnosticsTruncated || cycleDiagnostics.errorDiagnosticsTruncated || + suppressedSecretErrorCount > 0 || (hasFallbackErrors && this._fallbackErrorsTruncated) - : this._globalDiagnostics.warningDiagnosticsTruncated || cycleDiagnostics.warningDiagnosticsTruncated + : this._globalDiagnostics.warningDiagnosticsTruncated || + cycleDiagnostics.warningDiagnosticsTruncated || + suppressedSecretWarningCount > 0 }; if (this._logPath !== undefined) { record.log = { path: this._logPath, format: this._logFormat, complete: this._artifactComplete }; } + this._flushPendingProgress(); + record.truncated ||= this._progressTruncated; + const finalBudgetBytes: number = this._maxBytes - this._writtenBytes; + const serializedBytes = (): number => Buffer.byteLength(JSON.stringify(record), 'utf8') + 1; + // Enforce the byte cap by progressively trimming detailed diagnostics, then // error codes, then failed projects, so the record always fits the budget. const trimTargets: Array<{ get: () => unknown[]; set: (value: unknown[]) => void }> = [ @@ -519,29 +739,33 @@ export class AiReporter implements IReporter { } ]; for (const target of trimTargets) { - while (Buffer.byteLength(JSON.stringify(record), 'utf8') > this._maxBytes && target.get().length > 0) { + while (serializedBytes() > finalBudgetBytes && target.get().length > 0) { target.set(target.get().slice(0, target.get().length - 1)); record.truncated = true; } - if (Buffer.byteLength(JSON.stringify(record), 'utf8') <= this._maxBytes) { + if (serializedBytes() <= finalBudgetBytes) { break; } } let serialized: string = JSON.stringify(record); - if (Buffer.byteLength(serialized, 'utf8') > this._maxBytes) { + if (Buffer.byteLength(serialized, 'utf8') + 1 > finalBudgetBytes) { record.scope = { failedProjects: [] }; record.errorCodes = []; record.diagnosticCategoryCounts = {}; record.diagnostics = []; record.operationCounts = {}; - delete record.log; record.truncated = true; serialized = JSON.stringify(record); } - if (Buffer.byteLength(serialized, 'utf8') > this._maxBytes) { + if (Buffer.byteLength(serialized, 'utf8') + 1 > finalBudgetBytes && record.log !== undefined) { + // This can only fit by omitting an intrinsically oversized reference, even with no progress output. + delete record.log; + serialized = JSON.stringify(record); + } + if (Buffer.byteLength(serialized, 'utf8') + 1 > finalBudgetBytes) { throw new Error(`The minimal AI final record exceeds maxBytes=${this._maxBytes}`); } - this._write(`${serialized}\n`); + this._writeLine(`${serialized}\n`); } } diff --git a/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts index ea1fbbc832..58d00409fb 100644 --- a/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts +++ b/libraries/reporter/src/reporters/DefaultInteractiveReporter.ts @@ -4,9 +4,10 @@ import { createHash } from 'node:crypto'; import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; -import type { IReporter } from '../manager/IReporter'; +import type { IReporter, IReporterContext } from '../manager/IReporter'; import { getHumanReadableMessageText } from './ReporterRedaction'; import { formatHumanReadableDiagnostic } from './HumanReadableDiagnostic'; +import { startReporterTimer } from '../utilities/startReporterTimer'; import { SPINNER_FRAMES, MIN_REFRESH_INTERVAL_MS, @@ -139,6 +140,8 @@ export class DefaultInteractiveReporter implements IReporter { private _paintedRowCount: number; private _cursorHidden: boolean; private _finalized: boolean; + private _stopRefreshTimer: (() => void) | undefined; + private _abortSignal: AbortSignal | undefined; public constructor(options: IDefaultInteractiveReporterOptions) { this._terminal = options.terminal; @@ -164,8 +167,24 @@ export class DefaultInteractiveReporter implements IReporter { this._finalized = false; } - public async initializeAsync(): Promise { - /* The cursor is hidden lazily on the first paint. */ + public async initializeAsync(context?: IReporterContext): Promise { + this._abortSignal = context?.abortSignal; + if (!this._stopRefreshTimer && this._terminal.isTTY && !this._finalized) { + this._stopRefreshTimer = startReporterTimer( + context, + () => { + if ( + this._terminal.isTTY && + this._cursorHidden && + !this._finalized && + shouldRefresh(this._lastPaintMs, this._nowMs(), this._minRefreshIntervalMs) + ) { + this._paint(); + } + }, + Math.max(MIN_REFRESH_INTERVAL_MS, this._minRefreshIntervalMs) + ); + } } public report(event: IReporterEventEnvelope): void { @@ -189,6 +208,17 @@ export class DefaultInteractiveReporter implements IReporter { } public async closeAsync(): Promise { + this._stopRefreshTimer?.(); + this._stopRefreshTimer = undefined; + if (this._abortSignal?.aborted && this._abortSignal.reason instanceof Error) { + this._finalized = true; + this._paintedRowCount = 0; + if (this._cursorHidden) { + this._cursorHidden = false; + this._terminal.write(SHOW_CURSOR); + } + return; + } this._finalize(); } diff --git a/libraries/reporter/src/reporters/PlaintextReporter.ts b/libraries/reporter/src/reporters/PlaintextReporter.ts index c652e90e7d..cf2ba64d9d 100644 --- a/libraries/reporter/src/reporters/PlaintextReporter.ts +++ b/libraries/reporter/src/reporters/PlaintextReporter.ts @@ -7,13 +7,14 @@ import * as path from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; -import type { IReporter } from '../manager/IReporter'; +import type { IReporter, IReporterContext } from '../manager/IReporter'; import { getHumanReadableMessageText } from './ReporterRedaction'; import { formatHumanReadableDiagnostic } from './HumanReadableDiagnostic'; import type { PlaintextVariant } from '../config/AutomaticReporterMatrix'; import type { ReporterLogLevel } from '../config/ReporterNames'; import { createColorizer, type IColorizer } from './InteractiveRendering'; import { writeAllSync, WriteAllSyncError } from '../utilities/writeAllSync'; +import { startReporterTimer } from '../utilities/startReporterTimer'; const HEARTBEAT_INTERVAL_MS: number = 30000; const OWNER_ONLY_MODE: number = 0o600; @@ -119,6 +120,8 @@ export class PlaintextReporter implements IReporter { private _nextSpoolId: number; private _legacyIterationId: number; private _latestIterationId: number; + private _disposeHeartbeat: (() => void) | undefined; + private _abortSignal: AbortSignal | undefined; public constructor(options: IPlaintextReporterOptions) { this._write = options.write; @@ -139,8 +142,19 @@ export class PlaintextReporter implements IReporter { this._latestIterationId = 0; } - public async initializeAsync(): Promise { - /* no-op */ + public async initializeAsync(context?: IReporterContext): Promise { + this._abortSignal = context?.abortSignal; + if (!this._disposeHeartbeat && this._logLevel !== 'quiet') { + this._disposeHeartbeat = startReporterTimer( + context, + () => { + if (this._commandName !== undefined) { + this.emitHeartbeatIfDue(); + } + }, + Math.max(1, this._heartbeatIntervalMs) + ); + } } public report(event: IReporterEventEnvelope): void { @@ -247,6 +261,7 @@ export class PlaintextReporter implements IReporter { break; } case 'commandResult': { + this._stopHeartbeat(); this._onResult(event.payload as { commandName: string; succeeded: boolean; exitCode: number }); break; } @@ -260,9 +275,11 @@ export class PlaintextReporter implements IReporter { } public async closeAsync(): Promise { + this._stopHeartbeat(); + const failed: boolean = this._abortSignal?.aborted === true && this._abortSignal.reason instanceof Error; for (const cycle of this._watchCycles.values()) { for (const [operationId, record] of cycle.operations) { - if (!record.silent && this._variant === 'detailed') { + if (!failed && !record.silent && this._variant === 'detailed') { const phase: string = record.phaseName ? ` (${record.phaseName})` : ''; this._writeLine(''); this._writeLine(`==[ ${record.projectName}${phase} ]==`); @@ -293,6 +310,11 @@ export class PlaintextReporter implements IReporter { return false; } + private _stopHeartbeat(): void { + this._disposeHeartbeat?.(); + this._disposeHeartbeat = undefined; + } + private _onOperationCompleted(event: IReporterEventEnvelope): void { const payload: { operationId: string; status: string; iterationId?: number } = event.payload as { operationId: string; @@ -345,10 +367,14 @@ export class PlaintextReporter implements IReporter { return; } const operationId: string | undefined = event.scope?.operationId; - const payload: { text?: string; iterationId?: number } = event.payload as { + const payload: { text?: string; iterationId?: number; wasRendered?: boolean } = event.payload as { text?: string; iterationId?: number; + wasRendered?: boolean; }; + if (payload.wasRendered === true) { + return; + } const text: string = payload.text ?? ''; const cycle: IWatchCycleState = this._getWatchCycle(getIterationId(payload, this._legacyIterationId)); const record: IOperationRecord | undefined = diff --git a/libraries/reporter/src/reporters/ReporterRedaction.ts b/libraries/reporter/src/reporters/ReporterRedaction.ts index 37bc0d2866..5fe52c87a5 100644 --- a/libraries/reporter/src/reporters/ReporterRedaction.ts +++ b/libraries/reporter/src/reporters/ReporterRedaction.ts @@ -37,19 +37,28 @@ export function redactReporterEvent(event: IReporterEventEnvelope): IRe } let payload: unknown = event.payload; + const source: IReporterEventEnvelope['source'] = event.source; if (event.type === 'diagnosticEmitted') { - const diagnostic: { readonly parameters?: Readonly> } = - event.payload as { - readonly parameters?: Readonly>; - }; + const diagnostic: { + readonly parameters?: Readonly>; + readonly source?: unknown; + } = event.payload as { + readonly parameters?: Readonly>; + readonly source?: unknown; + }; + const redactedDiagnostic: { + parameters?: Record; + source?: unknown; + } = { ...diagnostic }; if (diagnostic.parameters) { const parameters: Record = {}; for (const [name, classified] of Object.entries(diagnostic.parameters)) { parameters[name] = classified.privacy === 'secret' ? { value: '[secret]', privacy: 'secret' } : classified; } - payload = { ...diagnostic, parameters }; + redactedDiagnostic.parameters = parameters; } + payload = redactedDiagnostic; } - return { ...event, payload }; + return { ...event, source, payload }; } diff --git a/libraries/reporter/src/telemetry/TelemetryAggregate.ts b/libraries/reporter/src/telemetry/TelemetryAggregate.ts index d54fa16977..12ab291610 100644 --- a/libraries/reporter/src/telemetry/TelemetryAggregate.ts +++ b/libraries/reporter/src/telemetry/TelemetryAggregate.ts @@ -67,7 +67,14 @@ export interface ITelemetryAggregate { readonly protocolVersion?: IReporterProtocolVersion; /** - * The distinct `packageName@packageVersion` producers observed, sorted. + * The distinct `packageName@packageVersion` producers observed on effectively + * public envelopes, sorted. + * + * @remarks + * The list is bounded by the reporter telemetry budgets. Parent-session + * producers are retained before child-session producers, remaining entries + * are selected lexicographically, and entries over the per-entry length + * budget are omitted. Package namespace text does not confer priority. */ readonly producerVersions: readonly string[]; } diff --git a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts index 48b45f5098..7a6d2230ef 100644 --- a/libraries/reporter/src/telemetry/TelemetrySubscriber.ts +++ b/libraries/reporter/src/telemetry/TelemetrySubscriber.ts @@ -5,16 +5,107 @@ import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; import type { IReporter } from '../manager/IReporter'; import type { IOperationStatusChangedPayload } from '../lifecycle/LifecycleEvents'; +import { + isValidRushDiagnosticCode, + RUSH_DIAGNOSTIC_CODE_DEFINITIONS, + type IRushDiagnosticCodeDefinition +} from '../diagnostics/RushDiagnosticCodeRegistry'; +import { REPORTER_PERFORMANCE_BUDGETS } from '../perf/PerformanceBudgets'; import type { ITelemetryAggregate, TelemetryResult } from './TelemetryAggregate'; +const OTHER_DIAGNOSTIC_CATEGORY: 'other' = 'other'; +const REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS: ReadonlyMap = new Map( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition): readonly [string, IRushDiagnosticCodeDefinition] => [ + definition.code, + definition + ] + ) +); +const KNOWN_DIAGNOSTIC_CATEGORIES: ReadonlySet = new Set( + RUSH_DIAGNOSTIC_CODE_DEFINITIONS.map( + (definition: IRushDiagnosticCodeDefinition): string => definition.category + ) +); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isDiagnosticPayloadEffectivelyPublic(payload: unknown): boolean { + if (!isRecord(payload)) { + return false; + } + const parameters: unknown = payload.parameters; + if (parameters === undefined) { + return true; + } + if (!isRecord(parameters)) { + return false; + } + for (const parameter of Object.values(parameters)) { + if (!isRecord(parameter) || parameter.privacy !== 'public') { + return false; + } + } + return true; +} + +function comparePrioritizedCandidates( + left: readonly [value: string, preferred: boolean], + right: readonly [value: string, preferred: boolean] +): number { + if (left[1] !== right[1]) { + return left[1] ? -1 : 1; + } + return left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0; +} + +function recordBoundedPrioritizedValue( + values: Map, + value: string, + preferred: boolean, + maximumCount: number +): void { + const existingPriority: boolean | undefined = values.get(value); + if (existingPriority !== undefined) { + if (preferred && !existingPriority) { + values.set(value, true); + } + return; + } + + if (values.size < maximumCount) { + values.set(value, preferred); + return; + } + + let worstCandidate: readonly [value: string, preferred: boolean] | undefined; + for (const candidate of values) { + if (worstCandidate === undefined || comparePrioritizedCandidates(candidate, worstCandidate) > 0) { + worstCandidate = candidate; + } + } + + const newCandidate: readonly [value: string, preferred: boolean] = [value, preferred]; + if (worstCandidate !== undefined && comparePrioritizedCandidates(newCandidate, worstCandidate) < 0) { + values.delete(worstCandidate[0]); + values.set(value, preferred); + } +} + /** * Consumes canonical events and produces the allowlisted telemetry aggregate. * * @remarks * The subscriber runs before reporter filtering, so it observes every event. It - * extracts only allowlisted values: from a diagnostic it keeps the code and - * category but never the parameters, remediation, or templates; it ignores - * messages, raw external output, and command arguments entirely. + * projects envelope metadata and lifecycle values only from effectively public + * events. A diagnostic containing any non-public parameter is treated as + * non-public even when its envelope floor is `public`. From a non-public + * diagnostic it keeps only a registered code and that code's registry category, + * never parameters, remediation, or templates. It ignores all other values from + * non-public events, messages, raw external output, and command arguments + * entirely. * * @beta */ @@ -27,14 +118,14 @@ export class TelemetrySubscriber { private _protocolVersion: IReporterProtocolVersion | undefined; private readonly _operationStatuses: Map; private readonly _diagnosticCategoryCounts: { [category: string]: number }; - private readonly _diagnosticCodes: Set; - private readonly _producerVersions: Set; + private readonly _diagnosticCodes: Map; + private readonly _producerVersions: Map; public constructor() { this._operationStatuses = new Map(); this._diagnosticCategoryCounts = {}; - this._diagnosticCodes = new Set(); - this._producerVersions = new Set(); + this._diagnosticCodes = new Map(); + this._producerVersions = new Map(); } /** @@ -48,8 +139,47 @@ export class TelemetrySubscriber { * Ingests one event, extracting only allowlisted values. */ public ingest(event: IReporterEventEnvelope): void { - this._protocolVersion = event.protocolVersion; - this._producerVersions.add(`${event.source.packageName}@${event.source.packageVersion}`); + const isEffectivelyPublicEnvelope: boolean = + event.privacy === 'public' && + (event.type !== 'diagnosticEmitted' || isDiagnosticPayloadEffectivelyPublic(event.payload)); + if (isEffectivelyPublicEnvelope) { + if (event.parentSessionId === undefined) { + this._protocolVersion = event.protocolVersion; + } + this._recordProducerVersion( + event.source.packageName, + event.source.packageVersion, + event.parentSessionId === undefined + ); + } + + if (event.type === 'diagnosticEmitted') { + // Code and category are public schema fields even when classified + // parameters make the diagnostic envelope non-public. + const payload: { code?: unknown; category?: unknown } = isRecord(event.payload) ? event.payload : {}; + const registeredDefinition: IRushDiagnosticCodeDefinition | undefined = + typeof payload.code === 'string' + ? REGISTERED_DIAGNOSTIC_CODE_DEFINITIONS.get(payload.code) + : undefined; + if (isEffectivelyPublicEnvelope) { + if (typeof payload.code === 'string' && isValidRushDiagnosticCode(payload.code)) { + this._recordDiagnosticCode(payload.code, registeredDefinition !== undefined); + } + if (typeof payload.category === 'string') { + this._recordDiagnosticCategory( + KNOWN_DIAGNOSTIC_CATEGORIES.has(payload.category) ? payload.category : OTHER_DIAGNOSTIC_CATEGORY + ); + } + } else if (registeredDefinition !== undefined) { + this._recordDiagnosticCode(registeredDefinition.code, true); + this._recordDiagnosticCategory(registeredDefinition.category); + } + return; + } + + if (!isEffectivelyPublicEnvelope) { + return; + } switch (event.type) { case 'commandStarted': { @@ -114,21 +244,6 @@ export class TelemetrySubscriber { this._operationStatuses.set(payload.operationId, payload.status); break; } - case 'diagnosticEmitted': { - // Keeps only the code and category, never parameters, remediation, or templates. - const payload: { code?: string; category?: string } = event.payload as { - code?: string; - category?: string; - }; - if (payload.code !== undefined) { - this._diagnosticCodes.add(payload.code); - } - if (payload.category !== undefined) { - this._diagnosticCategoryCounts[payload.category] = - (this._diagnosticCategoryCounts[payload.category] ?? 0) + 1; - } - break; - } default: { // Messages, raw external output, artifacts, and extension events are not // telemetry. @@ -145,6 +260,10 @@ export class TelemetrySubscriber { for (const status of this._operationStatuses.values()) { operationStatusCounts[status] = (operationStatusCounts[status] ?? 0) + 1; } + const diagnosticCategoryCounts: { [category: string]: number } = {}; + for (const category of Object.keys(this._diagnosticCategoryCounts).sort()) { + diagnosticCategoryCounts[category] = this._diagnosticCategoryCounts[category]; + } const aggregate: { commandName?: string; @@ -159,9 +278,9 @@ export class TelemetrySubscriber { producerVersions: string[]; } = { operationStatusCounts, - diagnosticCodes: [...this._diagnosticCodes].sort(), - diagnosticCategoryCounts: { ...this._diagnosticCategoryCounts }, - producerVersions: [...this._producerVersions].sort() + diagnosticCodes: [...this._diagnosticCodes.keys()].sort(), + diagnosticCategoryCounts, + producerVersions: [...this._producerVersions.keys()].sort() }; if (this._commandName !== undefined) { @@ -185,6 +304,46 @@ export class TelemetrySubscriber { return aggregate; } + + private _recordDiagnosticCode(code: string, registered: boolean): void { + recordBoundedPrioritizedValue( + this._diagnosticCodes, + code, + registered, + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes + ); + } + + private _recordDiagnosticCategory(category: string): void { + const existingCount: number | undefined = this._diagnosticCategoryCounts[category]; + if (existingCount !== undefined) { + this._diagnosticCategoryCounts[category] = existingCount + 1; + return; + } + if ( + Object.keys(this._diagnosticCategoryCounts).length < + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories + ) { + this._diagnosticCategoryCounts[category] = 1; + } + } + + private _recordProducerVersion( + packageName: string, + packageVersion: string, + isParentSessionProducer: boolean + ): void { + const producerVersion: string = `${packageName}@${packageVersion}`; + if (producerVersion.length > REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength) { + return; + } + recordBoundedPrioritizedValue( + this._producerVersions, + producerVersion, + isParentSessionProducer, + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions + ); + } } /** diff --git a/libraries/reporter/src/test/AiReporterQualification.test.ts b/libraries/reporter/src/test/AiReporterQualification.test.ts new file mode 100644 index 0000000000..0cc011c5fd --- /dev/null +++ b/libraries/reporter/src/test/AiReporterQualification.test.ts @@ -0,0 +1,356 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +import { + AiReporter, + AI_REPORTER_QUALIFICATION_THRESHOLDS, + evaluateAiReporterQualification, + formatAiReporterQualificationFailures, + getQualifiedAiReporterDecision, + runAiReporterQualificationCorpusAsync, + type IAiDiagnostic, + type IAiReporterQualificationCaseResult, + type IAiReporterQualificationGateResult, + type IAiReporterQualificationResult +} from '../index'; +import { + hasExpectedAiQualificationDiagnostic, + normalizeAiReporterQualificationOutput +} from '../qualification/AiReporterQualificationCorpus'; +import { + AiQualificationTestSession, + QUALIFICATION_CLEANUP_TIMEOUT_MS, + QUALIFICATION_TEST_TIMEOUT_MS +} from './helpers/AiQualificationTestSession'; +import type { AiQualificationMutation } from './helpers/AiQualificationWorker'; + +describe('AI reporter deterministic qualification corpus', () => { + let qualification: IAiReporterQualificationResult; + const sessions: Set = new Set(); + + function startSession( + mutation: AiQualificationMutation, + options?: ConstructorParameters[1] + ): AiQualificationTestSession { + const session: AiQualificationTestSession = new AiQualificationTestSession(mutation, options); + sessions.add(session); + return session; + } + + afterEach(async () => { + const results: PromiseSettledResult[] = await Promise.allSettled( + [...sessions].map((session) => session.stopAsync()) + ); + sessions.clear(); + const errors: unknown[] = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + if (errors.length > 0) { + throw new AggregateError(errors, 'Failed to join qualification test workers'); + } + }, QUALIFICATION_CLEANUP_TIMEOUT_MS + 1000); + + // Three file-backed corpus passes can exceed Jest's default setup allowance on Windows CI. + beforeAll(async () => { + qualification = await runAiReporterQualificationCorpusAsync(); + }, 15000); + + it('passes every blocking gate with machine-readable safe results', () => { + if (!qualification.passed) { + throw new Error(formatAiReporterQualificationFailures(qualification)); + } + expect(qualification.schemaVersion).toBe('1.0'); + expect(qualification.cases).toHaveLength(13); + expect(qualification.cases.filter(({ expectedResult }) => expectedResult === 'failed')).toHaveLength(11); + expect(qualification.cases.every(({ failures }) => failures.length === 0)).toBe(true); + const serialized: string = JSON.stringify(qualification); + expect(serialized).not.toContain('rush-ai-reporter-qualification-'); + expect(serialized).not.toContain('qualification-fake-secret-token'); + expect(serialized).not.toContain('qualification-secret-command'); + expect(serialized).not.toContain('qualification-secret-operation'); + expect(serialized).not.toContain('@private/qualification-secret-project'); + expect(serialized).not.toContain('qualification-secret-phase'); + expect(serialized).not.toContain('qualification-secret-parent-session'); + expect(serialized).not.toContain('qualification-secret-parent-operation'); + expect(serialized).not.toContain('qualification-secret-message-text'); + expect(serialized).not.toContain('qualification-secret-diagnostic-summary'); + expect(serialized).not.toContain('qualification-local-sensitive-fallback-message'); + expect(serialized).not.toContain('qualification-oversized-local-sensitive-value'); + expect(serialized).not.toContain('@private/oversized-qualification-fixture'); + expect(serialized).not.toContain('@private/example-rush-plugin'); + }); + + it('enforces the documented size and repeat thresholds', () => { + expect(AI_REPORTER_QUALIFICATION_THRESHOLDS).toMatchObject({ + minimumActionableFailurePercent: 100, + maximumOutputBytesPerCase: 64 * 1024, + maximumCompactCaseAiOutputBytes: 2 * 1024, + minimumComparableBaselineBytes: 1024, + maximumPerCaseAiToBaselinePercent: 100, + maximumAggregateAiToLegacyPercent: 50, + maximumAggregateAiToPlaintextPercent: 50, + deterministicRunCount: 3, + minimumPrivacyPassPercent: 100, + minimumFullLogPassPercent: 100, + minimumStdoutContractPassPercent: 100, + minimumWarningContractPassPercent: 100 + }); + expect(qualification.gates.find(({ id }) => id === 'size.invocation-boundary')).toMatchObject({ + passed: true, + failedCases: [] + }); + }); + + it( + 'measures unnormalized emitted UTF-8 output including every delimiter', + async () => { + const { qualification: result, outputs } = await startSession('capture-bytes').resultAsync(); + expect(result.passed).toBe(true); + const outputByLogPath: Map = new Map(); + for (const value of outputs) { + const final: { kind?: string; log?: { path?: string } } = JSON.parse( + value.trimEnd().split('\n').at(-1)! + ); + if (final.kind === 'ai.final' && final.log?.path !== undefined) { + const previous: string | undefined = outputByLogPath.get(final.log.path); + if ( + previous === undefined || + Buffer.byteLength(value, 'utf8') > Buffer.byteLength(previous, 'utf8') + ) { + outputByLogPath.set(final.log.path, value); + } + } + } + const capturedOutput: string[] = [...outputByLogPath.values()].slice(0, result.cases.length); + expect(capturedOutput).toHaveLength(result.cases.length); + expect(capturedOutput.every((output) => output.endsWith('\n'))).toBe(true); + expect(capturedOutput.every((output) => !output.includes(''))).toBe(true); + expect(capturedOutput.map((output) => Buffer.byteLength(output, 'utf8'))).toEqual( + result.cases.map(({ aiOutputBytes }) => aiOutputBytes) + ); + }, + QUALIFICATION_TEST_TIMEOUT_MS + ); + + it('normalizes Windows and POSIX paths without storing machine-specific separators', () => { + expect( + normalizeAiReporterQualificationOutput( + '{"path":"C:\\\\repo\\\\temp\\\\rush.log"}', + 'C:\\repo\\temp\\rush.log', + 'C:\\repo\\temp' + ) + ).toBe('{"path":""}'); + expect( + normalizeAiReporterQualificationOutput( + '{"path":"/repo/temp/rush.log","root":"/repo/temp"}', + '/repo/temp/rush.log', + '/repo/temp' + ) + ).toBe('{"path":"","root":""}'); + }); + + it('reports actionable per-case failures when a blocking gate regresses', () => { + const cases: IAiReporterQualificationCaseResult[] = qualification.cases.map( + (testCase: IAiReporterQualificationCaseResult, index: number) => + index === 0 ? { ...testCase, actionable: false } : testCase + ); + const failed: IAiReporterQualificationResult = evaluateAiReporterQualification(cases); + + expect(failed.passed).toBe(false); + expect(formatAiReporterQualificationFailures(failed)).toContain( + 'actionability: actual=90.91, required=>= 100%; cases=bootstrap-unsupported-node' + ); + }); + + it('fails with an actionable case list when the AI reporter omits its log reference', async () => { + const { qualification: failed } = await startSession('missing-log').resultAsync(); + expect(failed.passed).toBe(false); + expect(formatAiReporterQualificationFailures(failed)).toContain( + 'full-log: actual=0.00, required=>= 100%; cases=' + ); + expect( + failed.cases.every(({ failures }) => + failures.includes('full log path, permissions, completeness, or correlation invalid') + ) + ).toBe(true); + }); + + it( + 'fails qualification when a renderer substitutes unrelated remediation', + async () => { + const { qualification: result } = await startSession('unrelated-remediation').resultAsync(); + const actionability: IAiReporterQualificationGateResult | undefined = result.gates.find( + ({ id }) => id === 'actionability' + ); + expect(actionability?.passed).toBe(false); + expect(actionability?.failedCases).toContain('bootstrap-unsupported-node'); + expect(actionability?.failedCases).toContain('configuration-invalid-json'); + expect(result.passed).toBe(false); + }, + QUALIFICATION_TEST_TIMEOUT_MS + ); + + it( + 'keeps a delayed mutation isolated while another actual corpus completes', + async () => { + const report: typeof AiReporter.prototype.report = AiReporter.prototype.report; + const delayed: AiQualificationTestSession = startSession('missing-log', { waitForRelease: true }); + await delayed.ready; + expect(AiReporter.prototype.report).toBe(report); + const { qualification: next } = await startSession('unrelated-remediation').resultAsync(); + expect(next.gates.find(({ id }) => id === 'full-log')?.passed).toBe(true); + expect(next.gates.find(({ id }) => id === 'actionability')?.passed).toBe(false); + expect(delayed.worker.threadId).not.toBe(-1); + delayed.worker.postMessage('run'); + const { qualification: previous } = await delayed.resultAsync(); + expect(previous.gates.find(({ id }) => id === 'full-log')?.actual).toBe(0); + expect(delayed.worker.threadId).toBe(-1); + expect(fs.existsSync(delayed.tempRoot)).toBe(false); + }, + QUALIFICATION_TEST_TIMEOUT_MS + ); + + it.each(['timeout', 'reject', 'cancel'] as const)( + 'joins %s mutation work before the next negative corpus', + async (failure) => { + const report: typeof AiReporter.prototype.report = AiReporter.prototype.report; + const interrupted: AiQualificationTestSession = startSession('missing-log', { + waitForRelease: true, + timeoutAfterReadyMs: failure === 'timeout' ? 50 : undefined + }); + await interrupted.ready; + expect(AiReporter.prototype.report).toBe(report); + if (failure === 'reject') { + interrupted.worker.postMessage('reject'); + } else if (failure === 'cancel') { + await interrupted.stopAsync(); + } + const outcome = await interrupted.done; + expect(outcome.success).toBe(false); + if (!outcome.success) { + expect(outcome.error.message).toContain( + failure === 'timeout' ? 'timed out' : failure === 'reject' ? 'rejection' : 'cancelled' + ); + } + expect(interrupted.worker.threadId).toBe(-1); + expect(fs.existsSync(interrupted.tempRoot)).toBe(false); + expect(AiReporter.prototype.report).toBe(report); + const { qualification: next } = await startSession('unrelated-remediation').resultAsync(); + expect(next.gates.find(({ id }) => id === 'full-log')?.passed).toBe(true); + expect(next.gates.find(({ id }) => id === 'actionability')?.passed).toBe(false); + }, + QUALIFICATION_TEST_TIMEOUT_MS + ); +}); + +describe('AI qualification actionable diagnostic contract', () => { + const expected: Parameters[1] = { + code: 'RUSH_COMMAND_FAILED', + category: 'command', + summaryKey: 'diagnostic.RUSH_COMMAND_FAILED.summary', + parameters: { commandName: { value: 'build', privacy: 'public' } }, + remediation: [ + { + descriptionKey: 'remediation.review-command-usage', + command: 'rush build --help', + automatedExecutionSafety: 'safe' + } + ] + }; + const valid: IAiDiagnostic = { + code: expected.code, + category: expected.category, + severity: 'error', + summary: 'The requested command could not be parsed.', + context: { commandName: 'build' }, + remediation: expected.remediation + }; + + it('accepts genuinely retained fallback context and the correct usage action', () => { + expect(hasExpectedAiQualificationDiagnostic(valid, expected)).toBe(true); + }); + + it.each>([ + { context: undefined, remediation: undefined }, + { context: { commandName: 'other' } }, + { remediation: [] }, + { remediation: [{ ...expected.remediation[0], command: 'rush --version' }] }, + { remediation: [{ ...expected.remediation[0], automatedExecutionSafety: 'unsafe' }] } + ])('rejects missing or incorrect fallback evidence: %j', (change) => { + expect(hasExpectedAiQualificationDiagnostic({ ...valid, ...change }, expected)).toBe(false); + }); +}); + +describe('qualified AI reporter decision', () => { + function passedQualification(): IAiReporterQualificationResult { + const emptyCases: readonly IAiReporterQualificationCaseResult[] = []; + return { + schemaVersion: '1.0', + passed: true, + thresholds: AI_REPORTER_QUALIFICATION_THRESHOLDS, + cases: emptyCases, + gates: [] + }; + } + + it('recognizes agent variables without activating selection before the privacy prerequisite', () => { + expect(getQualifiedAiReporterDecision({ COPILOT_CLI: '1' }, [], passedQualification())).toMatchObject({ + agentDetected: true, + eligible: false, + reason: 'privacy prerequisite unavailable' + }); + expect( + getQualifiedAiReporterDecision({ MY_AGENT: 'yes' }, ['MY_AGENT'], passedQualification()) + ).toMatchObject({ + agentDetected: true, + eligible: false, + reason: 'privacy prerequisite unavailable' + }); + }); + + it('returns a reusable AI decision only after qualification and privacy are accepted', () => { + expect( + getQualifiedAiReporterDecision({ COPILOT_CLI: '1' }, [], passedQualification(), true) + ).toMatchObject({ + agentDetected: true, + eligible: true, + reporter: 'ai', + reason: 'qualified' + }); + }); + + it('blocks selection when qualification is absent or failed', () => { + expect(getQualifiedAiReporterDecision({ COPILOT_CLI: '1' }, [], undefined, true)).toMatchObject({ + eligible: false, + reason: 'qualification unavailable' + }); + expect( + getQualifiedAiReporterDecision( + { COPILOT_CLI: '1' }, + [], + { ...passedQualification(), passed: false }, + true + ) + ).toMatchObject({ + eligible: false, + reason: 'qualification failed' + }); + }); + + it('keeps RUSH_REPORTER=legacy authoritative even after qualification passes', () => { + expect( + getQualifiedAiReporterDecision( + { COPILOT_CLI: '1', RUSH_REPORTER: 'legacy' }, + [], + passedQualification(), + true + ) + ).toMatchObject({ + agentDetected: true, + eligible: false, + reason: 'RUSH_REPORTER=legacy' + }); + }); +}); diff --git a/libraries/reporter/src/test/Compatibility.test.ts b/libraries/reporter/src/test/Compatibility.test.ts index 971e8dcffc..cdb14abdb3 100644 --- a/libraries/reporter/src/test/Compatibility.test.ts +++ b/libraries/reporter/src/test/Compatibility.test.ts @@ -139,7 +139,8 @@ describe('OldEngineOutputAdapter', () => { expect(event.privacy).toBe('local-sensitive'); expect(event.payload).toEqual({ stream: 'stdout', - text: 'Building project-a...\nproject-a done.\n' + text: 'Building project-a...\nproject-a done.\n', + wasRendered: true }); }); @@ -175,14 +176,13 @@ describe('OldEngineOutputAdapter', () => { }); it('rejects a chunk limit smaller than one UTF-8 code point', () => { - expect( - () => - new OldEngineOutputAdapter({ - sink: new ReporterManager(), - sessionId: 'sess', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, - maxChunkBytes: 1 - }).capture('stdout', '😀') + expect(() => + new OldEngineOutputAdapter({ + sink: new ReporterManager(), + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, + maxChunkBytes: 1 + }).capture('stdout', '😀') ).toThrow(/at least 4/); }); }); diff --git a/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts index e834baa039..8fe52b8066 100644 --- a/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts +++ b/libraries/reporter/src/test/DefaultInteractiveReporter.test.ts @@ -97,6 +97,33 @@ describe('interactive rendering helpers', () => { }); describe('DefaultInteractiveReporter', () => { + it('repaints quiet operations at most ten times per second and clears its unrefed timer', async () => { + jest.useFakeTimers(); + const intervalSpy = jest.spyOn(global, 'setInterval'); + const terminal: FakeTerminal = new FakeTerminal(); + const reporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ terminal, color: false }); + try { + await reporter.initializeAsync(); + expect(intervalSpy.mock.results[0].value.hasRef()).toBe(false); + jest.advanceTimersByTime(1000); + expect(terminal.output).toBe(''); + reporter.report(ev('commandStarted', { commandName: 'quiet-build' })); + terminal.output = ''; + jest.advanceTimersByTime(1000); + expect(terminal.output.split('quiet-build').length - 1).toBe(10); + + await reporter.closeAsync(); + const afterClose: string = terminal.output; + jest.advanceTimersByTime(1000); + expect(terminal.output).toBe(afterClose); + expect(jest.getTimerCount()).toBe(0); + } finally { + await reporter.closeAsync(); + intervalSpy.mockRestore(); + jest.useRealTimers(); + } + }); + it('honors NO_COLOR and FORCE_COLOR when color is not explicit', async () => { const noColorTerminal: FakeTerminal = new FakeTerminal(); const noColorReporter: DefaultInteractiveReporter = new DefaultInteractiveReporter({ diff --git a/libraries/reporter/src/test/FileReporter.test.ts b/libraries/reporter/src/test/FileReporter.test.ts index b58ea33eda..4e80362abd 100644 --- a/libraries/reporter/src/test/FileReporter.test.ts +++ b/libraries/reporter/src/test/FileReporter.test.ts @@ -490,6 +490,37 @@ describe('FileReporter', () => { }); }); + it('retains local-sensitive producer identity but redacts secret producer identity', async () => { + await withTempDir(async (base: string) => { + const reporter: FileReporter = new FileReporter({ commonTempFolder: base, nowMs: () => FIXED_NOW }); + reporter.report({ + ...ev('extension', { name: 'local.plugin.event' }, 'local-sensitive'), + source: { + packageName: '@private/example-rush-plugin', + packageVersion: '1.0.0', + component: 'PrivatePluginImplementation' + } + }); + reporter.report({ + ...ev('extension', { name: 'secret.plugin.event' }, 'secret'), + source: { + packageName: '@secret/example-rush-plugin', + packageVersion: '2.0.0', + component: 'SecretPluginImplementation' + } + }); + await reporter.closeAsync(); + + const content: string = await fs.promises.readFile(reporter.getArtifact().path!, 'utf8'); + expect(content).toContain('@private/example-rush-plugin'); + expect(content).toContain('PrivatePluginImplementation'); + expect(content).not.toContain('@secret/example-rush-plugin'); + expect(content).not.toContain('SecretPluginImplementation'); + expect(content).toContain('[private-producer]'); + expect(content).toContain('[private-version]'); + }); + }); + it('deletes logs older than the retention window and caps the session count', async () => { await withTempDir(async (base: string) => { const logsDir: string = path.join(base, RUSH_LOGS_DIR_NAME); diff --git a/libraries/reporter/src/test/Goldens.test.ts b/libraries/reporter/src/test/Goldens.test.ts index ce0eb3badc..6324ad85f6 100644 --- a/libraries/reporter/src/test/Goldens.test.ts +++ b/libraries/reporter/src/test/Goldens.test.ts @@ -171,7 +171,7 @@ describe('compatibility goldens', () => { // full-detail reporter can retain the complete record. const forwardCompatible: Record = { ...({ - protocolVersion: { major: 1, minor: 1 }, + protocolVersion: { major: 1, minor: 3 }, eventId: 'evt_future', sessionId: 'sess_root', sequence: 9, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index bd1f0d0eed..a061baf9f6 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -2,11 +2,15 @@ // See LICENSE in the project root for license information. import * as childProcess from 'node:child_process'; -import { PassThrough, type Readable } from 'node:stream'; +import { PassThrough, type Readable, type Writable } from 'node:stream'; import { allocateChildDescriptor, + encodeNdjsonRecord, + readChildAckDescriptorFd, readChildDescriptorFd, + REPORTER_PROTOCOL_LIMITS, + RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR, RUSH_REPORTER_CHILD_FD_ENV_VAR, HeftChildEmitter, HeftDescriptorHost, @@ -21,6 +25,7 @@ import { type IReporterEventEnvelope, type IReporterEventSource } from '../index'; +import { validateReporterChildContext } from '../protocol/ReporterHandshake'; const SOURCE: IReporterEventSource = { packageName: '@rushstack/heft', packageVersion: '1.2.19' }; @@ -59,31 +64,42 @@ const TSC_MATCHER: IProblemMatcher = { }; describe('Heft descriptor allocation', () => { - it('allocates an inherited descriptor and communicates it by env var', () => { + it('allocates inherited event and acknowledgement descriptors', () => { const plan: IChildDescriptorPlan = allocateChildDescriptor(); expect(plan.fdNumber).toBe(3); + expect(plan.ackFdNumber).toBe(4); expect(plan.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBe('3'); + expect(plan.env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]).toBe('4'); expect(plan.stdio[3]).toBe('pipe'); + expect(plan.stdio[4]).toBe('pipe'); expect(plan.stdio.slice(0, 3)).toEqual(['inherit', 'pipe', 'pipe']); }); - it('reads or rejects the descriptor number from the environment', () => { + it('reads or rejects descriptor numbers from the environment', () => { expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' })).toBe(3); + expect(readChildAckDescriptorFd({ [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' })).toBe(4); expect(readChildDescriptorFd({})).toBeUndefined(); + expect(readChildAckDescriptorFd({})).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: 'abc' })).toBeUndefined(); + expect(readChildAckDescriptorFd({ [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4abc' })).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3abc' })).toBeUndefined(); expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '2' })).toBeUndefined(); }); it('rejects descriptor numbers that would replace standard streams', () => { expect(() => allocateChildDescriptor(2)).toThrow(/greater than or equal to 3/); + expect(() => allocateChildDescriptor(3, 2)).toThrow(/greater than or equal to 3/); + expect(() => allocateChildDescriptor(3, 3)).toThrow(/must be different/); }); }); describe('HeftChildEmitter', () => { - it('emits structured NDJSON when the descriptor is present', () => { + it('emits structured NDJSON only after a compatible acknowledgement', () => { let descriptor: string = ''; - const env: Record = { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }; + const env: Record = { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }; const emitter: HeftChildEmitter = new HeftChildEmitter({ env, childSessionId: 'child-sess', @@ -92,9 +108,32 @@ describe('HeftChildEmitter', () => { now: () => '2026-01-01T00:00:00.000Z', writeDescriptor: (text: string) => (descriptor += text) }); - expect(emitter.mode).toBe('structured'); + expect(emitter.mode).toBe('negotiation-pending'); expect(env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]).toBeUndefined(); expect(emitter.sendHello()).toBe(true); + expect(emitter.emitEvent({ type: 'commandStarted', payload: {} })).toBeUndefined(); + expect( + emitter.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'plaintext', + logLevel: 'verbose', + color: false, + terminalWidth: 100 + } + }) + ).toBe(true); + expect(emitter.mode).toBe('structured'); + expect(emitter.context).toEqual({ + reporter: 'plaintext', + logLevel: 'verbose', + color: false, + terminalWidth: 100 + }); const eventId: string | undefined = emitter.emitEvent({ type: 'commandStarted', payload: {} @@ -113,6 +152,85 @@ describe('HeftChildEmitter', () => { expect(records[2].required).toBe(false); }); + it('falls back for unsupported, malformed, or missing acknowledgements', () => { + const makeEmitter = (): HeftChildEmitter => { + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeDescriptor: () => undefined + }); + emitter.sendHello(); + return emitter; + }; + + const unsupported: HeftChildEmitter = makeEmitter(); + expect( + unsupported.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: [], + rejectedRequiredFeatures: [] + }) + ).toBe(false); + expect(unsupported.mode).toBe('raw-fallback'); + + const malformed: HeftChildEmitter = makeEmitter(); + expect(malformed.acceptHelloAck({ kind: 'helloAck' })).toBe(false); + expect(malformed.mode).toBe('raw-fallback'); + + const missing: HeftChildEmitter = makeEmitter(); + missing.handleAckDescriptorClose(); + expect(missing.mode).toBe('raw-fallback'); + }); + + it('chunks UTF-8 output into local-sensitive external output events', () => { + let descriptor: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeDescriptor: (text: string) => (descriptor += text) + }); + emitter.sendHello(); + emitter.acceptHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1'], + rejectedRequiredFeatures: [] + }); + const text: string = '😀'.repeat(Math.floor(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes / 4) + 1); + const eventIds: readonly string[] = emitter.emitOutput('stderr', text, { + operationId: 'op-1' + }); + const records: Record[] = descriptor + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + const outputRecords: Record[] = records.slice(1); + + expect(eventIds).toHaveLength(2); + expect(outputRecords.map((record) => (record.payload as { text: string }).text).join('')).toBe(text); + expect( + outputRecords.every( + (record) => + record.type === 'externalOutput' && + record.privacy === 'local-sensitive' && + (record.scope as { operationId: string }).operationId === 'op-1' && + Buffer.byteLength((record.payload as { text: string }).text, 'utf8') <= + REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + ) + ).toBe(true); + }); + it('falls back to raw streams when descriptor negotiation is unavailable', () => { let stdout: string = ''; const emitter: HeftChildEmitter = new HeftChildEmitter({ @@ -132,23 +250,19 @@ describe('HeftChildEmitter', () => { describe('HeftDescriptorHost new descriptor path', () => { it('negotiates the hello and correlates forwarded child events', async () => { - // Child produces a structured stream. let descriptor: string = ''; const child: HeftChildEmitter = new HeftChildEmitter({ - env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, childSessionId: 'child-sess', source: SOURCE, producerVersion: '@rushstack/heft 1.2.19', now: () => '2026-01-01T00:00:00.000Z', writeDescriptor: (text: string) => (descriptor += text) }); - child.sendHello(); - child.emitEvent({ - type: 'operationStatusChanged', - payload: { operationId: 'c1', status: 'success' } - }); - // Parent host forwards into a manager. const manager: ReporterManager = new ReporterManager(); const recording: RecordingReporter = new RecordingReporter(); manager.addReporter(recording); @@ -156,22 +270,59 @@ describe('HeftDescriptorHost new descriptor path', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', + parentRequestId: 'parent-request', parentOperationId: 'op-42', supportedProtocolVersion: { major: 1, minor: 0 }, - forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope) + trustedSource: { + packageName: '@rushstack/heft', + packageVersion: 'trusted' + }, + trustedPrivacy: 'local-sensitive', + context: { + reporter: 'plaintext', + logLevel: 'normal', + color: false, + terminalWidth: 120 + }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope), + sendHelloAck: (ack) => child.acceptHelloAck(ack) + }); + + child.sendHello(); + host.processChildNdjson(descriptor); + descriptor = ''; + expect(child.mode).toBe('structured'); + expect(child.context?.terminalWidth).toBe(120); + child.emitEvent({ + type: 'externalOutput', + privacy: 'public', + scope: { operationId: 'child-selected-operation' }, + payload: { stream: 'stdout', text: 'one' } + }); + child.emitEvent({ + type: 'externalOutput', + payload: { stream: 'stderr', text: 'two' } }); const result: IHeftChildResult = host.processChildNdjson(descriptor); await manager.flushAsync(); expect(result.accepted).toBe(true); - expect(result.eventCount).toBe(1); + expect(result.eventCount).toBe(2); const forwarded: IReporterEventEnvelope = recording.reported[0]; expect(forwarded.sessionId).toBe('child-sess'); expect(forwarded.parentSessionId).toBe('parent-sess'); + expect(forwarded.parentRequestId).toBe('parent-request'); expect(forwarded.parentOperationId).toBe('op-42'); - // ingestForeignEnvelope assigns a new global sequence and preserves the child's. + expect(forwarded.scope?.operationId).toBe('op-42'); + expect(forwarded.source).toEqual({ + packageName: '@rushstack/heft', + packageVersion: 'trusted' + }); expect(forwarded.sourceSequence).toBe(1); + expect(forwarded.privacy).toBe('local-sensitive'); + expect(recording.reported[1].sourceSequence).toBe(2); + expect(recording.reported[1].sequence).toBeGreaterThan(recording.reported[0].sequence); }); it('drains a spawned child descriptor before the child exits and exceeds pipe capacity', async () => { @@ -182,28 +333,24 @@ describe('HeftDescriptorHost new descriptor path', () => { let childExited: boolean = false; let forwardedBeforeExit: boolean = false; - const host: HeftDescriptorHost = new HeftDescriptorHost({ - parentSessionId: 'parent-sess', - supportedProtocolVersion: { major: 1, minor: 0 }, - forwardEnvelope: (envelope: IReporterEventEnvelope) => { - forwardedBeforeExit ||= !childExited; - manager.ingestForeignEnvelope(envelope); - } - }); - const processor = host.createStreamProcessor(); const plan: IChildDescriptorPlan = allocateChildDescriptor(); const eventCount: number = 2_000; const script: string = ` const fs = require('node:fs'); const fd = Number(process.env.${RUSH_REPORTER_CHILD_FD_ENV_VAR}); + const ackFd = Number(process.env.${RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR}); const source = ${JSON.stringify(SOURCE)}; fs.writeSync(fd, JSON.stringify({ kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) + '\\n'); + const ack = JSON.parse(fs.readFileSync(ackFd, 'utf8').trim()); + if (!ack.acceptedCapabilities.includes('heft-child-events-v1')) { + process.exit(2); + } for (let i = 0; i < ${eventCount}; i++) { fs.writeSync(fd, JSON.stringify({ protocolVersion: { major: 1, minor: 0 }, @@ -213,9 +360,9 @@ describe('HeftDescriptorHost new descriptor path', () => { timestamp: '2026-01-01T00:00:00.000Z', source, privacy: 'public', - required: true, - type: 'operationStatusChanged', - payload: { operationId: 'operation-' + i, status: 'success', padding: 'x'.repeat(128) } + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'operation-' + i + ' ' + 'x'.repeat(128) } }) + '\\n'); } `; @@ -223,6 +370,17 @@ describe('HeftDescriptorHost new descriptor path', () => { env: { ...process.env, ...plan.env }, stdio: plan.stdio as childProcess.StdioOptions }); + const acknowledgementDescriptor: Writable = spawned.stdio[plan.ackFdNumber] as Writable; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => { + forwardedBeforeExit ||= !childExited; + manager.ingestForeignEnvelope(envelope); + }, + sendHelloAck: (ack) => acknowledgementDescriptor.end(encodeNdjsonRecord(ack)) + }); + const processor = host.createStreamProcessor(); const descriptor: Readable = spawned.stdio[plan.fdNumber] as Readable; descriptor.setEncoding('utf8'); descriptor.on('data', (chunk: string) => processor.write(chunk)); @@ -250,7 +408,10 @@ describe('HeftDescriptorHost new descriptor path', () => { it('rejects an unsupported child protocol with an update-global-Rush diagnostic', () => { let descriptor: string = ''; const child: HeftChildEmitter = new HeftChildEmitter({ - env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + env: { + [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3', + [RUSH_REPORTER_CHILD_ACK_FD_ENV_VAR]: '4' + }, childSessionId: 'child-sess', source: SOURCE, producerVersion: '@rushstack/heft 2.0.0', @@ -282,7 +443,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 1 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -309,9 +470,9 @@ describe('HeftDescriptorHost new descriptor path', () => { timestamp: '2026-01-01T00:00:00.001Z', source: SOURCE, privacy: 'public', - required: true, - type: 'commandCompleted', - payload: { commandName: 'build', exitCode: 0 } + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'known after future' } }) ).toBe(true); @@ -342,6 +503,49 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(negotiationResults).toEqual([false]); }); + it.each([ + ['malformed', 'not-json\n'], + ['truncated', '{"eventId":'], + ['oversized', `${'x'.repeat(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes + 1)}\n`] + ])('forwards a valid prefix before rejecting a %s record', (kind: string, suffix: string) => { + expect(kind.length).toBeGreaterThan(0); + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => forwarded.push(envelope) + }); + const hello: string = encodeNdjsonRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + const event: string = encodeNdjsonRecord({ + protocolVersion: { major: 1, minor: 0 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'valid\n' } + }); + const processor = host.createStreamProcessor(); + + processor.write(hello + event + suffix); + const result: IHeftChildResult = processor.flush(); + + expect(forwarded).toHaveLength(1); + expect(forwarded[0].eventId).toBe('child_1'); + expect(result.eventCount).toBe(1); + expect(result.accepted).toBe(false); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + it('rejects an incomplete hello instead of dereferencing missing fields', () => { const host: HeftDescriptorHost = new HeftDescriptorHost({ parentSessionId: 'parent-sess', @@ -355,6 +559,25 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + it('rejects a semantically invalid hello without throwing from the live host', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => undefined + }); + + expect(() => + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '', + capabilities: [], + requiredFeatures: [] + }) + ).not.toThrow(); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + it('rejects malformed envelopes and derives required at the host boundary', () => { const forwarded: IReporterEventEnvelope[] = []; const host: HeftDescriptorHost = new HeftDescriptorHost({ @@ -368,7 +591,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -382,11 +605,11 @@ describe('HeftDescriptorHost new descriptor path', () => { source: SOURCE, privacy: 'public', required: true, - type: 'activityChanged', - payload: {} + type: 'externalOutput', + payload: { stream: 'stdout', text: 'valid' } }) ).toBe(true); - expect(forwarded[0].required).toBe(false); + expect(forwarded[0].required).toBe(true); expect(host.processChildRecord(null)).toBe(false); const result: IHeftChildResult = host.processChildRecords([]); @@ -408,7 +631,7 @@ describe('HeftDescriptorHost new descriptor path', () => { kind: 'hello', protocolVersion: { major: 1, minor: 0 }, producerVersion: '@rushstack/heft 1.2.19', - capabilities: [], + capabilities: ['heft-child-events-v1'], requiredFeatures: [] }) ).toBe(true); @@ -428,6 +651,415 @@ describe('HeftDescriptorHost new descriptor path', () => { ).toBe(false); expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); }); + + it('rejects child session changes, non-monotonic sequence, and oversized output chunks', () => { + const makeHost = (): HeftDescriptorHost => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => undefined + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + return host; + }; + const makeOutput = (sessionId: string, sequence: number, text: string): Record => ({ + protocolVersion: { major: 1, minor: 0 }, + eventId: `child_${sequence}`, + sessionId, + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text } + }); + + const changedSessionHost: HeftDescriptorHost = makeHost(); + expect(changedSessionHost.processChildRecord(makeOutput('child-a', 1, 'a'))).toBe(true); + expect(changedSessionHost.processChildRecord(makeOutput('child-b', 2, 'b'))).toBe(false); + expect(changedSessionHost.processChildRecords([]).diagnostic?.code).toBe( + 'RUSH_PROTOCOL_INVALID_CHILD_STREAM' + ); + + const reorderedHost: HeftDescriptorHost = makeHost(); + expect(reorderedHost.processChildRecord(makeOutput('child-a', 2, 'a'))).toBe(true); + expect(reorderedHost.processChildRecord(makeOutput('child-a', 1, 'b'))).toBe(false); + expect(reorderedHost.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + + const oversizedHost: HeftDescriptorHost = makeHost(); + expect( + oversizedHost.processChildRecord( + makeOutput('child-a', 1, 'x'.repeat(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes + 1)) + ) + ).toBe(false); + expect(oversizedHost.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + + it('uses parent-trusted source and a parent privacy floor for forwarded child events', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + trustedSource: { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope: (envelope: IReporterEventEnvelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: 'spoofed producer', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + const makeEvent = (sequence: number, privacy: 'public' | 'secret'): Record => ({ + protocolVersion: { major: 1, minor: 0 }, + eventId: `child_${sequence}`, + sessionId: 'child-sess', + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@malicious/spoof', packageVersion: '999.0.0' }, + privacy, + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: `${sequence}\n` } + }); + + expect(host.processChildRecord(makeEvent(1, 'public'))).toBe(true); + expect( + host.processChildRecord({ + ...makeEvent(2, 'secret'), + leaked: 'TOP_LEVEL_SECRET', + scope: { operationId: 'child-operation', leaked: 'SCOPE_SECRET' }, + payload: { stream: 'stdout', text: 'TOP_SECRET_OUTPUT', leaked: 'TOP_SECRET_EXTRA' } + }) + ).toBe(true); + expect(forwarded.map(({ source }) => source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + { packageName: '@rushstack/heft', packageVersion: 'trusted' } + ]); + expect(forwarded.map(({ privacy }) => privacy)).toEqual(['local-sensitive', 'secret']); + expect((forwarded[1].payload as { text?: string }).text).toBe('[secret child output omitted]'); + expect((forwarded[1].payload as { leaked?: string }).leaked).toBeUndefined(); + expect((forwarded[1] as IReporterEventEnvelope & { leaked?: string }).leaked).toBeUndefined(); + expect((forwarded[1].scope as { leaked?: string } | undefined)?.leaked).toBeUndefined(); + }); + + it('redacts output when the parent raises the effective privacy to secret', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + trustedPrivacy: 'secret', + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'TOP_SECRET_OUTPUT' } + }) + ).toBe(true); + expect(forwarded[0].privacy).toBe('secret'); + expect((forwarded[0].payload as { text?: string }).text).toBe('[secret child output omitted]'); + }); + + it('redacts secret diagnostic parameters and removes child remediation commands', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + trustedSource: { packageName: '@rushstack/heft', packageVersion: 'trusted' }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + message: { value: 'TOP_SECRET_MESSAGE', privacy: 'secret', leaked: 'TOP_SECRET_EXTRA' } + }, + source: { + kind: 'file', + file: 'src/index.ts', + line: 1, + column: 2, + toolName: 'typescript', + leaked: 'TOP_SECRET_SOURCE' + }, + remediation: [ + { + descriptionKey: 'malicious.action', + command: 'run-untrusted-command', + automatedExecutionSafety: 'safe' + } + ] + } + }) + ).toBe(true); + + const diagnostic: { + parameters?: { message?: { value?: string; leaked?: string } }; + source?: { leaked?: string }; + remediation?: unknown; + } = forwarded[0].payload as { + parameters?: { message?: { value?: string; leaked?: string } }; + source?: { leaked?: string }; + remediation?: unknown; + }; + expect(diagnostic.parameters?.message?.value).toBe('[secret]'); + expect(diagnostic.parameters?.message?.leaked).toBeUndefined(); + expect(diagnostic.source?.leaked).toBeUndefined(); + expect(diagnostic.remediation).toBeUndefined(); + }); + + it('validates parent reporter context once and rejects zero terminal width', () => { + expect( + () => + new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + context: { + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 0 + }, + forwardEnvelope: () => undefined + }) + ).toThrow(/terminalWidth must be a positive integer/); + }); + + it('rejects malformed parent reporter context', () => { + expect(() => + validateReporterChildContext({ + reporter: 'json', + logLevel: 'normal', + color: false, + terminalWidth: 'wide' + }) + ).toThrow(/terminalWidth must be a positive integer/); + }); + + it('drops unknown optional events and rejects unknown required events or invalid privacy', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => { + throw new Error('Unknown events must not be forwarded.'); + } + }); + + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + const unknownEvent = { + protocolVersion: { major: 1, minor: 0 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + type: 'futureEvent', + payload: {} + }; + expect(host.processChildRecord({ ...unknownEvent, required: false })).toBe(true); + expect(host.processChildRecord({ ...unknownEvent, eventId: 'child_2', required: true })).toBe(false); + + const invalidPrivacyHost: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => { + throw new Error('Invalid privacy must not be forwarded.'); + } + }); + invalidPrivacyHost.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 0 }, + producerVersion: '@rushstack/heft 1.2.19', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + invalidPrivacyHost.processChildRecord({ + ...unknownEvent, + type: 'commandStarted', + required: true, + privacy: 'private' + }) + ).toBe(false); + }); + + it('rejects a malformed structured diagnostic before forwarding it', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => { + throw new Error('Malformed diagnostics must not be forwarded.'); + } + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: null + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + + it('rejects malformed diagnostic identifier arrays without parameters', () => { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: () => { + throw new Error('Malformed diagnostics must not be forwarded.'); + } + }); + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'local-sensitive', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + causeDiagnosticIds: [123] + } + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); + + it('does not let child events drive parent lifecycle presentation', () => { + const forwarded: IReporterEventEnvelope[] = []; + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 2 }, + forwardEnvelope: (envelope) => forwarded.push(envelope) + }); + expect( + host.processChildRecord({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + ).toBe(true); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-sess', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: SOURCE, + privacy: 'public', + required: false, + type: 'activityChanged', + payload: { text: 'child activity' } + }) + ).toBe(true); + expect(forwarded).toEqual([]); + expect( + host.processChildRecord({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_2', + sessionId: 'child-sess', + sequence: 2, + timestamp: '2026-01-01T00:00:00.001Z', + source: SOURCE, + privacy: 'public', + required: true, + type: 'commandResult', + payload: { commandName: 'build', succeeded: true, exitCode: 0 } + }) + ).toBe(false); + expect(host.processChildRecords([]).diagnostic?.code).toBe('RUSH_PROTOCOL_INVALID_CHILD_STREAM'); + }); }); describe('Heft old raw-stream path', () => { @@ -463,6 +1095,7 @@ describe('Heft old raw-stream path', () => { expect(stdoutText).toBe('old stdout'); expect(stderrText).toBe('old stderr'); expect(spawned.stdio[plan.fdNumber]).not.toBeNull(); + expect(spawned.stdio[plan.ackFdNumber]).not.toBeNull(); }); it('recovers diagnostics from an old Heft version through problem matchers', () => { diff --git a/libraries/reporter/src/test/HumanReadableDiagnostic.test.ts b/libraries/reporter/src/test/HumanReadableDiagnostic.test.ts index 8d5b78d42e..e15581bc69 100644 --- a/libraries/reporter/src/test/HumanReadableDiagnostic.test.ts +++ b/libraries/reporter/src/test/HumanReadableDiagnostic.test.ts @@ -129,6 +129,30 @@ describe(formatHumanReadableDiagnostic.name, () => { expect(output).toContain('^5.100.0'); }); + it('retains unrelated local-sensitive source details even when another parameter is secret', () => { + const event: IReporterEventEnvelope> = { + ...createEvent(), + payload: { + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + severity: 'error', + parameters: { token: { value: 'classified-token', privacy: 'secret' } }, + source: { + kind: 'file', + file: '/private/project/input.ts', + line: 5, + column: 2, + toolName: 'private-compiler' + } + } + }; + const original: string = JSON.stringify(event); + const output: string = formatHumanReadableDiagnostic(event); + + expect(output).toContain('[private-compiler] /private/project/input.ts:5:2'); + expect(output).not.toContain('classified-token'); + expect(JSON.stringify(event)).toBe(original); + }); + it('does not treat an empty secret parameter as an alias of every source string', () => { const output: string = formatHumanReadableDiagnostic( createEvent({ diff --git a/libraries/reporter/src/test/JsonAiReporter.test.ts b/libraries/reporter/src/test/JsonAiReporter.test.ts index d418f99e88..beac11924b 100644 --- a/libraries/reporter/src/test/JsonAiReporter.test.ts +++ b/libraries/reporter/src/test/JsonAiReporter.test.ts @@ -10,6 +10,13 @@ import { type ITelemetryAggregate } from '../index'; +const SECRET_COMMAND: string = 'qualification-secret-command'; +const SECRET_OPERATION: string = 'qualification-secret-operation'; +const SECRET_PROJECT: string = '@private/qualification-secret-project'; +const SECRET_PHASE: string = 'qualification-secret-phase'; +const SECRET_PARENT_SESSION: string = 'qualification-secret-parent-session'; +const SECRET_PARENT_OPERATION: string = 'qualification-secret-parent-operation'; + function ev( type: string, payload: unknown = {}, @@ -57,21 +64,202 @@ describe('JsonReporter', () => { let output: string = ''; const reporter: JsonReporter = new JsonReporter({ write: (text: string) => (output += text), - maxRecordBytes: 512 + maxRecordBytes: 768 + }); + reporter.report({ + ...ev( + 'externalOutput', + { stream: 'stdout', text: 'x'.repeat(1000) }, + { operationId: 'operation-a', projectName: '@example/project' } + ), + sessionId: 'child-session', + parentSessionId: 'parent-session', + parentOperationId: 'parent-operation', + sourceSequence: 3 }); - reporter.report(ev('externalOutput', { stream: 'stdout', text: 'x'.repeat(1000) })); const records: Record[] = parseLines(output); expect(records).toHaveLength(1); expect((records[0].payload as { name: string }).name).toBe('rush.reporter.record-too-large'); expect(records[0]).toMatchObject({ timestamp: '2026-01-01T00:00:00.000Z', + sessionId: 'child-session', + parentSessionId: 'parent-session', + parentOperationId: 'parent-operation', + sourceSequence: 3, source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + scope: { operationId: 'operation-a', projectName: '@example/project' }, privacy: 'public', required: true, type: 'extension' }); - expect(Buffer.byteLength(output.trim(), 'utf8')).toBeLessThanOrEqual(512); + expect(Buffer.byteLength(output.trim(), 'utf8')).toBeLessThanOrEqual(768); + }); + + it('does not expose a secret diagnostic value in an oversized redacted record', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ + write: (text: string) => (output += text), + maxRecordBytes: 512 + }); + reporter.report({ + ...ev('diagnosticEmitted', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + parameters: { + token: { value: 'qualification-fake-secret-token', privacy: 'secret' }, + detail: { value: 'x'.repeat(1000), privacy: 'public' } + } + }), + source: { + packageName: '@private/oversized-reporter', + packageVersion: '1.0.0', + component: 'OversizedPrivateComponent' + }, + scope: { + operationId: 'oversized-private-operation', + projectName: '@private/oversized-project' + }, + privacy: 'local-sensitive' + }); + + expect(output).not.toContain('qualification-fake-secret-token'); + expect(output).not.toContain('@private/oversized-reporter'); + expect(output).not.toContain('OversizedPrivateComponent'); + expect(output).not.toContain('oversized-private-operation'); + expect(output).not.toContain('@private/oversized-project'); + expect(parseLines(output)[0]).toMatchObject({ + source: { + packageName: '[private-producer]', + packageVersion: '[private-version]' + }, + privacy: 'local-sensitive', + payload: { + name: 'rush.reporter.record-too-large' + } + }); + expect(parseLines(output)[0].scope).toBeUndefined(); + }); + + it('redacts local-sensitive message text from JSON stdout without dropping envelope metadata', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ write: (text: string) => (output += text) }); + reporter.report({ + ...ev( + 'messageEmitted', + { + severity: 'error', + text: 'qualification-local-sensitive-message' + }, + { + operationId: 'operation-a', + projectName: '@example/project' + } + ), + privacy: 'local-sensitive' + }); + + expect(output).not.toContain('qualification-local-sensitive-message'); + expect(parseLines(output)[0]).toMatchObject({ + source: { + packageName: '@microsoft/rush-lib', + packageVersion: '5.177.2' + }, + scope: { + operationId: 'operation-a', + projectName: '@example/project' + }, + privacy: 'local-sensitive', + payload: { + severity: 'error', + text: '[local-sensitive]' + } + }); + }); + + it('allowlists metadata for normal and oversized secret envelopes', () => { + let output: string = ''; + const reporter: JsonReporter = new JsonReporter({ + write: (text: string) => (output += text), + maxRecordBytes: 512 + }); + const secretMetadata: Partial> = { + parentSessionId: SECRET_PARENT_SESSION, + parentOperationId: SECRET_PARENT_OPERATION, + sourceSequence: 7, + source: { + packageName: '@private/qualification-secret-producer', + packageVersion: '1.0.0', + component: 'QualificationSecretComponent' + }, + scope: { + commandName: SECRET_COMMAND, + operationId: SECRET_OPERATION, + projectName: SECRET_PROJECT, + phaseName: SECRET_PHASE + }, + privacy: 'secret' + }; + reporter.report({ + ...ev('messageEmitted', { + severity: 'error', + text: 'qualification-secret-message-text' + }), + ...secretMetadata, + eventId: 'secret-message' + } as IReporterEventEnvelope); + reporter.report({ + ...ev('diagnosticEmitted', { + code: 'RUSH_INTERNAL_UNEXPECTED', + summary: 'qualification-secret-diagnostic-summary', + detail: 'x'.repeat(2000) + }), + ...secretMetadata, + eventId: 'secret-diagnostic', + sequence: 2 + } as IReporterEventEnvelope); + + const records: Record[] = parseLines(output); + expect(records).toHaveLength(2); + for (const record of records) { + expect(Object.keys(record).sort()).toEqual( + [ + 'eventId', + 'payload', + 'privacy', + 'protocolVersion', + 'required', + 'sequence', + 'sessionId', + 'source', + 'sourceSequence', + 'timestamp', + 'type' + ].sort() + ); + expect(record).toMatchObject({ + source: { + packageName: '[private-producer]', + packageVersion: '[private-version]' + }, + privacy: 'secret', + payload: '[secret]' + }); + } + for (const sentinel of [ + SECRET_COMMAND, + SECRET_OPERATION, + SECRET_PROJECT, + SECRET_PHASE, + SECRET_PARENT_SESSION, + SECRET_PARENT_OPERATION, + '@private/qualification-secret-producer', + 'QualificationSecretComponent', + 'qualification-secret-message-text', + 'qualification-secret-diagnostic-summary' + ]) { + expect(output).not.toContain(sentinel); + } + expect(output).not.toContain('rush.reporter.record-too-large'); }); it('redacts secret diagnostic fields from stdout', () => { @@ -136,6 +324,7 @@ describe('AiReporter', () => { events: IReporterEventEnvelope[], options?: { maxBytes?: number } ): { + output: string; records: Record[]; final: IAiFinalRecord; } { @@ -149,7 +338,7 @@ describe('AiReporter', () => { } void reporter.closeAsync(); const records: Record[] = parseLines(output); - return { records, final: records[records.length - 1] as unknown as IAiFinalRecord }; + return { output, records, final: records[records.length - 1] as unknown as IAiFinalRecord }; } it('fails closed when commandResult is missing', async () => { @@ -199,11 +388,121 @@ describe('AiReporter', () => { expect.objectContaining({ category: 'command', severity: 'error', - summary: 'The project \"missing\" passed to \"--only\" does not exist in rush.json.' + summary: 'The project \"missing\" passed to \"--only\" does not exist in rush.json.', + context: { commandName: 'build' }, + remediation: [ + { + descriptionKey: 'remediation.review-command-usage', + command: 'rush build --help', + automatedExecutionSafety: 'safe' + } + ] }) ]); }); + it('counts non-public fallback errors without exposing their message text', () => { + const { final } = run([ + ev('commandStarted', { commandName: 'build' }), + { + ...ev('messageEmitted', { + severity: 'error', + text: 'qualification-local-sensitive-message' + }), + privacy: 'local-sensitive' + }, + { + ...ev('messageEmitted', { + severity: 'error', + text: 'qualification-secret-message' + }), + parentSessionId: SECRET_PARENT_SESSION, + parentOperationId: SECRET_PARENT_OPERATION, + scope: { + commandName: SECRET_COMMAND, + operationId: SECRET_OPERATION, + projectName: SECRET_PROJECT, + phaseName: SECRET_PHASE + }, + privacy: 'secret' + }, + ev('artifactAvailable', { + role: 'log', + path: '/protected/rush.log', + format: 'plaintext', + complete: true + }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(final.errorCodes).toEqual(['RUSH_COMMAND_FAILED']); + expect(final.errorCount).toBe(2); + expect(final.diagnosticCategoryCounts.command).toBe(2); + expect(final.diagnostics).toEqual([]); + expect(final.truncated).toBe(true); + expect(final.log).toEqual({ + path: '/protected/rush.log', + format: 'plaintext', + complete: true + }); + expect(JSON.stringify(final)).not.toContain('qualification-local-sensitive-message'); + expect(JSON.stringify(final)).not.toContain('qualification-secret-message'); + expect(JSON.stringify(final)).not.toContain(SECRET_COMMAND); + expect(JSON.stringify(final)).not.toContain(SECRET_OPERATION); + expect(JSON.stringify(final)).not.toContain(SECRET_PROJECT); + expect(JSON.stringify(final)).not.toContain(SECRET_PHASE); + expect(JSON.stringify(final)).not.toContain(SECRET_PARENT_SESSION); + expect(JSON.stringify(final)).not.toContain(SECRET_PARENT_OPERATION); + }); + + it('ignores secret lifecycle context in AI status and final scope', () => { + const secretEnvelope: Partial> = { + parentSessionId: SECRET_PARENT_SESSION, + parentOperationId: SECRET_PARENT_OPERATION, + sourceSequence: 7, + scope: { + commandName: SECRET_COMMAND, + operationId: SECRET_OPERATION, + projectName: SECRET_PROJECT, + phaseName: SECRET_PHASE + }, + privacy: 'secret' + }; + const { records, final } = run([ + { + ...ev('commandStarted', { commandName: SECRET_COMMAND }), + ...secretEnvelope + } as IReporterEventEnvelope, + { + ...ev('operationRegistered', { + operationId: SECRET_OPERATION, + projectName: SECRET_PROJECT, + phaseName: SECRET_PHASE + }), + ...secretEnvelope + } as IReporterEventEnvelope, + { + ...ev('operationCompleted', { + operationId: SECRET_OPERATION, + status: 'failure' + }), + ...secretEnvelope + } as IReporterEventEnvelope, + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(records.filter(({ kind }) => kind === 'ai.status')).toEqual([]); + expect(final.scope).toEqual({ failedProjects: [] }); + expect(final.operationCounts).toEqual({}); + const serialized: string = JSON.stringify(records); + expect(serialized).not.toContain(SECRET_COMMAND); + expect(serialized).not.toContain(SECRET_OPERATION); + expect(serialized).not.toContain(SECRET_PROJECT); + expect(serialized).not.toContain(SECRET_PHASE); + expect(serialized).not.toContain(SECRET_PARENT_SESSION); + expect(serialized).not.toContain(SECRET_PARENT_OPERATION); + }); + it('counts fallback errors even when detailed diagnostics are disabled', async () => { let output: string = ''; const reporter: AiReporter = new AiReporter({ @@ -223,7 +522,7 @@ describe('AiReporter', () => { expect(final.truncated).toBe(true); }); - it('emits a status record and a bounded final record with scope, codes, and log', () => { + it('coalesces an unrendered start into a complete final record without losing scope, codes, or log', () => { const { records, final } = run([ ev('commandStarted', { commandName: 'build' }), ev('operationRegistered', { operationId: 'op1', projectName: 'project-a' }), @@ -239,7 +538,7 @@ describe('AiReporter', () => { ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) ]); - expect(records[0].kind).toBe('ai.status'); + expect(records.map(({ kind }) => kind)).toEqual(['ai.final']); expect(final.kind).toBe('ai.final'); expect(final.result).toBe('failed'); expect(final.exitCode).toBe(1); @@ -251,6 +550,113 @@ describe('AiReporter', () => { expect(final.log).toEqual({ path: '/abs/rush.log', format: 'plaintext', complete: true }); }); + it.each(['event', 'flush'])( + 'preserves active-command status at the next %s boundary', + async (boundary: string) => { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report( + ev('artifactAvailable', { role: 'log', path: '/abs/rush.log', format: 'plaintext', complete: false }) + ); + expect(output).toBe(''); + if (boundary === 'event') { + reporter.report(ev('activityChanged', { text: 'building' })); + } else { + await reporter.flushAsync(); + } + expect(parseLines(output).map(({ kind }) => kind)).toEqual(['ai.status']); + reporter.report(ev('commandResult', { succeeded: true, exitCode: 0 })); + reporter.report(ev('artifactAvailable', { role: 'log', path: '/abs/rush.log', complete: true })); + await reporter.closeAsync(); + const final: IAiFinalRecord = parseLines(output).at(-1) as unknown as IAiFinalRecord; + expect(final).toMatchObject({ + result: 'succeeded', + exitCode: 0, + scope: { commandName: 'build' }, + log: { path: '/abs/rush.log', complete: true }, + truncated: false + }); + } + ); + + it('preserves the complete final record when buffered start status is coalesced', async () => { + const output: string[] = ['', '']; + const reporters: AiReporter[] = output.map( + (value: string, index: number) => new AiReporter({ write: (text: string) => (output[index] += text) }) + ); + const events: IReporterEventEnvelope[] = [ + ev('commandStarted', { commandName: 'build' }), + ev('operationRegistered', { operationId: 'op', projectName: 'project' }), + ev('operationCompleted', { operationId: 'op', status: 'failure' }), + ev('diagnosticEmitted', { + diagnosticId: 'root', + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + parameters: { + project: { value: 'project', privacy: 'public' }, + token: { value: 'not-for-output', privacy: 'secret' } + }, + remediation: [{ descriptionKey: 'retry', command: 'rush rebuild', automatedExecutionSafety: 'safe' }] + }), + ev('diagnosticEmitted', { + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'warning' + }), + ev('artifactAvailable', { + role: 'log', + path: '/absolute/full.log', + format: 'plaintext', + complete: true + }) + ]; + for (const reporter of reporters) { + for (const event of events) reporter.report(event); + } + await reporters[0].flushAsync(); + for (const reporter of reporters) { + reporter.report(ev('commandResult', { succeeded: false, exitCode: 1 })); + await reporter.closeAsync(); + } + const activeRecords: Record[] = parseLines(output[0]); + const completedRecords: Record[] = parseLines(output[1]); + expect(activeRecords.map(({ kind }) => kind)).toEqual(['ai.status', 'ai.final']); + expect(completedRecords.map(({ kind }) => kind)).toEqual(['ai.final']); + expect(completedRecords[0]).toEqual(activeRecords[1]); + expect(output[1]).not.toContain('not-for-output'); + expect(Buffer.byteLength(output[0]) - Buffer.byteLength(output[1])).toBe( + Buffer.byteLength(`${JSON.stringify(activeRecords[0])}\n`) + ); + }); + + it('preserves ordered watch history when a terminal result supersedes buffered start status', () => { + const { records, final } = run([ + ev('commandStarted', { commandName: 'build' }), + ev('watchCycleCompleted', { succeeded: false, iterationId: 1 }), + ev('watchCycleCompleted', { succeeded: true, iterationId: 2 }), + ev('artifactAvailable', { role: 'log', path: '/abs/rush.log', complete: true }), + ev('commandResult', { succeeded: true, exitCode: 0 }) + ]); + expect(records.map(({ kind }) => kind)).toEqual(['ai.watchCycle', 'ai.watchCycle', 'ai.final']); + expect(records.slice(0, -1).map(({ succeeded }) => succeeded)).toEqual([false, true]); + expect(final.result).toBe('succeeded'); + expect(final.log?.path).toBe('/abs/rush.log'); + }); + + it('keeps buffered progress write failures in the synchronous reporter failure boundary', () => { + const failure: Error = new Error('output failed'); + const reporter: AiReporter = new AiReporter({ + write: () => { + throw failure; + } + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('artifactAvailable', { role: 'log', path: '/abs/rush.log' })); + expect(() => reporter.report(ev('activityChanged', {}))).toThrow(failure); + }); + it('excludes silent operations from AI result counts', () => { const { final } = run([ ev('commandStarted', { commandName: 'build' }), @@ -380,10 +786,107 @@ describe('AiReporter', () => { } void reporter.closeAsync(); const finalLine: string = output.trim().split('\n').pop() ?? ''; - expect(Buffer.byteLength(finalLine, 'utf8')).toBeLessThanOrEqual(512); + expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(512); expect((JSON.parse(finalLine) as IAiFinalRecord).truncated).toBe(true); }); + it('includes status records and newline delimiters in the invocation budget', () => { + const { output, final } = run([ + ev('commandStarted', { commandName: 'build' }), + ev('diagnosticEmitted', { + diagnosticId: 'root', + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + summary: 'x'.repeat(65115) + }), + ev('commandResult', { succeeded: false, exitCode: 1 }) + ]); + + expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(64 * 1024); + expect(final).toMatchObject({ kind: 'ai.final', result: 'failed', exitCode: 1, truncated: true }); + expect(output.endsWith('\n')).toBe(true); + }); + + it.each([true, false])( + 'bounds watch output and retains the log when published early=%s', + async (early: boolean) => { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); + const logPath: string = `/protected/${'logs/'.repeat(200)}full.log`; + const artifact: IReporterEventEnvelope = ev('artifactAvailable', { + role: 'log', + path: logPath, + format: 'plaintext', + complete: true + }); + reporter.report(ev('commandStarted', { commandName: 'build' })); + if (early) reporter.report(artifact); + for (let iterationId: number = 0; iterationId < 1000; iterationId++) { + reporter.report(ev('watchCycleCompleted', { succeeded: true, iterationId })); + } + if (!early) reporter.report(artifact); + reporter.report(ev('commandResult', { succeeded: false, exitCode: 1 })); + await reporter.closeAsync(); + + const records: Record[] = parseLines(output); + const final: IAiFinalRecord = records.at(-1) as unknown as IAiFinalRecord; + expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(64 * 1024); + expect(records.filter(({ kind }) => kind === 'ai.watchCycle').length).toBeLessThan(1000); + expect(final).toMatchObject({ result: 'failed', exitCode: 1, truncated: true }); + expect(final.log).toEqual({ path: logPath, format: 'plaintext', complete: true }); + expect(final.diagnostics.length).toBeLessThanOrEqual(20); + } + ); + + it('reserves a late log reference before spending a tight progress budget', async () => { + let output: string = ''; + const reporter: AiReporter = new AiReporter({ + write: (text: string) => (output += text), + maxBytes: 2048 + }); + const logPath: string = `/protected/${'logs/'.repeat(250)}full.log`; + reporter.report(ev('commandStarted', { commandName: 'build' })); + for (let iterationId: number = 0; iterationId < 100; iterationId++) { + reporter.report(ev('watchCycleCompleted', { succeeded: true, iterationId })); + } + expect(output).toBe(''); + reporter.report(ev('artifactAvailable', { role: 'log', path: logPath, complete: true })); + reporter.report(ev('commandResult', { succeeded: false, exitCode: 1 })); + await reporter.closeAsync(); + + const final: IAiFinalRecord = parseLines(output).at(-1) as unknown as IAiFinalRecord; + expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(2048); + expect(final.log?.path).toBe(logPath); + expect(final).toMatchObject({ result: 'failed', exitCode: 1, truncated: true }); + }); + + it('budgets escaped paths and multibyte diagnostics without exposing secrets', () => { + const logPath: string = `C:\\logs\\${'x'.repeat(80)}\\report-\u00e9.log`; + const { output, final } = run( + [ + ev('commandStarted', { commandName: 'build' }), + ev('diagnosticEmitted', { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + parameters: { + message: { value: '\u00e9\u{1f680}'.repeat(200), privacy: 'public' }, + token: { value: 'budget-secret-value', privacy: 'secret' } + } + }), + ev('artifactAvailable', { role: 'log', path: logPath, complete: true }), + ev('commandResult', { succeeded: false, exitCode: 1 }) + ], + { maxBytes: 512 } + ); + + expect(Buffer.byteLength(output, 'utf8')).toBeLessThanOrEqual(512); + expect(final.log?.path).toBe(logPath); + expect(final).toMatchObject({ kind: 'ai.final', result: 'failed', exitCode: 1 }); + expect(output).not.toContain('budget-secret-value'); + }); + it('falls back to a minimal bounded record when fixed fields are oversized', () => { const events: IReporterEventEnvelope[] = [ ev('commandStarted', { commandName: 'x'.repeat(5000) }), @@ -438,6 +941,130 @@ describe('AiReporter', () => { expect(final.truncated).toBe(false); }); + it('orders root-cause diagnostics before diagnostics that reference them', () => { + const { final } = run([ + ev('diagnosticEmitted', { + diagnosticId: 'outer', + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + causeDiagnosticIds: ['root'] + }), + ev('diagnosticEmitted', { + diagnosticId: 'root', + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + severity: 'error' + }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(final.diagnostics.map(({ diagnosticId }) => diagnosticId)).toEqual(['root', 'outer']); + }); + + it('projects classified context without exposing secret values', () => { + const { final } = run([ + ev('diagnosticEmitted', { + diagnosticId: 'auth', + code: 'RUSH_NETWORK_AUTH_UNAUTHORIZED', + category: 'network-auth', + severity: 'error', + summaryKey: 'diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary', + parameters: { + registryUrl: { value: 'https://registry.example.test/', privacy: 'public' }, + token: { value: 'qualification-fake-secret-token', privacy: 'secret' } + } + }), + ev('commandResult', { commandName: 'install', succeeded: false, exitCode: 1 }) + ]); + + expect(final.diagnostics[0]).toMatchObject({ + context: { + registryUrl: 'https://registry.example.test/', + token: '[secret]' + } + }); + expect(final.diagnostics[0].summaryKey).toBeUndefined(); + expect(JSON.stringify(final)).not.toContain('qualification-fake-secret-token'); + }); + + it('preserves nonstandard summary keys rather than treating them as derived metadata', () => { + const { final } = run([ + ev('diagnosticEmitted', { + code: 'RUSH_OPERATION_FAILED', + category: 'operation', + severity: 'error', + summaryKey: 'plugin.custom.failure-summary' + }), + ev('commandResult', { succeeded: false, exitCode: 1 }) + ]); + expect(final.diagnostics[0].summaryKey).toBe('plugin.custom.failure-summary'); + }); + + it('counts secret diagnostics while marking their omitted details as truncated', () => { + const { final } = run([ + { + ...ev('diagnosticEmitted', { + diagnosticId: 'secret-error', + code: 'RUSH_INTERNAL_UNEXPECTED', + category: 'internal', + severity: 'error', + summary: 'qualification-fake-secret-token' + }), + parentSessionId: SECRET_PARENT_SESSION, + parentOperationId: SECRET_PARENT_OPERATION, + scope: { + commandName: SECRET_COMMAND, + operationId: SECRET_OPERATION, + projectName: SECRET_PROJECT, + phaseName: SECRET_PHASE + }, + privacy: 'secret' + }, + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(final.errorCount).toBe(1); + expect(final.errorCodes).toEqual([]); + expect(final.diagnosticCategoryCounts).toEqual({}); + expect(final.diagnostics).toEqual([]); + expect(final.truncated).toBe(true); + expect(JSON.stringify(final)).not.toContain('qualification-fake-secret-token'); + expect(JSON.stringify(final)).not.toContain(SECRET_COMMAND); + expect(JSON.stringify(final)).not.toContain(SECRET_OPERATION); + expect(JSON.stringify(final)).not.toContain(SECRET_PROJECT); + expect(JSON.stringify(final)).not.toContain(SECRET_PHASE); + expect(JSON.stringify(final)).not.toContain(SECRET_PARENT_SESSION); + expect(JSON.stringify(final)).not.toContain(SECRET_PARENT_OPERATION); + }); + + it('preserves fallback errors when secret diagnostics are also suppressed', () => { + const { final } = run([ + { + ...ev('diagnosticEmitted', { + diagnosticId: 'secret-error', + code: 'RUSH_INTERNAL_UNEXPECTED', + category: 'internal', + severity: 'error', + summary: 'qualification-fake-secret-token' + }), + privacy: 'secret' + }, + ev('messageEmitted', { severity: 'error', text: 'First visible fallback error.' }), + ev('messageEmitted', { severity: 'error', text: 'Second visible fallback error.' }), + ev('commandResult', { commandName: 'build', succeeded: false, exitCode: 1 }) + ]); + + expect(final.errorCount).toBe(3); + expect(final.errorCodes).toEqual(['RUSH_COMMAND_FAILED']); + expect(final.diagnostics.map(({ summary }) => summary)).toEqual([ + 'First visible fallback error.', + 'Second visible fallback error.' + ]); + expect(final.truncated).toBe(true); + expect(JSON.stringify(final)).not.toContain('qualification-fake-secret-token'); + }); + it('excludes raw external output and keeps stdout pure JSON', () => { let output: string = ''; const reporter: AiReporter = new AiReporter({ write: (text: string) => (output += text) }); diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 332b7beb14..28599dc1a1 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -4,12 +4,16 @@ import { ReporterManager, ReporterMultiplexer, + DefaultInteractiveReporter, + PlaintextReporter, type IReporter, + type IReporterContext, type IReporterEmitEventInput, type IReporterEventEnvelope, type ReporterEventType, type ReporterJsonValue } from '../index'; +import { startReporterTimer } from '../utilities/startReporterTimer'; class RecordingReporter implements IReporter { public readonly name: string; @@ -20,12 +24,14 @@ class RecordingReporter implements IReporter { public throwOnInit: boolean = false; public throwOnReportType: ReporterEventType | undefined = undefined; public throwOnClose: boolean = false; + public context: IReporterContext | undefined; public constructor(name: string) { this.name = name; } - public async initializeAsync(): Promise { + public async initializeAsync(context?: IReporterContext): Promise { + this.context = context; this.initCount++; if (this.throwOnInit) { throw new Error(`init failed ${this.name}`); @@ -66,6 +72,339 @@ function makeInput( } describe('ReporterManager ordering and assignment', () => { + it('aborts all attempted initializations before blocked close and preserves once-only concurrent cleanup', async () => { + jest.useFakeTimers(); + const failure: Error = new Error('startup failed'); + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const started: RecordingReporter = new RecordingReporter('started'); + const partial: RecordingReporter = new RecordingReporter('partial'); + const unstarted: RecordingReporter = new RecordingReporter('unstarted'); + let backgroundCalls: number = 0; + const stopTimers: Array<() => void> = []; + let finishClose!: () => void; + let notifyCloseStarted!: () => void; + const closeFinished: Promise = new Promise((resolve) => (finishClose = resolve)); + const closeStarted: Promise = new Promise((resolve) => (notifyCloseStarted = resolve)); + for (const reporter of [started, partial]) { + jest.spyOn(reporter, 'initializeAsync').mockImplementation(async (context) => { + reporter.context = context; + reporter.initCount++; + stopTimers.push(startReporterTimer(context, () => backgroundCalls++, 10)); + if (reporter === partial) { + throw failure; + } + }); + manager.addReporter(reporter); + } + manager.addReporter(unstarted); + jest.spyOn(started, 'closeAsync').mockImplementation(async () => { + started.closeCount++; + notifyCloseStarted(); + await closeFinished; + }); + partial.throwOnClose = true; + let disposalAssertion: Promise | undefined; + let closing: Promise | undefined; + try { + await expect(manager.initializeAsync()).rejects.toBe(failure); + expect(jest.getTimerCount()).toBe(2); + const disposal: Promise = manager._disposeInitializedReportersAsync(failure); + disposalAssertion = expect(disposal).rejects.toThrow('close failed partial'); + + expect(jest.getTimerCount()).toBe(0); + for (const reporter of [started, partial]) { + expect(reporter.context?.abortSignal?.aborted).toBe(true); + expect(reporter.context?.abortSignal?.reason).toBe(failure); + reporter.context?.runWithErrorHandling?.(() => backgroundCalls++); + } + expect(backgroundCalls).toBe(0); + expect(started.closeCount).toBe(0); + closing = manager.closeAsync(); + await closeStarted; + expect(started.closeCount).toBe(1); + finishClose(); + await Promise.all([disposalAssertion, closing]); + expect([started.closeCount, partial.closeCount, unstarted.closeCount]).toEqual([1, 1, 0]); + expect([started.flushCount, partial.flushCount, unstarted.flushCount]).toEqual([0, 0, 0]); + expect(unstarted.initCount).toBe(0); + } finally { + finishClose(); + await Promise.all([disposalAssertion, closing]); + for (const stop of stopTimers) stop(); + jest.useRealTimers(); + } + }); + + it('cancels reporter work synchronously while disposal waits behind a blocked lifecycle action', async () => { + jest.useFakeTimers(); + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('blocked-lifecycle'); + const failure: Error = new Error('replay failed'); + let finishFlush!: () => void; + let notifyFlushStarted!: () => void; + const flushFinished: Promise = new Promise((resolve) => (finishFlush = resolve)); + const flushStarted: Promise = new Promise((resolve) => (notifyFlushStarted = resolve)); + jest.spyOn(reporter, 'flushAsync').mockImplementation(async () => { + reporter.flushCount++; + if (reporter.flushCount === 1) { + notifyFlushStarted(); + await flushFinished; + } + }); + manager.addReporter(reporter); + await manager.initializeAsync(); + let backgroundCalls: number = 0; + const stopTimer: () => void = startReporterTimer(reporter.context, () => backgroundCalls++, 10); + const flushing: Promise = manager.flushAsync(); + let disposing: Promise | undefined; + let closing: Promise | undefined; + try { + await flushStarted; + const timersBefore: number = jest.getTimerCount(); + disposing = manager._disposeInitializedReportersAsync(failure); + expect(reporter.context?.abortSignal?.aborted).toBe(true); + expect(reporter.context?.abortSignal?.reason).toBe(failure); + expect(jest.getTimerCount()).toBe(timersBefore - 1); + expect(reporter.closeCount).toBe(0); + jest.advanceTimersByTime(100); + expect(backgroundCalls).toBe(0); + closing = manager.closeAsync(); + finishFlush(); + await Promise.all([flushing, disposing, closing]); + expect(reporter.closeCount).toBe(1); + } finally { + finishFlush(); + await Promise.all([flushing, disposing, closing]); + stopTimer(); + jest.useRealTimers(); + } + }); + + it('publishes the disposal promise before abort listeners can reenter cleanup', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('reentrant-abort'); + manager.addReporter(reporter); + await manager.initializeAsync(); + const failure: Error = new Error('startup failed'); + let reentrant: Promise | undefined; + reporter.context?.abortSignal?.addEventListener( + 'abort', + () => { + reentrant = manager._disposeInitializedReportersAsync(failure); + }, + { once: true } + ); + + const disposal: Promise = manager._disposeInitializedReportersAsync(failure); + expect(reentrant).toBe(disposal); + await disposal; + expect(reporter.closeCount).toBe(1); + }); + + it('uses an Error cancellation reason while retaining a non-Error initialization failure as its cause', async () => { + const manager: ReporterManager = new ReporterManager(); + const failure: { message: string } = { message: 'foreign initialization failure' }; + let context: IReporterContext | undefined; + manager.addReporter({ + name: 'foreign-rejection', + initializeAsync: async (value: IReporterContext) => { + context = value; + return Promise.reject(failure); + }, + report: () => undefined, + flushAsync: async () => undefined, + closeAsync: async () => undefined + }); + await expect(manager.initializeAsync()).rejects.toBe(failure); + + const disposal: Promise = manager._disposeInitializedReportersAsync(failure); + expect(context?.abortSignal?.aborted).toBe(true); + expect(context?.abortSignal?.reason).toBeInstanceOf(Error); + expect(context?.abortSignal?.reason.cause).toBe(failure); + await disposal; + }); + + it('reserves the disposal lifecycle lane before concurrent shutdown can flush or close', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('blocked-disposal-flush'); + let notifyFlushStarted!: () => void; + let finishFlush!: () => void; + const flushStarted: Promise = new Promise((resolve) => (notifyFlushStarted = resolve)); + const flushFinished: Promise = new Promise((resolve) => (finishFlush = resolve)); + jest.spyOn(reporter, 'flushAsync').mockImplementation(async () => { + reporter.flushCount++; + if (reporter.flushCount === 1) { + notifyFlushStarted(); + await flushFinished; + } + }); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const disposing: Promise = manager._disposeInitializedReportersAsync(); + await flushStarted; + const closing: Promise = manager.closeAsync(); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(reporter.flushCount).toBe(1); + expect(reporter.closeCount).toBe(0); + } finally { + finishFlush(); + await Promise.all([disposing, closing]); + } + expect(reporter.flushCount).toBe(1); + expect(reporter.closeCount).toBe(1); + }); + + it('shares one close operation between concurrent shutdown and initialization disposal', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('shared-close'); + let notifyCloseStarted!: () => void; + let finishClose!: () => void; + const closeStarted: Promise = new Promise((resolve) => (notifyCloseStarted = resolve)); + const closeFinished: Promise = new Promise((resolve) => (finishClose = resolve)); + jest.spyOn(reporter, 'closeAsync').mockImplementation(async () => { + reporter.closeCount++; + notifyCloseStarted(); + await closeFinished; + }); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const closing: Promise = manager.closeAsync(); + const disposing: Promise = manager._disposeInitializedReportersAsync(); + await closeStarted; + expect(reporter.closeCount).toBe(1); + finishClose(); + await Promise.all([closing, disposing]); + const flushCount: number = reporter.flushCount; + await manager.flushAsync(); + await manager.closeAsync(); + expect(reporter.closeCount).toBe(1); + expect(reporter.flushCount).toBe(flushCount); + }); + + it('caches a rejected close across normal shutdown and disposal without retrying it', async () => { + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const reporter: RecordingReporter = new RecordingReporter('failed-close'); + reporter.throwOnClose = true; + manager.addReporter(reporter, { required: true }); + await manager.initializeAsync(); + + await expect(manager.closeAsync()).rejects.toThrow('close failed failed-close'); + await expect(manager._disposeInitializedReportersAsync()).rejects.toThrow('close failed failed-close'); + await expect(manager.closeAsync()).rejects.toThrow('close failed failed-close'); + expect(reporter.closeCount).toBe(1); + }); + + it.each([false, true])( + 'caches synchronous close throws across both lifecycle orders, disposal first=%s', + async (disposeFirst: boolean) => { + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const reporter: RecordingReporter = new RecordingReporter('synchronous-close'); + const failure: Error = new Error('synchronous close failed'); + jest.spyOn(reporter, 'closeAsync').mockImplementation(() => { + reporter.closeCount++; + throw failure; + }); + manager.addReporter(reporter, { required: true }); + await manager.initializeAsync(); + const close = (): Promise => manager.closeAsync(); + const dispose = (): Promise => manager._disposeInitializedReportersAsync(failure); + + for (const operation of disposeFirst ? [dispose, close] : [close, dispose]) { + await expect(operation()).rejects.toThrow('synchronous close failed'); + } + expect(reporter.closeCount).toBe(1); + } + ); + + it('keeps optional runtime failure abort-only until manager-owned shutdown', async () => { + const manager: ReporterManager = new ReporterManager({ emergencyDiagnosticWriter: () => undefined }); + const reporter: RecordingReporter = new RecordingReporter('runtime-abort-only'); + manager.addReporter(reporter); + await manager.initializeAsync(); + const failure: Error = new Error('runtime failed'); + + reporter.context?.runWithErrorHandling?.(() => { + throw failure; + }); + expect(reporter.context?.abortSignal?.reason).toBe(failure); + await new Promise((resolve) => setImmediate(resolve)); + expect(reporter.closeCount).toBe(0); + await manager.closeAsync(); + expect(reporter.closeCount).toBe(1); + }); + + it('closes attempted initializations even when a prior lifecycle error reporter rejected', async () => { + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: () => { + throw new Error('emergency writer failed'); + } + }); + const reporter: RecordingReporter = new RecordingReporter('lifecycle-failure'); + reporter.flushAsync = async () => { + throw new Error('flush failed'); + }; + manager.addReporter(reporter); + await manager.initializeAsync(); + + await expect(manager.flushAsync()).rejects.toThrow('emergency writer failed'); + await expect(manager._disposeInitializedReportersAsync()).rejects.toThrow('emergency writer failed'); + expect(reporter.closeCount).toBe(1); + }); + + it('disposes every attempted initialization once without closing unstarted reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const first: RecordingReporter = new RecordingReporter('first'); + const failed: RecordingReporter = new RecordingReporter('failed'); + const unstarted: RecordingReporter = new RecordingReporter('unstarted'); + failed.throwOnInit = true; + first.throwOnClose = true; + manager.addReporter(first); + manager.addReporter(failed); + manager.addReporter(unstarted); + + await expect(manager.initializeAsync()).rejects.toThrow('init failed failed'); + const disposal: Promise = manager._disposeInitializedReportersAsync(); + expect(manager._disposeInitializedReportersAsync()).toBe(disposal); + await expect(disposal).rejects.toThrow('close failed first'); + expect([first.closeCount, failed.closeCount, unstarted.closeCount]).toEqual([1, 1, 0]); + expect([first.flushCount, failed.flushCount, unstarted.flushCount]).toEqual([0, 0, 0]); + }); + + it('joins other destination cleanup after one close rejects', async () => { + const manager: ReporterManager = new ReporterManager(); + const first: RecordingReporter = new RecordingReporter('first'); + first.throwOnClose = true; + const second: RecordingReporter = new RecordingReporter('second'); + let releaseClose!: () => void; + let notifyCloseStarted!: () => void; + const closeStarted: Promise = new Promise((resolve) => (notifyCloseStarted = resolve)); + const closeFinished: Promise = new Promise((resolve) => (releaseClose = resolve)); + second.closeAsync = async () => { + notifyCloseStarted(); + await closeFinished; + second.closeCount++; + }; + manager.addReporter(first); + manager.addReporter(second); + await manager.initializeAsync(); + + let settled: boolean = false; + const disposal: Promise = manager._disposeInitializedReportersAsync(); + const assertion: Promise = expect(disposal).rejects.toThrow('close failed first'); + void disposal.then( + () => (settled = true), + () => (settled = true) + ); + await closeStarted; + expect(settled).toBe(false); + releaseClose(); + await assertion; + expect(second.closeCount).toBe(1); + }); + it('rejects in-process events before reporters are initialized', () => { const manager: ReporterManager = new ReporterManager(); manager.addReporter(new RecordingReporter('a')); @@ -108,6 +447,24 @@ describe('ReporterManager ordering and assignment', () => { expect(reporter.reported[0].timestamp).toBe('2026-01-01T00:00:00.000Z'); }); + it('delivers protected events synchronously so hard exits cannot strand output', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + manager.emit(makeInput('activityChanged', { text: 'status' })); + manager.emit(makeInput('externalOutput', { text: 'first' })); + manager.emit(makeInput('externalOutput', { text: 'second' })); + + expect(reporter.reported.map((event: IReporterEventEnvelope) => event.payload)).toEqual([ + { text: 'status' }, + { text: 'first' }, + { text: 'second' } + ]); + expect(manager.getPendingEventCount()).toBe(0); + }); + it('derives the required flag from the event type, ignoring producer input', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter('a'); @@ -231,6 +588,86 @@ describe('ReporterManager destinations', () => { }); describe('ReporterManager failure handling', () => { + function makeTimedReporter(kind: 'default' | 'plaintext', write: (text: string) => void): IReporter { + return kind === 'default' + ? new DefaultInteractiveReporter({ terminal: { isTTY: true, columns: 80, write }, color: false }) + : new PlaintextReporter({ write, heartbeatIntervalMs: 100 }); + } + + it.each(['default', 'plaintext'] as const)( + 'contains %s timer failures and stops the disabled timer', + async (kind) => { + jest.useFakeTimers(); + const emergency: string[] = []; + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: (message: string) => emergency.push(message) + }); + const good: RecordingReporter = new RecordingReporter('good'); + let failWrites: boolean = false; + let writeCount: number = 0; + manager.addReporter( + makeTimedReporter(kind, () => { + writeCount++; + if (failWrites) { + throw new Error('timer output failed'); + } + }) + ); + manager.addReporter(good); + try { + await manager.initializeAsync(); + manager.emit(makeInput('commandStarted', { commandName: 'build' })); + failWrites = true; + expect(() => jest.advanceTimersByTime(100)).not.toThrow(); + expect(emergency).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + const countAfterFailure: number = writeCount; + jest.advanceTimersByTime(1000); + expect(writeCount).toBe(countAfterFailure); + manager.emit(makeInput('commandResult', { succeeded: true, exitCode: 0 })); + expect(good.reported.at(-1)?.payload).toEqual({ succeeded: true, exitCode: 0 }); + await expect(manager.closeAsync()).resolves.toBeUndefined(); + expect(emergency).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + } finally { + failWrites = false; + await manager.closeAsync(); + jest.useRealTimers(); + } + } + ); + + it.each(['default', 'plaintext'] as const)( + 'cancels the %s timer when event delivery disables the reporter', + async (kind) => { + jest.useFakeTimers(); + const emergency: string[] = []; + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: (message: string) => emergency.push(message) + }); + let writeCount: number = 0; + manager.addReporter( + makeTimedReporter(kind, () => { + if (++writeCount >= (kind === 'default' ? 2 : 1)) { + throw new Error('event output failed'); + } + }) + ); + try { + await manager.initializeAsync(); + expect(() => manager.emit(makeInput('commandStarted', { commandName: 'build' }))).not.toThrow(); + expect(emergency).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + const countAfterFailure: number = writeCount; + expect(() => jest.advanceTimersByTime(1000)).not.toThrow(); + expect(writeCount).toBe(countAfterFailure); + } finally { + await manager.closeAsync(); + jest.useRealTimers(); + } + } + ); + it('treats initialization failure as fatal', async () => { const reporter: RecordingReporter = new RecordingReporter('a'); reporter.throwOnInit = true; @@ -290,6 +727,35 @@ describe('ReporterManager failure handling', () => { expect(emergency).toHaveLength(1); expect(emergency[0]).toContain('Required reporter "bad" failed'); }); + + it('preserves fatal required-reporter semantics for timer failures', async () => { + jest.useFakeTimers(); + const emergency: string[] = []; + const manager: ReporterManager = new ReporterManager({ + emergencyDiagnosticWriter: (message: string) => emergency.push(message) + }); + let failWrites: boolean = false; + const reporter: IReporter = makeTimedReporter('plaintext', () => { + if (failWrites) { + throw new Error('required timer failed'); + } + }); + manager.addReporter(reporter, { required: true }); + try { + await manager.initializeAsync(); + manager.emit(makeInput('commandStarted', { commandName: 'build' })); + failWrites = true; + expect(() => jest.advanceTimersByTime(100)).not.toThrow(); + expect(jest.getTimerCount()).toBe(0); + await expect(manager.flushAsync()).rejects.toThrow('required timer failed'); + await expect(manager.closeAsync()).rejects.toThrow('required timer failed'); + expect(emergency).toHaveLength(1); + } finally { + failWrites = false; + await reporter.closeAsync(); + jest.useRealTimers(); + } + }); }); describe('ReporterManager coalescing', () => { @@ -320,6 +786,24 @@ describe('ReporterManager coalescing', () => { }); describe('ReporterManager flush and close', () => { + it('cancels background work before close and rejects subsequent timer dispatch', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('background'); + manager.addReporter(reporter); + await manager.initializeAsync(); + const context: IReporterContext | undefined = reporter.context; + expect(context?.abortSignal?.aborted).toBe(false); + let workCount: number = 0; + context?.runWithErrorHandling?.(() => workCount++); + expect(workCount).toBe(1); + const closePromise: Promise = manager.closeAsync(); + expect(context?.abortSignal?.aborted).toBe(true); + expect(context?.abortSignal?.reason).toBeNull(); + context?.runWithErrorHandling?.(() => workCount++); + await closePromise; + expect(workCount).toBe(1); + }); + it('flushes and closes every reporter', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter('a'); diff --git a/libraries/reporter/src/test/Performance.test.ts b/libraries/reporter/src/test/Performance.test.ts index 47c50adf89..0898117580 100644 --- a/libraries/reporter/src/test/Performance.test.ts +++ b/libraries/reporter/src/test/Performance.test.ts @@ -118,6 +118,10 @@ describe('reporter performance budgets', () => { expect(REPORTER_PERFORMANCE_BUDGETS.maxInteractiveRefreshHz).toBe(10); expect(REPORTER_PERFORMANCE_BUDGETS.maxAiOutputBytes).toBe(64 * 1024); expect(REPORTER_PERFORMANCE_BUDGETS.maxAiDetailedDiagnostics).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCategories).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions).toBe(20); + expect(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength).toBe(256); }); it('evaluates wall-time regression against the 3 percent budget', () => { diff --git a/libraries/reporter/src/test/PlaintextReporter.test.ts b/libraries/reporter/src/test/PlaintextReporter.test.ts index a97ce55a29..19dafd9e32 100644 --- a/libraries/reporter/src/test/PlaintextReporter.test.ts +++ b/libraries/reporter/src/test/PlaintextReporter.test.ts @@ -150,6 +150,15 @@ describe('PlaintextReporter', () => { expect(capture.getOutput()).not.toContain('TOP_SECRET'); }); + it('does not replay old-engine output that was already rendered', () => { + const capture: ICapture = makeDetailed(); + capture.reporter.report( + ev('externalOutput', { stream: 'stdout', text: 'already rendered\n', wasRendered: true }) + ); + + expect(capture.getOutput()).toBe(''); + }); + it('preserves partial-line chunks within grouped output', () => { const capture: ICapture = makeDetailed(); capture.reporter.report( @@ -381,4 +390,64 @@ describe('PlaintextReporter', () => { expect(output).toContain('still running'); }); + + it.each(['normal', 'quiet'] as const)( + 'schedules unrefed heartbeats at %s level and stops on close', + async (logLevel) => { + jest.useFakeTimers(); + const intervalSpy = jest.spyOn(global, 'setInterval'); + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => { + output += text; + }, + logLevel + }); + try { + await reporter.initializeAsync(); + if (logLevel === 'normal') { + expect(intervalSpy.mock.results[0].value.hasRef()).toBe(false); + } else { + expect(intervalSpy).not.toHaveBeenCalled(); + } + reporter.report(ev('commandStarted', { commandName: 'build' })); + jest.advanceTimersByTime(29999); + expect(output).not.toContain('still running'); + jest.advanceTimersByTime(1); + expect(output.includes('still running')).toBe(logLevel !== 'quiet'); + + await reporter.closeAsync(); + const afterClose: string = output; + jest.advanceTimersByTime(60000); + expect(output).toBe(afterClose); + expect(jest.getTimerCount()).toBe(0); + } finally { + await reporter.closeAsync(); + intervalSpy.mockRestore(); + jest.useRealTimers(); + } + } + ); + + it('stops automatic heartbeats when the command result arrives', async () => { + jest.useFakeTimers(); + let output: string = ''; + const reporter: PlaintextReporter = new PlaintextReporter({ + write: (text: string) => { + output += text; + } + }); + try { + await reporter.initializeAsync(); + reporter.report(ev('commandStarted', { commandName: 'build' })); + reporter.report(ev('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + const finalOutput: string = output; + jest.advanceTimersByTime(60000); + expect(output).toBe(finalOutput); + expect(jest.getTimerCount()).toBe(0); + } finally { + await reporter.closeAsync(); + jest.useRealTimers(); + } + }); }); diff --git a/libraries/reporter/src/test/ProblemMatchers.test.ts b/libraries/reporter/src/test/ProblemMatchers.test.ts index 462421e77c..20a43fbab2 100644 --- a/libraries/reporter/src/test/ProblemMatchers.test.ts +++ b/libraries/reporter/src/test/ProblemMatchers.test.ts @@ -3,6 +3,7 @@ import { normalizeAnsi, + ProblemMatcherRunner, ProblemMatcherRegistry, runProblemMatchers, OperationStreamEmitter, @@ -139,7 +140,7 @@ describe('runProblemMatchers', () => { const splitAnsiEvents: IReporterEventEnvelope[] = emitOutput([ '\u001b[31', - "msrc/split.ts(4,5): error TS2001: split escape\u001b[0m\n" + 'msrc/split.ts(4,5): error TS2001: split escape\u001b[0m\n' ]); const splitAnsiResult: IProblemMatcherResult = runProblemMatchers(splitAnsiEvents, [TSC_ERROR_MATCHER]); expect(splitAnsiResult.diagnostics).toHaveLength(1); @@ -201,4 +202,59 @@ describe('runProblemMatchers', () => { expect(result.matchedLineCount).toBe(2); expect(result.unmatchedLineCount).toBe(2); }); + + it('streams per-operation partial lines and ignores non-output events', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER]); + const op1: IReporterEventEnvelope[] = emitOutput(['src/a.ts(1,1): error TS10'], 'op1'); + const op2: IReporterEventEnvelope[] = emitOutput(['src/b.ts(2,2): error TS2000: two\n'], 'op2'); + expect(runner.write(op1[0])).toEqual([]); + expect(runner.write({ ...op1[0], type: 'activityChanged' })).toEqual([]); + expect(runner.write(op2[0])).toHaveLength(1); + const completion: IReporterEventEnvelope[] = emitOutput(['00: one\n'], 'op1'); + expect(runner.write(completion[0])).toHaveLength(1); + expect(runner.flush()).toEqual([]); + expect(runner.result.diagnostics.map((diagnostic) => diagnostic.parameters?.code.value)).toEqual([ + 'TS2000', + 'TS1000' + ]); + }); + + it('keeps interleaved stdout and stderr partial lines independent', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER]); + const stdoutStart: IReporterEventEnvelope = emitOutput( + ['src/a.ts(1,2): error TS1005: broken'], + 'op-1' + )[0]; + const stderr: IReporterEventEnvelope = { + ...emitOutput(['unrelated stderr\n'], 'op-1')[0], + payload: { stream: 'stderr', text: 'unrelated stderr\n' } + }; + const stdoutEnd: IReporterEventEnvelope = emitOutput([' output\n'], 'op-1')[0]; + + expect(runner.write(stdoutStart)).toEqual([]); + expect(runner.write(stderr)).toEqual([]); + expect(runner.write(stdoutEnd)).toHaveLength(1); + expect(runner.result.diagnostics[0].parameters?.message.value).toBe('broken output'); + expect(runner.result.unmatchedLineCount).toBe(1); + }); + + it('caps streaming duplicates and recovers after an oversized partial line', () => { + const runner: ProblemMatcherRunner = new ProblemMatcherRunner([TSC_ERROR_MATCHER], { + maxDuplicates: 1, + maxPartialLineBytes: 64 + }); + const events: IReporterEventEnvelope[] = emitOutput([ + 'x'.repeat(80), + '\n', + 'src/dup.ts(1,1): error TS1005: duplicate\n', + 'src/dup.ts(1,1): error TS1005: duplicate\n' + ]); + const emitted = events.flatMap((event) => runner.write(event)); + runner.flush(); + + expect(emitted).toHaveLength(1); + expect(runner.matchedLineCount).toBe(2); + expect(runner.unmatchedLineCount).toBe(1); + expect(runner.suppressedDuplicateCount).toBe(1); + }); }); diff --git a/libraries/reporter/src/test/Protocol.test.ts b/libraries/reporter/src/test/Protocol.test.ts index 587de7ae8b..3fc3548eda 100644 --- a/libraries/reporter/src/test/Protocol.test.ts +++ b/libraries/reporter/src/test/Protocol.test.ts @@ -10,8 +10,12 @@ import { NdjsonDecoder, NdjsonInvalidRecordError, NdjsonRecordTooLargeError, + InvalidReporterHelloAckError, InvalidReporterHelloError, negotiateReporterHello, + parseReporterHelloAck, + REPORTER_KNOWN_CAPABILITIES, + type IReporterHelloAck, type IReporterHello, type IReporterHandshakeResult } from '../index'; @@ -19,7 +23,7 @@ import { describe('ReporterProtocol', () => { it('advertises protocol major 1 and the specified byte limits', () => { expect(REPORTER_PROTOCOL_VERSION.major).toBe(1); - expect(REPORTER_PROTOCOL_VERSION.minor).toBe(1); + expect(REPORTER_PROTOCOL_VERSION.minor).toBe(2); expect(REPORTER_PROTOCOL_LIMITS.bootstrapBufferBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.ndjsonRecordBytes).toBe(1024 * 1024); expect(REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes).toBe(64 * 1024); @@ -139,6 +143,35 @@ describe('negotiateReporterHello', () => { expect(result.diagnostic).toBeUndefined(); }); + it('governs Heft event and reporter context capabilities', () => { + expect(REPORTER_KNOWN_CAPABILITIES).toEqual(['heft-child-events-v1', 'reporter-context-v1']); + }); + + it('includes validated parent context only when its capability is accepted', () => { + const context = { + reporter: 'plaintext' as const, + logLevel: 'verbose' as const, + color: false, + terminalWidth: 120 + }; + const accepted: IReporterHandshakeResult = negotiateReporterHello( + makeHello({ capabilities: ['reporter-context-v1'] }), + { + supportedProtocolVersion: { major: 1, minor: 0 }, + supportedCapabilities: REPORTER_KNOWN_CAPABILITIES, + context + } + ); + expect(accepted.ack.context).toEqual(context); + + const notAccepted: IReporterHandshakeResult = negotiateReporterHello(makeHello(), { + supportedProtocolVersion: { major: 1, minor: 0 }, + supportedCapabilities: REPORTER_KNOWN_CAPABILITIES, + context + }); + expect(notAccepted.ack.context).toBeUndefined(); + }); + it('accepts across an additive minor difference', () => { const result: IReporterHandshakeResult = negotiateReporterHello( makeHello({ protocolVersion: { major: 1, minor: 7 } }), @@ -195,4 +228,34 @@ describe('negotiateReporterHello', () => { ) ).toThrow(/capabilities must be an array of strings/); }); + + it('validates untrusted acknowledgements and reporter context', () => { + const ack: IReporterHelloAck = parseReporterHelloAck({ + kind: 'helloAck', + protocolVersion: { major: 1, minor: 0 }, + acceptedCapabilities: ['heft-child-events-v1', 'reporter-context-v1'], + rejectedRequiredFeatures: [], + context: { + reporter: 'ai', + logLevel: 'debug', + color: true, + terminalWidth: 80 + } + }); + expect(ack.context?.reporter).toBe('ai'); + + expect(() => + parseReporterHelloAck({ + ...ack, + context: { ...ack.context, terminalWidth: 0 } + }) + ).toThrow(InvalidReporterHelloAckError); + expect(() => + parseReporterHelloAck({ + ...ack, + acceptedCapabilities: ['heft-child-events-v1'] + }) + ).toThrow(/context requires/); + expect(() => parseReporterHelloAck(undefined)).toThrow(InvalidReporterHelloAckError); + }); }); diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 2472ef5793..d346e75db9 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -188,6 +188,7 @@ describe('ReporterHost handoff replay', () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); buffer.emit({ type: 'sessionStarted', payload: {} }); + buffer.addExternalOutput('stderr', 'legacy bootstrap output\n'); const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); @@ -204,6 +205,37 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toEqual([{ stream: 'stderr', text: 'legacy bootstrap output\n' }]); + }); + }); + + it('does not duplicate already-rendered output during legacy fallback', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'live output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); + + const manager: ReporterManager = new ReporterManager(); + await manager.initializeAsync(); + const host: ReporterHost = new ReporterHost({ + manager, + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + + expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toBeUndefined(); + expect(fs.existsSync(handoffPath)).toBe(false); }); }); @@ -301,7 +333,45 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); - expect(result).toMatchObject({ replayed: false, skipReason: 'invalid-event' }); + expect(result).toMatchObject({ replayed: false, skipReason: 'unsupported-required-event' }); + }); + }); +}); + +describe('ReporterHost handoff discard', () => { + it('deletes only the current authenticated handoff', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + + it('does not delete a handoff with a mismatched nonce', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: 'wrong-nonce' + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(true); }); }); }); @@ -331,10 +401,26 @@ describe('ReporterHost sink', () => { }); describe('ReporterHost abandoned file cleanup', () => { + const deadPid: number = 99999999; + + beforeEach(() => { + const userInfo: os.UserInfo = os.userInfo(); + jest + .spyOn(jest.requireActual('node:os'), 'userInfo') + .mockReturnValue({ ...userInfo, uid: fs.statSync(os.tmpdir()).uid }); + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('deletes only stale handoff files and leaves other files untouched', async () => { await withTempDir(async (directory: string) => { - const oldFile: string = path.join(directory, 'rush-reporter-bootstrap-1-1000.ndjson'); - const newFile: string = path.join(directory, 'rush-reporter-bootstrap-2-2000.ndjson'); + const oldFile: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-1000.ndjson`); + const newFile: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-2000.ndjson`); const otherFile: string = path.join(directory, 'unrelated.txt'); await fs.promises.writeFile(oldFile, '{}\n'); await fs.promises.writeFile(newFile, '{}\n'); @@ -352,4 +438,79 @@ describe('ReporterHost abandoned file cleanup', () => { expect(fs.existsSync(otherFile)).toBe(true); }); }); + + it('retains 20 recent abandoned sessions with deterministic timestamp ties', async () => { + await withTempDir(async (directory: string) => { + const files: string[] = []; + const timestamp: Date = new Date('2026-09-01T00:00:00Z'); + for (let index: number = 20; index >= 0; index--) { + const filePath: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-${String(index).padStart(3, '0')}.ndjson` + ); + await fs.promises.writeFile(filePath, '{}\n', { mode: 0o600 }); + await fs.promises.utimes(filePath, timestamp, timestamp); + files.push(filePath); + } + const host: ReporterHost = new ReporterHost({ + env: {}, + handoffDirectory: directory, + nowMs: () => Date.parse('2026-09-09T00:00:00Z') + }); + + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([files[0]]); + expect((await fs.promises.readdir(directory)).length).toBe(20); + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([]); + }); + }); + + it('protects live, current, foreign-owned and non-file entries regardless of age', async () => { + await withTempDir(async (directory: string) => { + const livePid: number = 88888888; + const currentHandoff: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-current.ndjson` + ); + const foreign: string = path.join(directory, `rush-reporter-bootstrap-${deadPid}-foreign.ndjson`); + const protectedPaths: string[] = [ + currentHandoff, + foreign, + path.join(directory, `rush-reporter-bootstrap-${process.pid}-self.ndjson`), + path.join(directory, `rush-reporter-bootstrap-${livePid}-live.ndjson`) + ]; + const old: Date = new Date('2000-01-01T00:00:00Z'); + for (const filePath of protectedPaths) { + await fs.promises.writeFile(filePath, '{}\n'); + await fs.promises.utimes(filePath, old, old); + } + const directoryEntry: string = path.join( + directory, + `rush-reporter-bootstrap-${deadPid}-directory.ndjson` + ); + await fs.promises.mkdir(directoryEntry); + const originalLstat: typeof fs.promises.lstat = fs.promises.lstat; + jest.spyOn(fs.promises, 'lstat').mockImplementation(async (filePath) => { + const stats: fs.Stats = await originalLstat(filePath); + if (filePath === foreign) { + stats.uid++; + } + return stats; + }); + jest.mocked(process.kill).mockImplementation((pid) => { + if (pid === livePid) { + throw Object.assign(new Error('Not permitted'), { code: 'EPERM' }); + } + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + const host: ReporterHost = new ReporterHost({ + env: { [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: currentHandoff }, + handoffDirectory: directory + }); + + expect(await host.cleanAbandonedHandoffFilesAsync()).toEqual([]); + for (const filePath of [...protectedPaths, directoryEntry]) { + expect(fs.existsSync(filePath)).toBe(true); + } + }); + }); }); diff --git a/libraries/reporter/src/test/Telemetry.test.ts b/libraries/reporter/src/test/Telemetry.test.ts index fed71783a8..6fb592076e 100644 --- a/libraries/reporter/src/test/Telemetry.test.ts +++ b/libraries/reporter/src/test/Telemetry.test.ts @@ -5,6 +5,7 @@ import { TelemetrySubscriber, createTelemetryReporter, createBeforeLogAdapter, + REPORTER_PERFORMANCE_BUDGETS, TELEMETRY_AGGREGATE_KEYS, LifecycleEmitter, ReporterManager, @@ -51,6 +52,46 @@ function rawInput(type: string, payload: unknown): IReporterEmitEventInput['privacy']; + readonly source?: IReporterEventSource; + readonly protocolVersion?: IReporterEventEnvelope['protocolVersion']; + readonly parentSessionId?: string; +} + +function foreignEnvelope( + sequence: number, + type: IReporterEventEnvelope['type'], + payload: unknown, + options: IForeignEnvelopeOptions = {} +): IReporterEventEnvelope { + return { + protocolVersion: options.protocolVersion ?? { major: 1, minor: 0 }, + eventId: `foreign_${sequence}`, + sessionId: 'foreign-session', + parentSessionId: options.parentSessionId, + sequence, + timestamp: '2026-08-28T00:00:00.000Z', + source: options.source ?? { + packageName: '@foreign/reporter-plugin', + packageVersion: '1.0.0' + }, + privacy: options.privacy ?? 'public', + required: true, + type, + payload + }; +} + +function foreignDiagnosticEnvelope( + sequence: number, + privacy: IReporterEventEnvelope['privacy'], + payload: unknown, + options: Omit = {} +): IReporterEventEnvelope { + return foreignEnvelope(sequence, 'diagnosticEmitted', payload, { ...options, privacy }); +} + describe('TelemetrySubscriber', () => { it('produces an allowlisted aggregate from the event stream before reporter filtering', async () => { const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); @@ -85,7 +126,7 @@ describe('TelemetrySubscriber', () => { expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); expect(aggregate.reporterMode).toBe('default'); - expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 1 }); + expect(aggregate.protocolVersion).toEqual({ major: 1, minor: 2 }); expect(aggregate.producerVersions).toEqual(['@microsoft/rush-lib@5.177.2']); // The subscriber runs alongside a rendering reporter and does not consume events from it. @@ -110,6 +151,504 @@ describe('TelemetrySubscriber', () => { } }); + it('does not collect producer identities from local-sensitive or secret extension events', async () => { + const LOCAL_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/local-reporter-plugin', + packageVersion: '1.2.3-private' + }; + const SECRET_PRIVATE_SOURCE: IReporterEventSource = { + packageName: '@private/secret-reporter-plugin', + packageVersion: '4.5.6-secret' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('extension', { name: 'private.local.event', privateField: 'local-private-value' }), + source: LOCAL_PRIVATE_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event', secretField: 'secret-private-value' }), + source: SECRET_PRIVATE_SOURCE, + privacy: 'secret' + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + const serialized: string = JSON.stringify(aggregate); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.protocolVersion).toBeUndefined(); + for (const forbidden of [ + LOCAL_PRIVATE_SOURCE.packageName, + LOCAL_PRIVATE_SOURCE.packageVersion, + SECRET_PRIVATE_SOURCE.packageName, + SECRET_PRIVATE_SOURCE.packageVersion, + 'local-private-value', + 'secret-private-value' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('rejects hostile non-public diagnostic fields from foreign envelopes', async () => { + const TOKEN_CODE: string = 'ghp_super_secret_token'; + const TOKEN_CATEGORY: string = 'token=super-secret-value'; + const PATH_CODE: string = '/home/user/private/.npmrc'; + const PATH_CATEGORY: string = 'C:\\Users\\private\\rush.json'; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(1, 'local-sensitive', { + code: PATH_CODE, + category: TOKEN_CATEGORY + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(2, 'secret', { + code: TOKEN_CODE, + category: PATH_CATEGORY + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(3, 'local-sensitive', { + code: 'RUSH_OPERATION_FAILED', + category: 'network-auth' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(4, 'secret', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'configuration' + }) + ); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.diagnosticCodes).toEqual(['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ + 'dependency-tool': 1, + operation: 1 + }); + expect(Object.keys(aggregate.diagnosticCategoryCounts)).toEqual(['dependency-tool', 'operation']); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [TOKEN_CODE, TOKEN_CATEGORY, PATH_CODE, PATH_CATEGORY]) { + expect(serialized).not.toContain(forbidden); + } + }); + + it('preserves allowlisted diagnostics across mixed privacy ordering', async () => { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(1, 'secret', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(2, 'public', { + code: 'RUSH_OPERATION_FAILED', + category: 'operation' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(3, 'local-sensitive', { + code: 'RUSH_CONFIG_INVALID_JSON', + category: 'configuration' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(4, 'secret', { + code: 'RUSH_NOT_REGISTERED_PRIVATE', + category: 'future-private-category' + }) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(5, 'public', { + code: 'RUSH_FUTURE_PUBLIC_CODE', + category: 'future-public-category' + }) + ); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + diagnosticCodes: [ + 'RUSH_CONFIG_INVALID_JSON', + 'RUSH_DEPENDENCY_TOOL_FAILED', + 'RUSH_FUTURE_PUBLIC_CODE', + 'RUSH_OPERATION_FAILED' + ], + diagnosticCategoryCounts: { + configuration: 1, + 'dependency-tool': 1, + operation: 1, + other: 1 + } + }); + expect(Object.keys(telemetry.buildAggregate().diagnosticCategoryCounts)).toEqual([ + 'configuration', + 'dependency-tool', + 'operation', + 'other' + ]); + }); + + it('bounds diagnostic dimensions deterministically under cardinality flooding', async () => { + const publicCodes: string[] = []; + for ( + let index: number = 0; + index < REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes * 3; + index++ + ) { + publicCodes.push(`RUSH_FOREIGN_CODE${String(index).padStart(3, '0')}`); + } + const hostilePrivateCodes: string[] = publicCodes.map((code: string): string => `${code}_PRIVATE`); + const payloads: Array<{ + privacy: IReporterEventEnvelope['privacy']; + code: string; + category: string; + }> = [ + ...publicCodes.map((code: string, index: number) => ({ + privacy: 'public' as const, + code, + category: `/private/category/${index}` + })), + ...hostilePrivateCodes.map((code: string, index: number) => ({ + privacy: index % 2 === 0 ? ('local-sensitive' as const) : ('secret' as const), + code, + category: `token-${index}` + })), + { privacy: 'secret', code: 'RUSH_OPERATION_FAILED', category: 'operation' }, + { + privacy: 'local-sensitive', + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool' + } + ]; + + async function aggregatePayloads(orderedPayloads: typeof payloads): Promise { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + orderedPayloads.forEach((payload, index: number) => { + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope(index + 1, payload.privacy, { + code: payload.code, + category: payload.category + }) + ); + }); + await manager.flushAsync(); + return telemetry.buildAggregate(); + } + + const forward: ITelemetryAggregate = await aggregatePayloads(payloads); + const reverse: ITelemetryAggregate = await aggregatePayloads([...payloads].reverse()); + expect(reverse.diagnosticCodes).toEqual(forward.diagnosticCodes); + expect(forward.diagnosticCodes).toHaveLength(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryDiagnosticCodes); + expect(forward.diagnosticCodes).toContain('RUSH_OPERATION_FAILED'); + expect(forward.diagnosticCodes).toContain('RUSH_DEPENDENCY_TOOL_FAILED'); + expect(forward.diagnosticCodes).not.toContain(hostilePrivateCodes[0]); + expect(forward.diagnosticCategoryCounts).toEqual({ + other: publicCodes.length, + operation: 1, + 'dependency-tool': 1 + }); + expect(Object.keys(forward.diagnosticCategoryCounts)).toHaveLength(3); + }); + + it('keeps protocol root-owned while attributing safe child diagnostics', async () => { + const CHILD_PUBLIC_SOURCE: IReporterEventSource = { + packageName: '@rushstack/heft', + packageVersion: '1.2.19' + }; + const CHILD_SECRET_SOURCE: IReporterEventSource = { + packageName: '@private/child-plugin', + packageVersion: '9.9.9-secret' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope( + 1, + 'public', + { code: 'RUSH_OPERATION_FAILED', category: 'operation' }, + { + source: CHILD_PUBLIC_SOURCE, + protocolVersion: { major: 99, minor: 1 }, + parentSessionId: 'sess' + } + ) + ); + manager.ingestForeignEnvelope( + foreignDiagnosticEnvelope( + 2, + 'secret', + { code: 'RUSH_DEPENDENCY_TOOL_FAILED', category: 'configuration' }, + { + source: CHILD_SECRET_SOURCE, + protocolVersion: { major: 100, minor: 0 }, + parentSessionId: 'sess' + } + ) + ); + await manager.flushAsync(); + + expect(telemetry.buildAggregate()).toMatchObject({ + protocolVersion: { major: 1, minor: 0 }, + producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/heft@1.2.19'], + diagnosticCodes: ['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { 'dependency-tool': 1, operation: 1 } + }); + }); + + it('protects parent producers from root-first and root-last spoofed-prefix floods', async () => { + const childSources: IReporterEventSource[] = []; + for ( + let index: number = 0; + index < REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions * 3; + index++ + ) { + childSources.push({ + packageName: + index % 2 === 0 + ? `@microsoft/spoof-${String(index).padStart(3, '0')}` + : `@rushstack/spoof-${String(index).padStart(3, '0')}`, + packageVersion: '1.0.0' + }); + } + const parentSources: IReporterEventSource[] = [ + { packageName: 'zz-parent-owned-a', packageVersion: '1.0.0' }, + { packageName: 'zz-parent-owned-b', packageVersion: '2.0.0' } + ]; + const oversizedChildSource: IReporterEventSource = { + packageName: `@microsoft/${'x'.repeat(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength)}`, + packageVersion: '1.0.0' + }; + const oversizedParentSource: IReporterEventSource = { + packageName: 'z'.repeat(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength), + packageVersion: '1.0.0' + }; + + async function aggregateSources( + rootFirst: boolean, + reverseChildren: boolean + ): Promise { + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + const emitParentSources = (): void => { + for (const source of [...parentSources, oversizedParentSource]) { + manager.emit({ + ...rawInput('extension', { name: 'parent.public.event' }), + source + }); + } + }; + const emitChildSources = (): void => { + const orderedChildren: IReporterEventSource[] = [ + ...(reverseChildren ? [...childSources].reverse() : childSources), + oversizedChildSource + ]; + orderedChildren.forEach((source: IReporterEventSource, index: number) => { + manager.ingestForeignEnvelope( + foreignEnvelope( + index + 1, + 'extension', + { name: 'foreign.public.event' }, + { + source, + parentSessionId: 'sess', + protocolVersion: { major: 50 + index, minor: 0 } + } + ) + ); + }); + }; + + if (rootFirst) { + emitParentSources(); + emitChildSources(); + } else { + emitChildSources(); + emitParentSources(); + } + await manager.flushAsync(); + return telemetry.buildAggregate(); + } + + const rootFirstForward: ITelemetryAggregate = await aggregateSources(true, false); + const rootFirstReverse: ITelemetryAggregate = await aggregateSources(true, true); + const rootLastForward: ITelemetryAggregate = await aggregateSources(false, false); + const rootLastReverse: ITelemetryAggregate = await aggregateSources(false, true); + expect(rootFirstReverse.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootLastForward.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootLastReverse.producerVersions).toEqual(rootFirstForward.producerVersions); + expect(rootFirstForward.producerVersions).toHaveLength( + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions + ); + for (const source of parentSources) { + expect(rootFirstForward.producerVersions).toContain(`${source.packageName}@${source.packageVersion}`); + } + expect(rootFirstForward.producerVersions).not.toContain( + `${oversizedChildSource.packageName}@${oversizedChildSource.packageVersion}` + ); + expect(rootFirstForward.producerVersions).not.toContain( + `${oversizedParentSource.packageName}@${oversizedParentSource.packageVersion}` + ); + expect( + rootFirstForward.producerVersions.filter((producerVersion: string): boolean => + producerVersion.startsWith('@microsoft/spoof-') + ) + ).toHaveLength(REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersions - parentSources.length); + for (const producerVersion of rootFirstForward.producerVersions) { + expect(producerVersion.length).toBeLessThanOrEqual( + REPORTER_PERFORMANCE_BUDGETS.maxTelemetryProducerVersionLength + ); + } + expect(rootFirstForward.protocolVersion).toEqual({ major: 1, minor: 0 }); + }); + + it('does not admit producer metadata for lifecycle diagnostics with mixed privacy', async () => { + const SECRET: string = 'mixed-privacy-secret'; + const MIXED_SOURCE: IReporterEventSource = { + packageName: '@private/mixed-diagnostic-plugin', + packageVersion: '1.0.0-private' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const recording: RecordingReporter = new RecordingReporter(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + manager.addReporter(recording); + await manager.initializeAsync(); + const emitter: LifecycleEmitter = new LifecycleEmitter({ + sink: manager, + sessionId: 'sess', + source: MIXED_SOURCE, + protocolVersion: { major: 7, minor: 0 } + }); + + emitter.emitDiagnostic( + createRushDiagnostic('RUSH_OPERATION_FAILED', { + parameters: { + publicValue: { value: 'safe', privacy: 'public' }, + token: { value: SECRET, privacy: 'secret' } + } + }) + ); + await manager.flushAsync(); + + expect(recording.reported[0].privacy).toBe('public'); + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate.protocolVersion).toBeUndefined(); + expect(aggregate.producerVersions).toEqual([]); + expect(aggregate.diagnosticCodes).toEqual(['RUSH_OPERATION_FAILED']); + expect(aggregate.diagnosticCategoryCounts).toEqual({ operation: 1 }); + expect(JSON.stringify(aggregate)).not.toContain(SECRET); + expect(JSON.stringify(aggregate)).not.toContain(MIXED_SOURCE.packageName); + }); + + it('projects public envelopes while preserving allowlisted diagnostic fields deterministically', async () => { + const PUBLIC_EXTENSION_SOURCE: IReporterEventSource = { + packageName: '@rushstack/public-reporter-plugin', + packageVersion: '1.2.3' + }; + const PRIVATE_FIRST_PARTY_SOURCE: IReporterEventSource = { + packageName: '@microsoft/internal-build-plugin', + packageVersion: '9.8.7-private' + }; + const telemetry: TelemetrySubscriber = new TelemetrySubscriber(); + const manager: ReporterManager = new ReporterManager(); + manager.addReporter(createTelemetryReporter(telemetry)); + await manager.initializeAsync(); + + manager.emit({ + ...rawInput('commandResult', { + commandName: 'private-command', + succeeded: false, + exitCode: 97 + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive', + protocolVersion: { major: 7, minor: 0 } + }); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('commandResult', { commandName: 'build', succeeded: true, exitCode: 0 })); + manager.emit({ + ...rawInput('extension', { name: 'public.plugin.event' }), + source: PUBLIC_EXTENSION_SOURCE + }); + manager.emit(rawInput('diagnosticEmitted', { code: 'RUSH_OPERATION_FAILED', category: 'operation' })); + manager.emit({ + ...rawInput('operationStatusChanged', { + operationId: 'private-operation', + status: 'failure' + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'local-sensitive' + }); + manager.emit({ + ...rawInput('diagnosticEmitted', { + code: 'RUSH_DEPENDENCY_TOOL_FAILED', + category: 'dependency-tool', + parameters: { + token: { value: 'private-secret-value', privacy: 'secret' } + } + }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret' + }); + manager.emit(rawInput('operationStatusChanged', { operationId: 'public-operation', status: 'success' })); + manager.emit({ + ...rawInput('extension', { name: 'private.secret.event' }), + source: PRIVATE_FIRST_PARTY_SOURCE, + privacy: 'secret', + protocolVersion: { major: 99, minor: 0 } + }); + await manager.flushAsync(); + + const aggregate: ITelemetryAggregate = telemetry.buildAggregate(); + expect(aggregate).toMatchObject({ + commandName: 'build', + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 1 }, + diagnosticCodes: ['RUSH_DEPENDENCY_TOOL_FAILED', 'RUSH_OPERATION_FAILED'], + diagnosticCategoryCounts: { operation: 1, 'dependency-tool': 1 }, + protocolVersion: { major: 1, minor: 0 }, + producerVersions: ['@microsoft/rush-lib@5.177.2', '@rushstack/public-reporter-plugin@1.2.3'] + }); + const serialized: string = JSON.stringify(aggregate); + for (const forbidden of [ + PRIVATE_FIRST_PARTY_SOURCE.packageName, + PRIVATE_FIRST_PARTY_SOURCE.packageVersion, + 'private-command', + 'private-secret-value', + 'private-operation' + ]) { + expect(serialized).not.toContain(forbidden); + } + }); + it('never leaks messages, paths, arguments, remediation, raw output, or secret values', async () => { const SECRET: string = 'sk-super-secret-value'; const LOG_PATH: string = '/home/user/secret/install.log'; diff --git a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap index e46cd3c517..776b7beca2 100644 --- a/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap +++ b/libraries/reporter/src/test/__snapshots__/Goldens.test.ts.snap @@ -3,7 +3,7 @@ exports[`compatibility goldens advertises the current protocol version as the negotiation baseline 1`] = ` Object { "major": 1, - "minor": 1, + "minor": 2, } `; diff --git a/libraries/reporter/src/test/helpers/AiQualificationTestSession.ts b/libraries/reporter/src/test/helpers/AiQualificationTestSession.ts new file mode 100644 index 0000000000..31b694d59c --- /dev/null +++ b/libraries/reporter/src/test/helpers/AiQualificationTestSession.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { Worker } from 'node:worker_threads'; + +import type { + AiQualificationMutation, + AiQualificationWorkerMessage, + IAiQualificationWorkerRequest, + IAiQualificationWorkerResult +} from './AiQualificationWorker'; + +export const QUALIFICATION_TEST_TIMEOUT_MS: number = 15000; +export const QUALIFICATION_CLEANUP_TIMEOUT_MS: number = 2000; +const WORK_TIMEOUT_MS: number = 12000; + +type WorkerOutcome = + | { success: true; result: IAiQualificationWorkerResult } + | { success: false; error: Error }; + +export class AiQualificationTestSession { + public readonly tempRoot: string; + public readonly worker: Worker; + public readonly ready: Promise; + public readonly done: Promise; + readonly #complete: (outcome: WorkerOutcome) => void; + + public constructor( + mutation: AiQualificationMutation, + options: { waitForRelease?: boolean; timeoutAfterReadyMs?: number } = {} + ) { + this.tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-ai-reporter-qualification-')); + try { + this.worker = new Worker(path.join(__dirname, 'AiQualificationWorker.js'), { + workerData: { + mutation, + tempRoot: this.tempRoot, + waitForRelease: options.waitForRelease + } satisfies IAiQualificationWorkerRequest + }); + } catch (error) { + fs.rmSync(this.tempRoot, { recursive: true, force: true }); + throw error; + } + + let complete!: (outcome: WorkerOutcome) => void; + const outcomePromise: Promise = new Promise((resolve) => { + complete = resolve; + }); + this.#complete = complete; + const timedOut: () => void = () => + complete({ success: false, error: new Error('Qualification work timed out') }); + let workTimer: NodeJS.Timeout = setTimeout(timedOut, WORK_TIMEOUT_MS); + let ready!: () => void; + this.ready = new Promise((resolve) => { + ready = resolve; + }); + this.worker.on('message', (message: AiQualificationWorkerMessage) => { + if (message.kind === 'ready') { + if (options.timeoutAfterReadyMs !== undefined) { + clearTimeout(workTimer); + workTimer = setTimeout(timedOut, options.timeoutAfterReadyMs); + } + ready(); + } else { + complete({ success: true, result: message.result }); + } + }); + this.worker.once('error', (error: Error) => complete({ success: false, error })); + this.worker.once('exit', (code: number) => + complete({ success: false, error: new Error(`Qualification worker exited without a result (${code})`) }) + ); + + const worker: Worker = this.worker; + const tempRoot: string = this.tempRoot; + this.done = (async () => { + const outcome: WorkerOutcome = await outcomePromise; + clearTimeout(workTimer); + let cleanupTimer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + (async () => { + await worker.terminate(); + await fs.promises.rm(tempRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }); + })(), + new Promise((resolve, reject) => { + cleanupTimer = setTimeout( + () => reject(new Error('Qualification worker cleanup timed out')), + QUALIFICATION_CLEANUP_TIMEOUT_MS + ); + }) + ]); + } finally { + clearTimeout(cleanupTimer); + ready(); + } + return outcome; + })(); + } + + public async resultAsync(): Promise { + const outcome: WorkerOutcome = await this.done; + if (!outcome.success) { + throw outcome.error; + } + return outcome.result; + } + + public async stopAsync(): Promise { + this.#complete({ success: false, error: new Error('Qualification work cancelled during test cleanup') }); + await this.done; + } +} diff --git a/libraries/reporter/src/test/helpers/AiQualificationWorker.ts b/libraries/reporter/src/test/helpers/AiQualificationWorker.ts new file mode 100644 index 0000000000..639fdc97b2 --- /dev/null +++ b/libraries/reporter/src/test/helpers/AiQualificationWorker.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import { parentPort, workerData, type MessagePort } from 'node:worker_threads'; + +import { AiReporter } from '../../reporters/AiReporter'; +import { runAiReporterQualificationCorpusAsync } from '../../qualification/AiReporterQualificationCorpus'; +import type { IAiReporterQualificationResult } from '../../qualification/AiReporterQualification'; +import type { IReporterEventEnvelope } from '../../events/IReporterEventEnvelope'; + +export type AiQualificationMutation = 'missing-log' | 'unrelated-remediation' | 'capture-bytes'; + +export interface IAiQualificationWorkerRequest { + mutation: AiQualificationMutation; + tempRoot: string; + waitForRelease?: boolean; +} + +export interface IAiQualificationWorkerResult { + qualification: IAiReporterQualificationResult; + outputs: string[]; +} + +export type AiQualificationWorkerMessage = + | { kind: 'ready' } + | { kind: 'result'; result: IAiQualificationWorkerResult }; + +if (!parentPort) { + throw new Error('The qualification mutation fixture must run in an owned worker.'); +} +const port: MessagePort = parentPort; +const request: IAiQualificationWorkerRequest = workerData; +const report: typeof AiReporter.prototype.report = AiReporter.prototype.report; +const outputs: string[] = []; + +if (request.mutation === 'capture-bytes') { + const byteLength: typeof Buffer.byteLength = Buffer.byteLength; + Buffer.byteLength = (...args: Parameters): number => { + const [value] = args; + if ( + typeof value === 'string' && + value.startsWith('{"kind":"ai.') && + value.includes('"kind":"ai.final"') && + value.endsWith('\n') + ) { + outputs.push(value); + } + return byteLength(...args); + }; +} else { + AiReporter.prototype.report = function (event: IReporterEventEnvelope): void { + if (request.mutation === 'missing-log' && event.type === 'artifactAvailable') { + return; + } + const payload: { remediation?: unknown } = event.payload as { remediation?: unknown }; + report.call( + this, + request.mutation === 'unrelated-remediation' && + event.type === 'diagnosticEmitted' && + payload.remediation !== undefined + ? { + ...event, + payload: { + ...payload, + remediation: [ + { + descriptionKey: 'remediation.unrelated', + command: 'rush --version', + automatedExecutionSafety: 'safe' + } + ] + } + } + : event + ); + }; +} + +// The parent allocates the normal corpus temp path so it can also remove it after forced termination. +type TempDirectoryOptions = Parameters[1] | fs.BufferEncodingOption; +function useOwnedTempRoot( + prefix: string, + options?: Exclude +): Promise; +function useOwnedTempRoot(prefix: string, options: fs.BufferEncodingOption): Promise; +function useOwnedTempRoot(prefix: string, options?: TempDirectoryOptions): Promise; +async function useOwnedTempRoot(prefix: string, options?: TempDirectoryOptions): Promise { + if (!request.tempRoot.startsWith(prefix)) { + throw new Error('Unexpected qualification temporary directory prefix'); + } + port.postMessage({ kind: 'ready' } satisfies AiQualificationWorkerMessage); + if (request.waitForRelease) { + await new Promise((resolve, reject) => { + port.once('message', (message: 'run' | 'reject') => { + if (message === 'reject') { + reject(new Error('Injected qualification work rejection')); + } else { + resolve(); + } + }); + }); + } + const encoding: fs.ObjectEncodingOptions['encoding'] | 'buffer' = + typeof options === 'object' ? options?.encoding : options; + const bytes: Buffer = Buffer.from(request.tempRoot); + return encoding === 'buffer' ? bytes : bytes.toString(encoding ?? 'utf8'); +} +fs.promises.mkdtemp = useOwnedTempRoot; + +void runAiReporterQualificationCorpusAsync().then( + (qualification) => { + port.postMessage({ + kind: 'result', + result: { qualification, outputs } + } satisfies AiQualificationWorkerMessage); + port.close(); + }, + (error: unknown) => { + port.close(); + throw error; + } +); diff --git a/libraries/reporter/src/utilities/startReporterTimer.ts b/libraries/reporter/src/utilities/startReporterTimer.ts new file mode 100644 index 0000000000..1d05e505dd --- /dev/null +++ b/libraries/reporter/src/utilities/startReporterTimer.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterContext } from '../manager/IReporter'; + +export function startReporterTimer( + context: IReporterContext | undefined, + action: () => void, + intervalMs: number +): () => void { + const signal: AbortSignal | undefined = context?.abortSignal; + let timer: ReturnType | undefined; + const stop = (): void => { + if (timer) { + clearInterval(timer); + timer = undefined; + } + signal?.removeEventListener('abort', stop); + }; + if (!signal?.aborted) { + signal?.addEventListener('abort', stop, { once: true }); + timer = setInterval(() => { + try { + if (context?.runWithErrorHandling) { + context.runWithErrorHandling(action); + } else { + action(); + } + } catch (error) { + stop(); + throw error; + } + }, intervalMs); + timer.unref(); + } + return stop; +} diff --git a/libraries/rush-client-core/.npmignore b/libraries/rush-client-core/.npmignore new file mode 100644 index 0000000000..f7a40e1021 --- /dev/null +++ b/libraries/rush-client-core/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/rush-client-core/README.md b/libraries/rush-client-core/README.md new file mode 100644 index 0000000000..6c1afe75e6 --- /dev/null +++ b/libraries/rush-client-core/README.md @@ -0,0 +1,124 @@ +# @rushstack/rush-client-core + +Opt-in clients for the Rush daemon wire protocol: request lifecycle requires 0.5; +shutdown requires 0.6; stdin admission, write credits, and EOF require 0.7. +Explicit Rushx invocation selection requires 0.8. +Native install/update and guaranteed pre-execution restart results require 0.10. +Later additive minors do not raise the request minimum. +This package has no +`rush-lib` dependency, command parser, operation graph, or presentation layer. + +`captureDaemonRequest()` copies and freezes argv, cwd, environment, terminal +capabilities and admission settings before startup. `DaemonClient.connectAsync()` +negotiates hello, subscribes capabilities, and awaits a matching pong. +`executeAsync()` uses one fresh connection per invocation and closes it after the +authoritative result. Async stdout/stderr/event callbacks are awaited in wire order, +so slow destinations backpressure the transport. Log callbacks receive raw bytes +and the protocol's operation ID. The calling client owns terminal presentation. + +The optional `invocationKind` is captured unchanged. This core does not infer it from +command names or custom origin. Rushx requests fall back on older peers before sending +`requestStart`, even for TTY input. A peer cannot request fallback after emitting output +or admitting stdin: that is a protocol error, not permission to replay the command. + +Abort signals send `requestCancel`, then wait for the result; cancellation has a +bounded grace period. Disconnects, protocol errors and sink failures are errors, +never reasons to replay possibly executed work. Only pre-execution `unsupported`, +`controllingTerminalRequired`, and `stdinEndUnsupported` outcomes permit fallback. Raw-mode changes are +acknowledged only after applying them. Input listeners and raw state are restored +on success, cancellation, disconnect and failure. No resize messages are sent. + +`executeWithDaemonRestartAsync(readyClient, connectionOptions, executionOptions)` +adds one bounded retry for an explicit `retryAfterRestart: true` result. It captures +the endpoint's PID/start identity before sending, requires protocol 0.10, waits for +that ownership to be released, and reconnects through the same startup mutex. +The original immutable request and unread input are preserved. Output, events, +terminal control, or stdin admission forbid retry, as do connection loss and plain +error messages. A second restart result fails explicitly. Cancellation stops waiting +without killing a daemon. Disabling auto-start still permits waiting for a +host-started successor, but never lets the client spawn one. + +`connectOrStartDaemonAsync()` accepts an **explicit, version-selected** executable, +arguments, environment and cwd. It does not discover or install a Rush version. +It reuses transport paths/reclaim checks and node-core-library's process-identity +aware `LockFile` for the first-start mutex, including kernel-enforced exclusive +file sharing on Windows. The winning client rechecks readiness, +reclaims only an absent/dead owner, and reserves `.starting` before +handing the explicit command to a detached startup helper. The helper spawns without +a shell and retains that reservation until the daemon completes hello/ping readiness, +independently of whether the requesting client survives. Clients still await +hello/pong under bounded backoff. Stdout/stderr go to `.log`. No PID +is killed; a live (possibly reused) PID with an unreachable socket fails closed. +The helper uses a stable tool cwd, and the starting client awaits its exit after +readiness. The explicit launcher's cwd is unchanged. + +An unresolved startup reservation is never automatically reclaimed based on PID +liveness or elapsed time. If the helper cannot establish readiness, subsequent starts +fail closed instead of risking a second detached daemon. Only a known spawn failure +(no executable started) releases the reservation immediately. An arbitrary launcher +can spawn descendants, so its exit is not proof that another launch is safe. +Recovery of an abandoned reservation requires operator confirmation that the original +startup cannot still publish an endpoint; normal successful startup releases it +automatically. Cancellation stops the client waiting, not the detached handoff. + +If a wire-compatible daemon reports the wrong implementation version and an explicit +replacement launcher is available, startup serializes replacement under that same mutex. +It verifies the old endpoint's attested ownership, requests shutdown, waits for ownership +release, and starts or reuses the expected version before returning a client. Concurrent +callers share one replacement; no command is submitted to the old version or replayed. +Passive clients never replace a daemon, and unverifiable ownership or unsupported lifecycle +protocol fails closed. `requestDaemonShutdownAsync()` is the shared ownership-checked +shutdown primitive used by both this path and explicit CLI restart. +Socket resets and broken pipes during hello/ping readiness are retried within the same startup deadline: +a closing host may still retain its listener while joining owned resources. This applies only before +`requestStart`; a connection failure after execution begins still never permits replay. + +`getDaemonLogFilePath(paths)` is the shared stable path used by both the launcher +and the CLI's local `daemon logs` reader. Child stdout/stderr are appended across +restarts, including startup failures; the parent always closes its descriptor +after spawn or failure. On POSIX the launcher enforces `0600` permissions on a +regular, unshared, current-user-owned file and refuses symlink destinations. +This is a text launcher log, not structured request observability. + +`shutdownAsync()` requires a fresh connection with negotiated minor >= 6. It sends +the existing `shutdown` control and resolves only after `shutdownAck` and EOF, +with a bounded timeout. This is acceptance plus connection closure, **not** proof +of successful workspace disposal. + +After acknowledged shutdown, `previousDaemon` on `connectOrStartDaemonAsync()` +identifies the original ownership record by its `pid` and `startedAt`, captured +before sending shutdown. Startup waits until that record disappears, another +owner replaces it, or its owner is demonstrably dead. A new owner is checked by +hello/ping; it is never blindly reclaimed. Signal 0 is only a liveness probe; no +process is killed. A live/reused owner times out conservatively, while corrupt or +unreadable metadata fails closed. +During a captured predecessor handoff, transient Windows sharing-denied reads stay +unknown and are retried only within the existing startup deadline. They never +authorize reclamation; malformed records still fail immediately. + +This relies on the two-phase host close contract: admission stops first, ownership +is retained through workspace disposal, and successful cleanup releases it. +Failed cleanup retains live ownership and prevents restart. Embedded hosts may +therefore release a workspace without exiting their process, and late repeated +close calls cannot remove successor artifacts. + +## Integration boundaries + +Protocol 0.7 negotiates `supportsInputLifecycle`. Input remains untouched until the +host attaches a destination and grants the first `stdinReady` credit. Each credit +permits one chunk, bounded to 64 KiB; the next credit follows the destination's +completed write. This bounds buffering without blocking cancellation or raw-mode +control frames. `stdinEnd` follows all preceding chunks and credits, including for +empty input. Raw Ctrl+C handling is opt-in and must not be enabled for binary pipes. +Set `requiresStdinEnd` for pipes: peers without the capability return +`stdinEndUnsupported` before sending `requestStart` or consuming any input. +Legacy 0.5/0.6 interactive clients retain their raw-mode/terminal-policy input path. + +The standalone host supports native phased builds and the experimental structured +graph reference client. A successful handshake is still transport readiness, not a +guarantee that every command or configuration is supported. Version-selected daemon +installation and incompatible-protocol replacement remain +separate integration work; this core package does not construct an engine. +The startup helper and durable pre-bind reservation protect concurrent first-invocations +even if the original client dies. They do not add a new daemon protocol or authorize +automatic recovery of ambiguous launcher failures. diff --git a/libraries/rush-client-core/config/api-extractor.json b/libraries/rush-client-core/config/api-extractor.json new file mode 100644 index 0000000000..5889c560b9 --- /dev/null +++ b/libraries/rush-client-core/config/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/api-extractor-base.json" +} diff --git a/libraries/rush-client-core/config/jest.config.json b/libraries/rush-client-core/config/jest.config.json new file mode 100644 index 0000000000..d1749681d9 --- /dev/null +++ b/libraries/rush-client-core/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/rush-client-core/config/rig.json b/libraries/rush-client-core/config/rig.json new file mode 100644 index 0000000000..596af2f5a4 --- /dev/null +++ b/libraries/rush-client-core/config/rig.json @@ -0,0 +1,3 @@ +{ + "rigPackageName": "local-node-rig" +} diff --git a/libraries/rush-client-core/eslint.config.js b/libraries/rush-client-core/eslint.config.js new file mode 100644 index 0000000000..07a548c512 --- /dev/null +++ b/libraries/rush-client-core/eslint.config.js @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); + +module.exports = [ + ...nodeTrustedToolProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { parserOptions: { tsconfigRootDir: __dirname } } + } +]; diff --git a/libraries/rush-client-core/package.json b/libraries/rush-client-core/package.json new file mode 100644 index 0000000000..2e57724c90 --- /dev/null +++ b/libraries/rush-client-core/package.json @@ -0,0 +1,50 @@ +{ + "name": "@rushstack/rush-client-core", + "version": "0.1.0", + "description": "Transport and request lifecycle for opt-in Rush daemon clients", + "main": "./lib-commonjs/index.js", + "module": "./lib-esm/index.js", + "types": "./dist/rush-client-core.d.ts", + "exports": { + ".": { + "types": "./dist/rush-client-core.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib-esm/index.js", + "require": "./lib-commonjs/index.js" + }, + "./package.json": "./package.json" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/rush-client-core" + }, + "engines": { + "node": ">=20.9.0" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/rush-daemon-transport": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "sideEffects": false +} diff --git a/libraries/rush-client-core/src/DaemonClient.ts b/libraries/rush-client-core/src/DaemonClient.ts new file mode 100644 index 0000000000..e965154419 --- /dev/null +++ b/libraries/rush-client-core/src/DaemonClient.ts @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Readable } from 'node:stream'; + +import { + DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR, + DAEMON_INVOCATION_KIND_PROTOCOL_MINOR, + DAEMON_LIFECYCLE_PROTOCOL_MINOR, + DAEMON_PROTOCOL_VERSION, + DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR, + DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR, + DaemonFrameType, + DaemonProtocolError, + decodeDaemonControlMessage, + decodeDaemonEventFrame, + decodeDaemonLogChunk, + encodeDaemonControlMessage, + encodeDaemonStdinChunk, + type DaemonControlMessage, + type IDaemonClientCaps, + type IDaemonCommandResult, + type IDaemonEventEnvelope, + type IDaemonFrame, + type IDaemonPongMessage, + type IDaemonProtocolVersion, + type IDaemonRequestEnvelope, + type IDaemonRequestRejectedMessage +} from '@rushstack/rush-daemon-protocol'; +import { connectDaemonAsync, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { DaemonClientError } from './DaemonClientError'; + +const MAX_STDIN_CHUNK_BYTES: number = 64 * 1024; + +/** Options for a fresh connection; readiness includes both hello and ping. @beta */ +export interface IDaemonClientConnectOptions { + readonly socketPath: string; + readonly capabilities?: IDaemonClientCaps; + readonly expectedDaemonVersion?: string; + /** Deadline for connection and handshake, in milliseconds. Defaults to 5000. */ + readonly timeoutMs?: number; +} + +/** One request's backpressured destinations. Callback order is wire order. @beta */ +export interface IDaemonClientExecuteOptions { + readonly request: IDaemonRequestEnvelope; + readonly onStdoutAsync?: (bytes: Uint8Array, operationId: string) => Promise; + readonly onStderrAsync?: (bytes: Uint8Array, operationId: string) => Promise; + readonly onEventAsync?: (event: IDaemonEventEnvelope) => Promise; + readonly onQueuePositionAsync?: (position: number) => Promise; + readonly abortSignal?: AbortSignal; + /** Protocol 0.7 input waits for stdinReady credits; older peers use the legacy raw-mode/terminal policy. */ + readonly stdin?: Readable; + /** Requires negotiated stdin admission and EOF; older peers fall back before requestStart or input consumption. */ + readonly requiresStdinEnd?: boolean; + readonly setRawMode?: (enabled: boolean) => void; + readonly initialRawMode?: boolean; + /** Treat a raw Ctrl+C byte as request cancellation. Defaults to false, preserving arbitrary input bytes. */ + readonly cancelOnCtrlC?: boolean; + /** Time allowed to finish cancellation. Defaults to 5000 milliseconds. */ + readonly cancellationTimeoutMs?: number; +} + +/** Only explicit, pre-execution rejections permit in-process fallback. @beta */ +export type DaemonClientOutcome = + | { readonly kind: 'result'; readonly result: IDaemonCommandResult } + | { + readonly kind: 'fallback'; + readonly reason: 'unsupported' | 'controllingTerminalRequired' | 'stdinEndUnsupported'; + readonly message?: string; + } + | { readonly kind: 'rejected'; readonly rejection: IDaemonRequestRejectedMessage['payload'] }; + +interface IDeferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (error: Error) => void; +} + +function deferred(): IDeferred { + let resolveDeferred!: (value: T) => void; + let rejectDeferred!: (error: Error) => void; + const promise: Promise = new Promise((resolve, reject) => { + resolveDeferred = resolve; + rejectDeferred = reject; + }); + return { promise, resolve: resolveDeferred, reject: rejectDeferred }; +} + +/** + * A single-invocation client. Always closes after execution; request IDs are never recycled. + * @remarks No command parsing, process-global environment changes, or terminal rendering occurs here. + * @beta + */ +export class DaemonClient { + readonly #connection: DaemonFrameConnection; + readonly #ready: IDeferred = deferred(); + readonly #connectOptions: IDaemonClientConnectOptions; + #peerProtocolVersion: IDaemonProtocolVersion | undefined; + #used: boolean = false; + #result: IDeferred | undefined; + #shutdown: IDeferred | undefined; + #shutdownAcknowledged: boolean = false; + #execution: IDaemonClientExecuteOptions | undefined; + #finished: boolean = false; + #inputStarted: boolean = false; + #inputAdmitted: boolean = false; + #observedExecution: boolean = false; + #inputEnded: boolean = false; + #supportsInputLifecycle: boolean = false; + #inputAcknowledgement: IDeferred | undefined; + #inputTail: Promise = Promise.resolve(); + #rawModeChanged: boolean = false; + #sendTail: Promise = Promise.resolve(); + #cancelTimer: ReturnType | undefined; + #cancelSent: boolean = false; + #wasInputPaused: boolean = true; + + private constructor(connection: DaemonFrameConnection, options: IDaemonClientConnectOptions) { + this.#connection = connection; + this.#connectOptions = options; + connection.onFrame((frame) => this.#onFrameAsync(frame)); + connection.onClosed((error) => { + if (this.#shutdown && this.#shutdownAcknowledged && !error) { + this.#shutdown.resolve(undefined); + return; + } + this.#fail( + error ?? + new DaemonClientError( + 'disconnected', + this.#shutdown + ? 'Daemon disconnected before acknowledging shutdown.' + : 'Daemon disconnected before delivering a result; the command was not retried.' + ) + ); + }); + } + + public static async connectAsync(options: IDaemonClientConnectOptions): Promise { + const timeoutMs: number = options.timeoutMs ?? 5000; + validateTimeout(timeoutMs); + const started: number = Date.now(); + const connection: DaemonFrameConnection = await connectDaemonAsync(options.socketPath, { + connectTimeoutMs: timeoutMs + }); + const client: DaemonClient = new DaemonClient(connection, options); + const timer: ReturnType = setTimeout( + () => { + connection.abort( + new DaemonClientError( + 'timeout', + `Daemon at ${options.socketPath} did not complete hello/ping readiness within ${timeoutMs}ms.` + ) + ); + }, + Math.max(1, timeoutMs - (Date.now() - started)) + ); + try { + // Observe readiness before a send failure can reject it. + await Promise.all([ + client.#ready.promise, + client.#sendControlAsync({ kind: 'hello', payload: { protocolVersion: DAEMON_PROTOCOL_VERSION } }) + ]); + return client; + } catch (error) { + await client.closeAsync(); + throw error; + } finally { + clearTimeout(timer); + } + } + + /** The reply that proved this connection ready. */ + public get status(): Promise { + return this.#ready.promise; + } + + /** The common protocol version established by hello. */ + public get protocolVersion(): IDaemonProtocolVersion { + return this.#peerProtocolVersion!; + } + + /** + * Requests shutdown on a fresh connection and waits for acknowledgement followed by EOF. + * @remarks This confirms acceptance and connection closure, not successful workspace cleanup. + * Requires protocol 0.6. The timeout defaults to 15000 milliseconds. + */ + public async shutdownAsync(timeoutMs: number = 15000): Promise { + if (this.#used) throw new Error('Create a fresh DaemonClient for shutdown.'); + this.#used = true; + let timer: ReturnType | undefined; + try { + validateTimeout(timeoutMs); + if (this.protocolVersion.minor < DAEMON_LIFECYCLE_PROTOCOL_MINOR) { + throw new DaemonClientError('versionMismatch', 'Daemon shutdown requires protocol 0.6 or newer.'); + } + this.#shutdown = deferred(); + timer = setTimeout(() => { + this.#connection.abort( + new DaemonClientError( + 'timeout', + 'Timed out waiting for shutdown acknowledgement and EOF; no PID was signaled.' + ) + ); + }, timeoutMs); + await Promise.all([this.#shutdown.promise, this.#sendControlAsync({ kind: 'shutdown', payload: {} })]); + } finally { + clearTimeout(timer); + await this.closeAsync(); + } + } + + /** Executes once, relays the result after output drains, and restores terminal state on every exit. */ + public async executeAsync(options: IDaemonClientExecuteOptions): Promise { + if (this.#used) throw new Error('Create a fresh DaemonClient for each invocation.'); + this.#used = true; + this.#execution = options; + const cancel = (): void => this.#cancel(); + options.abortSignal?.addEventListener('abort', cancel, { once: true }); + try { + validateTimeout(options.cancellationTimeoutMs ?? 5000); + if (options.stdin?.readableEncoding) { + throw new Error('Daemon stdin must supply raw bytes; do not use setEncoding().'); + } + if (options.requiresStdinEnd && (!options.stdin || !options.request.terminal.acceptsStdin)) { + throw new Error('requiresStdinEnd requires a stdin source and an input-capable request.'); + } + if (options.abortSignal?.aborted) { + return { + kind: 'result', + result: { requestId: options.request.requestId, exitCode: 130, outcome: 'aborted', aborted: true } + }; + } + if (options.requiresStdinEnd && !this.#supportsInputLifecycle) { + return { + kind: 'fallback', + reason: 'stdinEndUnsupported', + message: 'The daemon does not support stdin admission and EOF; no request was sent.' + }; + } + if ( + options.request.invocationKind === 'rushx' && + this.protocolVersion.minor < DAEMON_INVOCATION_KIND_PROTOCOL_MINOR + ) { + return { + kind: 'fallback', + reason: 'unsupported', + message: 'The daemon does not support explicit Rushx invocations; no request was sent.' + }; + } + if ( + options.request.invocationKind !== 'rushx' && + options.request.commandOrigin === 'built-in' && + ['install', 'update'].includes(options.request.commandName) && + this.protocolVersion.minor < DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR + ) { + return { + kind: 'fallback', + reason: 'unsupported', + message: 'The daemon does not support native mutation handoff; no request was sent.' + }; + } + this.#result = deferred(); + await Promise.all([ + this.#sendControlAsync({ kind: 'requestStart', payload: options.request }), + this.#result.promise + ]); + return await this.#result.promise; + } finally { + this.#finished = true; + clearTimeout(this.#cancelTimer); + options.abortSignal?.removeEventListener('abort', cancel); + this.#stopInput(); + try { + if (this.#rawModeChanged) options.setRawMode?.(options.initialRawMode ?? false); + } finally { + await this.closeAsync(); + } + } + } + + public async closeAsync(): Promise { + await this.#connection.closeAsync(); + } + + #cancel(): void { + if (this.#cancelSent || this.#finished || !this.#execution) return; + this.#cancelSent = true; + this.#stopInput(); + this.#cancelTimer = setTimeout(() => { + this.#connection.abort( + new DaemonClientError( + 'timeout', + 'Daemon did not finish cancellation; disconnected without retrying the command.' + ) + ); + }, this.#execution.cancellationTimeoutMs ?? 5000); + void this.#sendControlAsync({ + kind: 'requestCancel', + payload: { requestId: this.#execution.request.requestId } + }).catch((error: Error) => this.#fail(error)); + } + + async #onFrameAsync(frame: IDaemonFrame): Promise { + if (frame.kind === DaemonFrameType.controlJson) { + await this.#onControlAsync(decodeDaemonControlMessage(frame.payload)); + return; + } + const execution: IDaemonClientExecuteOptions = this.#requireExecution(); + this.#observedExecution = true; + if (frame.kind === DaemonFrameType.event) { + await execution.onEventAsync?.(decodeDaemonEventFrame(frame.payload)); + } else if (frame.kind === DaemonFrameType.logStdout || frame.kind === DaemonFrameType.logStderr) { + const { chunk, operationId } = decodeDaemonLogChunk(frame.payload); + const sink: IDaemonClientExecuteOptions['onStdoutAsync'] = + frame.kind === DaemonFrameType.logStdout ? execution.onStdoutAsync : execution.onStderrAsync; + if (!sink) throw new Error('A daemon output stream has no destination.'); + await sink(chunk, operationId); + } else { + throw new DaemonProtocolError('malformedControlMessage', 'Unexpected input frame from daemon.'); + } + } + + async #onControlAsync(message: DaemonControlMessage): Promise { + if (message.kind === 'error') + throw new DaemonProtocolError(message.payload.code, message.payload.message); + if (!this.#peerProtocolVersion) { + if (message.kind !== 'helloAck') throw new Error('Expected daemon helloAck.'); + const version: IDaemonProtocolVersion = message.payload.protocolVersion; + if ( + version.major !== DAEMON_PROTOCOL_VERSION.major || + version.minor < DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR + ) { + throw new DaemonClientError( + 'versionMismatch', + 'Daemon does not support the required request lifecycle protocol; restart it with a matching version.' + ); + } + this.#peerProtocolVersion = Object.freeze({ + major: version.major, + minor: Math.min(version.minor, DAEMON_PROTOCOL_VERSION.minor) + }); + this.#supportsInputLifecycle = + this.#peerProtocolVersion.minor >= DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR && + this.#connectOptions.capabilities?.supportsInputLifecycle !== false; + await this.#sendControlAsync({ + kind: 'subscribe', + payload: { + ...this.#connectOptions.capabilities, + isTTY: this.#connectOptions.capabilities?.isTTY ?? false, + supportsInteractiveIO: true, + supportsInputLifecycle: this.#supportsInputLifecycle, + supportsRequestAdmission: true, + supportsRequestLifecycle: true + } + }); + await this.#sendControlAsync({ kind: 'ping', payload: {} }); + return; + } + if (message.kind === 'pong' && !this.#used) { + const expected: string | undefined = this.#connectOptions.expectedDaemonVersion; + if (expected !== undefined && message.payload.daemonVersion !== expected) { + throw new DaemonClientError( + 'versionMismatch', + `Expected daemon ${expected}, received ${message.payload.daemonVersion ?? 'unknown'}. Stop the old daemon before retrying; no PID was killed.` + ); + } + this.#ready.resolve(message.payload); + return; + } + if (message.kind === 'shutdownAck') { + if (!this.#shutdown || this.#shutdownAcknowledged) { + throw new DaemonProtocolError('malformedControlMessage', 'Unexpected shutdown acknowledgement.'); + } + this.#shutdownAcknowledged = true; + return; + } + const execution: IDaemonClientExecuteOptions = this.#requireExecution(); + if (!('requestId' in message.payload) || message.payload.requestId !== execution.request.requestId) { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'Daemon control does not belong to the active request.' + ); + } + switch (message.kind) { + case 'requestResult': + this.#complete({ kind: 'result', result: message.payload }); + return; + case 'requestRejected': + this.#complete( + message.payload.code === 'unsupported' + ? { kind: 'fallback', reason: 'unsupported', message: message.payload.message } + : { kind: 'rejected', rejection: message.payload } + ); + return; + case 'terminalPolicy': + if (message.payload.decision === 'requiresInProcess') { + this.#complete({ kind: 'fallback', reason: 'controllingTerminalRequired' }); + } else { + if (!this.#supportsInputLifecycle) this.#startInput(); + } + return; + case 'setRawMode': + if (!execution.setRawMode) + throw new Error('Daemon requested raw mode from a client without terminal control.'); + execution.setRawMode(message.payload.enabled); + this.#rawModeChanged = true; + await this.#sendControlAsync({ kind: 'rawModeChanged', payload: message.payload }); + if (!this.#supportsInputLifecycle) { + if (message.payload.enabled) this.#startInput(); + else this.#stopInput(); + } + return; + case 'stdinReady': + if (!this.#supportsInputLifecycle) { + throw new DaemonProtocolError('malformedControlMessage', 'Unexpected stdin admission.'); + } + if (!this.#inputAdmitted) { + this.#inputAdmitted = true; + this.#startInput(); + } else if (this.#inputAcknowledgement) { + const acknowledgement: IDeferred = this.#inputAcknowledgement; + this.#inputAcknowledgement = undefined; + acknowledgement.resolve(undefined); + } else { + throw new DaemonProtocolError('malformedControlMessage', 'Unexpected stdin write acknowledgement.'); + } + return; + case 'queuePosition': + await execution.onQueuePositionAsync?.(message.payload.position); + return; + default: + throw new DaemonProtocolError( + 'malformedControlMessage', + `Unexpected daemon control: ${message.kind}.` + ); + } + } + + #requireExecution(): IDaemonClientExecuteOptions { + if (!this.#execution || this.#finished) + throw new Error('Daemon sent request output outside an active request.'); + return this.#execution; + } + + #complete(outcome: DaemonClientOutcome): void { + const restart: boolean = outcome.kind === 'result' && outcome.result.retryAfterRestart === true; + if (restart && this.protocolVersion.minor < DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR) { + throw new DaemonProtocolError('malformedControlMessage', 'Unexpected pre-execution restart outcome.'); + } + if ( + (outcome.kind === 'fallback' || restart) && + (this.#observedExecution || this.#inputAdmitted || this.#inputStarted || this.#rawModeChanged) + ) { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'Daemon requested fallback or restart after output, terminal control, or stdin admission; the command was not retried.' + ); + } + if (restart && this.#cancelSent) { + outcome = { + kind: 'result', + result: { requestId: this.#execution!.request.requestId, outcome: 'aborted', exitCode: 130, aborted: true } + }; + } + this.#finished = true; + this.#stopInput(); + this.#inputAcknowledgement?.resolve(undefined); + this.#inputAcknowledgement = undefined; + this.#result!.resolve(outcome); + } + + #fail(error: Error): void { + this.#inputAcknowledgement?.reject(error); + this.#inputAcknowledgement = undefined; + this.#ready.reject(error); + this.#result?.reject(error); + this.#shutdown?.reject(error); + } + + async #sendControlAsync(message: DaemonControlMessage): Promise { + return this.#sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + } + + #sendFrameAsync(frame: IDaemonFrame): Promise { + this.#sendTail = this.#sendTail.then(() => this.#connection.sendFrameAsync(frame)); + return this.#sendTail; + } + + readonly #onInput = (chunk: Buffer): void => { + const execution: IDaemonClientExecuteOptions = this.#requireExecution(); + if (execution.cancelOnCtrlC && chunk.includes(3)) { + this.#cancel(); + return; + } + execution.stdin!.pause(); + this.#inputTail = this.#sendInputAsync(chunk); + void this.#inputTail + .then(() => { + if (this.#inputStarted && !this.#finished) execution.stdin!.resume(); + }) + .catch((error: Error) => this.#connection.abort(error)); + }; + + async #sendInputAsync(chunk: Uint8Array): Promise { + for (let offset: number = 0; offset < chunk.byteLength; offset += MAX_STDIN_CHUNK_BYTES) { + if (this.#finished || this.#cancelSent) return; + const acknowledgement: IDeferred | undefined = this.#supportsInputLifecycle + ? deferred() + : undefined; + this.#inputAcknowledgement = acknowledgement; + await Promise.all([ + this.#sendFrameAsync({ + kind: DaemonFrameType.stdin, + payload: encodeDaemonStdinChunk({ + requestId: this.#execution!.request.requestId, + chunk: chunk.subarray(offset, offset + MAX_STDIN_CHUNK_BYTES) + }) + }), + acknowledgement?.promise + ]); + } + } + + readonly #onInputError = (error: Error): void => this.#connection.abort(error); + + readonly #onInputEnd = (): void => { + if (!this.#inputStarted || this.#inputEnded || this.#finished) return; + this.#inputEnded = true; + this.#stopInput(); + void this.#inputTail.then(async () => { + if (this.#finished || this.#cancelSent) return; + await this.#sendControlAsync({ + kind: 'stdinEnd', + payload: { requestId: this.#execution!.request.requestId } + }); + }).catch((error: Error) => this.#connection.abort(error)); + }; + + #startInput(): void { + const execution: IDaemonClientExecuteOptions = this.#requireExecution(); + if (this.#inputStarted || this.#inputEnded || !execution.stdin || !execution.request.terminal.acceptsStdin) return; + if (execution.stdin.destroyed && !execution.stdin.readableEnded) { + throw new Error('Daemon stdin closed before admission without reaching EOF.'); + } + this.#wasInputPaused = execution.stdin.isPaused(); + this.#inputStarted = true; + execution.stdin.on('data', this.#onInput); + execution.stdin.on('error', this.#onInputError); + if (this.#supportsInputLifecycle) { + execution.stdin.once('end', this.#onInputEnd); + if (execution.stdin.readableEnded) { + this.#onInputEnd(); + return; + } + } + execution.stdin.resume(); + } + + #stopInput(): void { + if (!this.#inputStarted) return; + this.#inputStarted = false; + const stdin: Readable = this.#execution!.stdin!; + stdin.pause(); + stdin.removeListener('data', this.#onInput); + stdin.removeListener('error', this.#onInputError); + stdin.removeListener('end', this.#onInputEnd); + if (!this.#wasInputPaused && stdin.listenerCount('data') > 0) stdin.resume(); + } +} + +function validateTimeout(timeoutMs: number): void { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 0x7fffffff) { + throw new RangeError('Client timeout must be an integer between 1 and 2147483647 milliseconds.'); + } +} diff --git a/libraries/rush-client-core/src/DaemonClientError.ts b/libraries/rush-client-core/src/DaemonClientError.ts new file mode 100644 index 0000000000..09b21b881f --- /dev/null +++ b/libraries/rush-client-core/src/DaemonClientError.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** A failure before readiness, or a connection lost without an authoritative result. @beta */ +export type DaemonClientErrorCode = 'timeout' | 'versionMismatch' | 'disconnected' | 'startupFailed'; + +/** An actionable client failure. Never replay a request following a disconnect. @beta */ +export class DaemonClientError extends Error { + public readonly code: DaemonClientErrorCode; + + public constructor(code: DaemonClientErrorCode, message: string, options?: ErrorOptions) { + super(message, options); + this.code = code; + this.name = 'DaemonClientError'; + } +} diff --git a/libraries/rush-client-core/src/DaemonLogFile.ts b/libraries/rush-client-core/src/DaemonLogFile.ts new file mode 100644 index 0000000000..42e4a1dbe7 --- /dev/null +++ b/libraries/rush-client-core/src/DaemonLogFile.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +/** The workspace launcher's persistent stdout/stderr log, independent of daemon lifetime. @beta */ +export function getDaemonLogFilePath(paths: IDaemonPaths): string { + return `${paths.lockfilePath}.log`; +} diff --git a/libraries/rush-client-core/src/DaemonStartup.ts b/libraries/rush-client-core/src/DaemonStartup.ts new file mode 100644 index 0000000000..8fcd8e8477 --- /dev/null +++ b/libraries/rush-client-core/src/DaemonStartup.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { + DaemonTransportError, + DaemonTransportErrorCode, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import type { IDaemonStartCommand } from './connectOrStartDaemon'; +import { DaemonClient } from './DaemonClient'; +import { DaemonClientError } from './DaemonClientError'; + +export interface IDaemonStartupOptions { + readonly paths: IDaemonPaths; + readonly startCommand: IDaemonStartCommand; + readonly token: string; + readonly timeoutMs: number; +} + +export function getDaemonStartupFilePath(paths: IDaemonPaths): string { + return `${paths.lockfilePath}.starting`; +} + +export function reserveDaemonStartup(paths: IDaemonPaths): string { + const token: string = randomUUID(); + fs.writeFileSync(getDaemonStartupFilePath(paths), token, { flag: 'wx', mode: 0o600 }); + return token; +} + +function assertReservation(paths: IDaemonPaths, token: string): void { + if (fs.readFileSync(getDaemonStartupFilePath(paths), 'utf8') !== token) { + throw new DaemonClientError('startupFailed', 'The daemon startup reservation changed ownership.'); + } +} + +export function releaseDaemonStartup(paths: IDaemonPaths, token: string): void { + assertReservation(paths, token); + fs.unlinkSync(getDaemonStartupFilePath(paths)); +} + +/** + * Runs independently of the requesting client. Once spawn succeeds, only protocol readiness releases + * the reservation: an arbitrary launcher may outlive its parent or spawn descendants. + * Failure before readiness deliberately leaves a durable reservation instead of guessing that a PID is safe. + */ +export async function runDaemonStartupAsync(options: IDaemonStartupOptions): Promise { + const { paths, startCommand: start, token, timeoutMs } = options; + assertReservation(paths, token); + let child: ChildProcess; + let closed: Promise | undefined; + try { + child = spawn(start.command, [...start.args], { + cwd: start.cwd, + env: start.environment, + detached: true, + stdio: ['ignore', 1, 2], + windowsHide: true + }); + closed = new Promise((resolve) => child.once('close', () => resolve())); + await once(child, 'spawn'); + } catch (error) { + // No executable was started, so this helper can safely release its own reservation. + if (closed) await closed; + releaseDaemonStartup(paths, token); + throw error; + } + child.unref(); + + const deadline: number = Date.now() + timeoutMs; + let backoffMs: number = 50; + while (Date.now() < deadline) { + let client: DaemonClient | undefined; + try { + client = await DaemonClient.connectAsync({ + socketPath: paths.socketPath, + timeoutMs: Math.min(1000, Math.max(1, deadline - Date.now())) + }); + } catch (error) { + if ( + !( + (error instanceof DaemonTransportError && + (error.code === DaemonTransportErrorCode.connectionRefused || + error.code === DaemonTransportErrorCode.connectionTimeout)) || + (error instanceof DaemonClientError && (error.code === 'timeout' || error.code === 'disconnected')) + ) + ) { + throw error; + } + } + if (client) { + try { + releaseDaemonStartup(paths, token); + } finally { + await client.closeAsync(); + } + return; + } + if (child.exitCode !== null || child.signalCode !== null) { + await closed; + throw new DaemonClientError( + 'startupFailed', + `Launcher exited (${child.exitCode ?? child.signalCode}) before protocol readiness; startup reservation retained.` + ); + } + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now()))); + backoffMs = Math.min(500, backoffMs * 2); + } + throw new DaemonClientError( + 'startupFailed', + `Timed out awaiting daemon readiness; startup reservation retained at ${getDaemonStartupFilePath(paths)}.` + ); +} diff --git a/libraries/rush-client-core/src/StartupLock.ts b/libraries/rush-client-core/src/StartupLock.ts new file mode 100644 index 0000000000..32f0d7152d --- /dev/null +++ b/libraries/rush-client-core/src/StartupLock.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { LockFile } from '@rushstack/node-core-library'; +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +export interface IStartupLock { + releaseAsync(): Promise; +} + +export async function tryAcquireStartupLockAsync(paths: IDaemonPaths): Promise { + const folder: string = path.dirname(paths.lockfilePath); + const lock: LockFile | undefined = LockFile.tryAcquire( + folder, + `${path.basename(paths.lockfilePath)}-start` + ); + return lock ? { releaseAsync: async () => lock.release() } : undefined; +} diff --git a/libraries/rush-client-core/src/captureDaemonRequest.ts b/libraries/rush-client-core/src/captureDaemonRequest.ts new file mode 100644 index 0000000000..20abcef061 --- /dev/null +++ b/libraries/rush-client-core/src/captureDaemonRequest.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; + +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +/** Inputs captured once, before connecting or starting a daemon. @beta */ +export interface ICaptureDaemonRequestOptions + extends Omit { + readonly environment: Readonly>; + readonly requestId?: string; +} + +/** Copies and freezes all request-local inputs without modifying process state. @beta */ +export function captureDaemonRequest(options: ICaptureDaemonRequestOptions): IDaemonRequestEnvelope { + const environment: Record = Object.fromEntries( + Object.entries(options.environment).filter((entry): entry is [string, string] => entry[1] !== undefined) + ); + return Object.freeze({ + ...options, + requestId: options.requestId ?? randomUUID(), + argv: Object.freeze([...options.argv]), + environment: Object.freeze(environment), + terminal: Object.freeze({ ...options.terminal }), + admission: options.admission && Object.freeze({ ...options.admission }) + }); +} diff --git a/libraries/rush-client-core/src/connectOrStartDaemon.ts b/libraries/rush-client-core/src/connectOrStartDaemon.ts new file mode 100644 index 0000000000..86361ba10e --- /dev/null +++ b/libraries/rush-client-core/src/connectOrStartDaemon.ts @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { DAEMON_LIFECYCLE_PROTOCOL_MINOR } from '@rushstack/rush-daemon-protocol'; +import { + DaemonTransportError, + DaemonTransportErrorCode, + ensureDaemonRuntimeDir, + reclaimStaleDaemonAsync, + readDaemonLockfile, + type IDaemonLockfile, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import { DaemonClient, type IDaemonClientConnectOptions } from './DaemonClient'; +import { DaemonClientError } from './DaemonClientError'; +import { getDaemonLogFilePath } from './DaemonLogFile'; +import { + getDaemonStartupFilePath, + reserveDaemonStartup, + releaseDaemonStartup, + type IDaemonStartupOptions +} from './DaemonStartup'; +import { tryAcquireStartupLockAsync, type IStartupLock } from './StartupLock'; + +interface IStartupHelper { + readonly child: ChildProcess; + readonly closed: Promise; +} + +/** A version-selected launch command supplied by the embedding application, never guessed by the core. @beta */ +export interface IDaemonStartCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly environment: Readonly>; +} + +/** Detached startup options. @beta */ +export interface IConnectOrStartDaemonOptions extends Omit { + readonly paths: IDaemonPaths; + /** Omit to connect without auto-start. */ + readonly startCommand?: IDaemonStartCommand; + /** + * Ownership captured before acknowledged shutdown. Wait for this record to disappear, change owner, + * or have a demonstrably dead owner before connecting or starting. A live/reused owner times out safely. + */ + readonly previousDaemon?: Pick; + /** Total startup/retry deadline. Defaults to 15000 milliseconds. */ + readonly startupTimeoutMs?: number; + /** Cancels waiting/startup, without stopping startup already handed off to the detached helper. */ + readonly abortSignal?: AbortSignal; +} + +/** + * Connects or serializes first-start with a process-identity-aware mutex, then waits for actual protocol readiness. + * @remarks Uses existing transport reclaim checks. Never kills a PID, reclaims a live owner, or retries a request. + * The daemon is detached and its stdout/stderr are appended to `.log`. + * @beta + */ +export async function connectOrStartDaemonAsync( + options: IConnectOrStartDaemonOptions +): Promise { + const timeoutMs: number = options.startupTimeoutMs ?? 15000; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 0x7fffffff) { + throw new RangeError('startupTimeoutMs must be an integer between 1 and 2147483647.'); + } + const deadline: number = Date.now() + timeoutMs; + options.abortSignal?.throwIfAborted(); + await waitForPreviousDaemonAsync(options.paths, options.previousDaemon, deadline, options.abortSignal); + const initial: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (initial) return initial; + if (!options.startCommand) { + if (options.previousDaemon) { + while (Date.now() < deadline) { + await delayAsync(Math.min(100, Math.max(1, deadline - Date.now())), undefined, { signal: options.abortSignal }); + const successor: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (successor) return successor; + } + } + throw new DaemonClientError( + 'startupFailed', + `No ready daemon at ${options.paths.socketPath}; auto-start is disabled.` + ); + } + ensureDaemonRuntimeDir(options.paths); + let lock: IStartupLock | undefined; + let backoffMs: number = 50; + while (Date.now() < deadline) { + options.abortSignal?.throwIfAborted(); + lock = await tryAcquireStartupLockAsync(options.paths); + if (lock) break; + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now())), undefined, { signal: options.abortSignal }); + backoffMs = Math.min(500, backoffMs * 2); + const ready: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (ready) return ready; + } + if (!lock) throw startupError(options, 'timed out waiting for another starting client'); + try { + while (fs.lstatSync(getDaemonStartupFilePath(options.paths), { throwIfNoEntry: false })) { + const ready: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (ready) return ready; + if (Date.now() >= deadline) { + throw startupError( + options, + `has an unresolved startup handoff at ${getDaemonStartupFilePath(options.paths)}; refusing another launch` + ); + } + await delayAsync(Math.min(100, Math.max(1, deadline - Date.now())), undefined, { + signal: options.abortSignal + }); + } + const ready: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (ready) return ready; + const replacement: DaemonClient | undefined = await replaceMismatchedDaemonAsync(options, deadline); + if (replacement) return replacement; + const handoff: DaemonClient | undefined = await waitForHandoffAsync(options, deadline); + if (handoff) return handoff; + if (Date.now() >= deadline) throw startupError(options, 'exceeded its deadline before reclaim'); + assertNoLiveOwner(options.paths); + await reclaimStaleDaemonAsync(options.paths); + if (Date.now() >= deadline) throw startupError(options, 'exceeded its deadline before spawn'); + options.abortSignal?.throwIfAborted(); + const helper: IStartupHelper = await spawnDetachedAsync(options, deadline); + const { child } = helper; + backoffMs = 50; + while (Date.now() < deadline) { + options.abortSignal?.throwIfAborted(); + const client: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (client) { + try { + await waitForHelperExitAsync(helper, options, deadline); + options.abortSignal?.throwIfAborted(); + return client; + } catch (error) { + await client.closeAsync(); + throw error; + } + } + if ((child.exitCode !== null && child.exitCode !== 0) || child.signalCode !== null) { + await waitForHelperExitAsync(helper, options, deadline); + throw startupError( + options, + `failed: Unable to start ${options.startCommand.command}; helper exited (${child.exitCode ?? child.signalCode}) before readiness` + ); + } + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now())), undefined, { signal: options.abortSignal }); + backoffMs = Math.min(500, backoffMs * 2); + } + throw startupError(options, 'timed out awaiting hello/ping readiness'); + } finally { + await lock.releaseAsync(); + } +} + +/** + * Captures attested ownership and requests shutdown without claiming that cleanup has finished. + * Pass the returned identity as previousDaemon to connectOrStartDaemonAsync before replacement. + * @beta + */ +export async function requestDaemonShutdownAsync( + client: DaemonClient, + paths: IDaemonPaths, + timeoutMs?: number +): Promise> { + if (client.protocolVersion.minor < DAEMON_LIFECYCLE_PROTOCOL_MINOR) { + throw new DaemonClientError('versionMismatch', 'Daemon restart requires protocol 0.6 or newer.'); + } + const { pid } = await client.status; + const owner: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); + if (!isDaemonOwnership(owner) || owner.pid !== pid || owner.socketPath !== paths.socketPath) { + throw new DaemonClientError( + 'startupFailed', + 'The daemon ownership record is missing, unreadable, or changed; shutdown was not sent.' + ); + } + const previousDaemon: Pick = { + pid: owner.pid, + startedAt: owner.startedAt + }; + await client.shutdownAsync(timeoutMs); + return previousDaemon; +} + +async function replaceMismatchedDaemonAsync( + options: IConnectOrStartDaemonOptions, + deadline: number +): Promise { + if (options.expectedDaemonVersion === undefined) return undefined; + const current: DaemonClient | undefined = await tryConnectAsync({ + ...options, expectedDaemonVersion: undefined, startCommand: undefined + }, deadline); + if (!current) return undefined; + if ((await current.status).daemonVersion === options.expectedDaemonVersion) return current; + try { + const previousDaemon: Pick = await requestDaemonShutdownAsync( + current, options.paths, Math.max(1, deadline - Date.now()) + ); + await waitForPreviousDaemonAsync(options.paths, previousDaemon, deadline, options.abortSignal); + } finally { + await current.closeAsync(); + } + return await tryConnectAsync(options, deadline); +} + +async function tryConnectAsync( + options: IConnectOrStartDaemonOptions, + deadline: number +): Promise { + options.abortSignal?.throwIfAborted(); + try { + const client: DaemonClient = await DaemonClient.connectAsync({ + ...options, + socketPath: options.paths.socketPath, + timeoutMs: Math.min(options.timeoutMs ?? 1000, Math.max(1, deadline - Date.now())) + }); + // Do not expose a just-started daemon to shutdown/restart until the helper finishes the handoff. + let pendingStartup: boolean = true; + try { + options.abortSignal?.throwIfAborted(); + pendingStartup = !!fs.lstatSync(getDaemonStartupFilePath(options.paths), { throwIfNoEntry: false }); + } finally { + if (pendingStartup) await client.closeAsync(); + } + return pendingStartup ? undefined : client; + } catch (error) { + options.abortSignal?.throwIfAborted(); + if ( + error instanceof DaemonTransportError && + (error.code === DaemonTransportErrorCode.connectionRefused || + error.code === DaemonTransportErrorCode.connectionTimeout) + ) { + return undefined; + } + if (error instanceof DaemonClientError && (error.code === 'timeout' || error.code === 'disconnected')) { + return undefined; + } + // A closing owner can refuse the handshake while retaining its listener through resource cleanup. + // No request has been sent by connectAsync(), so this does not authorize replay of executed work. + if (hasErrorCode(error, 'ECONNRESET') || hasErrorCode(error, 'EPIPE')) return undefined; + if ( + error instanceof DaemonClientError && + error.code === 'versionMismatch' && + options.startCommand && + options.expectedDaemonVersion !== undefined + ) { + return undefined; + } + throw error; + } +} + +async function waitForHandoffAsync( + options: IConnectOrStartDaemonOptions, + deadline: number +): Promise { + let backoffMs: number = 50; + while (Date.now() < deadline) { + const owner: IDaemonLockfile | undefined = readDaemonLockfile(options.paths.lockfilePath); + // Only wait on a fully published endpoint; malformed or ambiguous ownership still fails closed. + if (!owner || owner.socketPath !== options.paths.socketPath || !isProcessAlive(owner.pid)) return undefined; + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now())), undefined, { signal: options.abortSignal }); + const client: DaemonClient | undefined = await tryConnectAsync(options, deadline); + if (client) return client; + backoffMs = Math.min(500, backoffMs * 2); + } + assertNoLiveOwner(options.paths); + return undefined; +} + +function assertNoLiveOwner(paths: IDaemonPaths): void { + const owner: Pick | undefined = readDaemonOwnership( + paths.lockfilePath + ); + if (!owner) { + if (process.platform !== 'win32' && fs.existsSync(paths.socketPath)) { + throw new DaemonClientError( + 'startupFailed', + `Socket ${paths.socketPath} has no ownership record; refusing automatic reclaim.` + ); + } + return; + } + if (!isProcessAlive(owner.pid)) return; + throw new DaemonClientError( + 'startupFailed', + `PID ${owner.pid} still exists but the daemon is not ready. It may be a reused PID; refusing to kill it or remove ${paths.lockfilePath}.` + ); +} + +function readDaemonOwnership(lockfilePath: string): Pick | undefined { + let record: unknown; + try { + record = JSON.parse(fs.readFileSync(lockfilePath, 'utf8')); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) return undefined; + throw new DaemonClientError( + 'startupFailed', + `Cannot safely read ${lockfilePath}; refusing automatic reclaim.`, + { cause: error } + ); + } + if (!isDaemonOwnership(record)) { + throw new DaemonClientError( + 'startupFailed', + `Invalid daemon ownership record in ${lockfilePath}; refusing automatic reclaim.` + ); + } + return { pid: record.pid, startedAt: record.startedAt }; +} + +function isDaemonOwnership(record: unknown): record is Pick { + return ( + typeof record === 'object' && + record !== null && + 'pid' in record && + typeof record.pid === 'number' && + Number.isSafeInteger(record.pid) && + record.pid > 0 && + 'startedAt' in record && + typeof record.startedAt === 'string' && + Number.isFinite(Date.parse(record.startedAt)) + ); +} + +async function readHandoffOwnershipAsync( + lockfilePath: string, + deadline: number, + abortSignal?: AbortSignal +): Promise | undefined> { + let backoffMs: number = 20; + while (true) { + abortSignal?.throwIfAborted(); + try { + return readDaemonOwnership(lockfilePath); + } catch (error) { + if ( + process.platform !== 'win32' || + !(error instanceof DaemonClientError) || + !(hasErrorCode(error.cause, 'EPERM') || hasErrorCode(error.cause, 'EBUSY')) || + Date.now() >= deadline + ) { + throw error; + } + } + // A sharing-denied record is unknown, not released. Retry only within the existing handoff deadline. + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now())), undefined, { signal: abortSignal }); + backoffMs = Math.min(100, backoffMs * 2); + } +} + +async function waitForPreviousDaemonAsync( + paths: IDaemonPaths, + previous: IConnectOrStartDaemonOptions['previousDaemon'], + deadline: number, + abortSignal?: AbortSignal +): Promise { + if (previous === undefined) return; + if (!isDaemonOwnership(previous)) { + throw new RangeError( + 'previousDaemon must contain a positive safe-integer pid and valid startedAt timestamp.' + ); + } + const { pid, startedAt } = previous; + let backoffMs: number = 50; + while (true) { + abortSignal?.throwIfAborted(); + const owner: Pick | undefined = await readHandoffOwnershipAsync( + paths.lockfilePath, deadline, abortSignal + ); + if (!owner || owner.pid !== pid || owner.startedAt !== startedAt || !isProcessAlive(owner.pid)) return; + if (Date.now() >= deadline) { + throw new DaemonClientError( + 'timeout', + `The previous daemon still owns ${paths.lockfilePath} (PID ${pid}); cleanup is incomplete or failed. No PID was killed and no ownership record was reclaimed.` + ); + } + await delayAsync(Math.min(backoffMs, Math.max(1, deadline - Date.now())), undefined, { signal: abortSignal }); + backoffMs = Math.min(500, backoffMs * 2); + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) return false; + throw error; + } +} + +async function spawnDetachedAsync( + options: IConnectOrStartDaemonOptions, + deadline: number +): Promise { + const start: IDaemonStartCommand = { + ...options.startCommand!, + cwd: path.resolve(options.startCommand!.cwd) + }; + const logFilePath: string = getDaemonLogFilePath(options.paths); + // These distinct native flags have non-overlapping values. + const flags: number = + fs.constants.O_WRONLY + + fs.constants.O_APPEND + + fs.constants.O_CREAT + + (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW + fs.constants.O_NONBLOCK); + const logFd: number = fs.openSync(logFilePath, flags, 0o600); + try { + const stats: fs.Stats = fs.fstatSync(logFd); + if (!stats.isFile() || stats.nlink !== 1) { + throw new DaemonClientError( + 'startupFailed', + `Launcher log must be a regular, unshared file: ${logFilePath}` + ); + } + if (process.platform !== 'win32') { + if (stats.uid !== process.getuid?.()) { + throw new DaemonClientError('startupFailed', `Launcher log is owned by another user: ${logFilePath}`); + } + fs.fchmodSync(logFd, 0o600); + } + const token: string = reserveDaemonStartup(options.paths); + let helper: IStartupHelper | undefined; + try { + const child: ChildProcess = spawn(process.execPath, [path.join(__dirname, 'runDaemonStartup.js')], { + cwd: __dirname, + detached: true, + stdio: ['ignore', logFd, logFd, 'ipc'], + windowsHide: true + }); + helper = { + child, + closed: new Promise((resolve) => child.once('close', () => resolve())) + }; + await once(child, 'spawn'); + } catch (error) { + if (helper) await helper.closed; + releaseDaemonStartup(options.paths, token); + throw new DaemonClientError( + 'startupFailed', + `Unable to start ${start.command}; inspect ${logFilePath}.`, + { cause: error } + ); + } + const { child } = helper; + child.unref(); + const startup: IDaemonStartupOptions = { + paths: options.paths, + startCommand: start, + token, + timeoutMs: Math.max(1, deadline - Date.now()) + }; + let delivered: boolean = false; + try { + await new Promise((resolve, reject) => { + child.send(startup, (error) => (error ? reject(error) : resolve())); + }); + delivered = true; + } finally { + if (!delivered && child.connected) child.disconnect(); + // On successful handoff, let exit close IPC naturally so ChildProcess emits its close event. + child.channel?.unref(); + } + return helper; + } finally { + fs.closeSync(logFd); + } +} + +async function waitForHelperExitAsync( + helper: IStartupHelper, + options: IConnectOrStartDaemonOptions, + deadline: number +): Promise { + const timeout: AbortController = new AbortController(); + const signal: AbortSignal = options.abortSignal + ? AbortSignal.any([options.abortSignal, timeout.signal]) + : timeout.signal; + try { + await Promise.race([ + helper.closed, + delayAsync(Math.max(1, deadline - Date.now()), undefined, { signal }).then(() => { + throw startupError(options, 'timed out awaiting startup helper exit'); + }) + ]); + } catch (error) { + options.abortSignal?.throwIfAborted(); + throw error; + } finally { + timeout.abort(); + } +} + +function startupError(options: IConnectOrStartDaemonOptions, reason: string): DaemonClientError { + return new DaemonClientError( + 'startupFailed', + `Daemon startup ${reason}. Inspect ${getDaemonLogFilePath(options.paths)} and retry, or use --no-daemon.` + ); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code; +} diff --git a/libraries/rush-client-core/src/executeWithDaemonRestart.ts b/libraries/rush-client-core/src/executeWithDaemonRestart.ts new file mode 100644 index 0000000000..1d4784e1c4 --- /dev/null +++ b/libraries/rush-client-core/src/executeWithDaemonRestart.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR } from '@rushstack/rush-daemon-protocol'; +import { readDaemonLockfile, type IDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { connectOrStartDaemonAsync, type IConnectOrStartDaemonOptions } from './connectOrStartDaemon'; +import { captureDaemonRequest } from './captureDaemonRequest'; +import type { DaemonClient, DaemonClientOutcome, IDaemonClientExecuteOptions } from './DaemonClient'; +import { DaemonClientError } from './DaemonClientError'; + +/** + * Executes on a ready client, retrying once only for a typed pre-execution restart. + * Preserves the original request, callbacks and unread input; never retries connection loss. + * The connection options must select the request's expected daemon and startup environment. + * @beta + */ +export async function executeWithDaemonRestartAsync( + client: DaemonClient, + connection: IConnectOrStartDaemonOptions, + execution: IDaemonClientExecuteOptions +): Promise { + const startedAt: number = Date.now(); + const abortSignal: AbortSignal | undefined = execution.abortSignal && connection.abortSignal + ? AbortSignal.any([execution.abortSignal, connection.abortSignal]) + : execution.abortSignal ?? connection.abortSignal; + const owner: IDaemonLockfile | undefined = readDaemonLockfile(connection.paths.lockfilePath); + const { pid } = await client.status; + const attested: boolean = + client.protocolVersion.minor >= DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR && + owner !== undefined && owner.pid === pid && owner.socketPath === connection.paths.socketPath && + Number.isSafeInteger(owner.pid) && owner.pid > 0 && Number.isFinite(Date.parse(owner.startedAt)); + const outcome: DaemonClientOutcome = await client.executeAsync({ ...execution, abortSignal }); + if (outcome.kind !== 'result' || !outcome.result.retryAfterRestart) return outcome; + if (!attested || !owner) { + throw new DaemonClientError( + 'startupFailed', 'Cannot attest the restarting daemon ownership; the request was not retried.' + ); + } + if (abortSignal?.aborted) return abortedOutcome(execution); + let successor: DaemonClient; + try { + successor = await connectOrStartDaemonAsync({ + ...connection, + previousDaemon: { pid: owner.pid, startedAt: owner.startedAt }, + abortSignal + }); + } catch (error) { + if ( + abortSignal?.aborted && + (error === abortSignal.reason || + (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ABORT_ERR')) + ) { + return abortedOutcome(execution); + } + throw error; + } + const waitTimeoutMs: number | undefined = execution.request.admission?.waitTimeoutMs; + const result: DaemonClientOutcome = await successor.executeAsync({ + ...execution, + abortSignal, + request: waitTimeoutMs === undefined ? execution.request : captureDaemonRequest({ + ...execution.request, + admission: { + ...execution.request.admission, + waitTimeoutMs: Math.max(0, waitTimeoutMs - (Date.now() - startedAt)) + } + }) + }); + if (result.kind === 'result' && result.result.retryAfterRestart) { + throw new DaemonClientError( + 'startupFailed', 'The successor requested another restart; the single safe retry was exhausted.' + ); + } + return result; +} + +function abortedOutcome(execution: IDaemonClientExecuteOptions): DaemonClientOutcome { + return { + kind: 'result', + result: { requestId: execution.request.requestId, exitCode: 130, outcome: 'aborted', aborted: true } + }; +} diff --git a/libraries/rush-client-core/src/index.ts b/libraries/rush-client-core/src/index.ts new file mode 100644 index 0000000000..b2962188b7 --- /dev/null +++ b/libraries/rush-client-core/src/index.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Opt-in Rush daemon client transport, immutable requests, and detached startup. + * This package does not depend on rush-lib or construct an operation graph. + * @packageDocumentation + */ +export { captureDaemonRequest, type ICaptureDaemonRequestOptions } from './captureDaemonRequest'; +export { + DaemonClient, + type DaemonClientOutcome, + type IDaemonClientConnectOptions, + type IDaemonClientExecuteOptions +} from './DaemonClient'; +export { DaemonClientError, type DaemonClientErrorCode } from './DaemonClientError'; +export { getDaemonLogFilePath } from './DaemonLogFile'; +export { executeWithDaemonRestartAsync } from './executeWithDaemonRestart'; +export { + connectOrStartDaemonAsync, + requestDaemonShutdownAsync, + type IConnectOrStartDaemonOptions, + type IDaemonStartCommand +} from './connectOrStartDaemon'; diff --git a/libraries/rush-client-core/src/runDaemonStartup.ts b/libraries/rush-client-core/src/runDaemonStartup.ts new file mode 100644 index 0000000000..e71da1359f --- /dev/null +++ b/libraries/rush-client-core/src/runDaemonStartup.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { runDaemonStartupAsync, type IDaemonStartupOptions } from './DaemonStartup'; + +process.once('message', (options: IDaemonStartupOptions) => { + process.channel?.unref(); + runDaemonStartupAsync(options).catch((error: Error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; + }); +}); diff --git a/libraries/rush-client-core/src/test/DaemonClient.test.ts b/libraries/rush-client-core/src/test/DaemonClient.test.ts new file mode 100644 index 0000000000..7a673c30b1 --- /dev/null +++ b/libraries/rush-client-core/src/test/DaemonClient.test.ts @@ -0,0 +1,582 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as net from 'node:net'; +import { PassThrough } from 'node:stream'; +import { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + decodeDaemonControlMessage, + decodeDaemonStdinChunk, + encodeDaemonControlMessage, + encodeDaemonEventFrame, + encodeDaemonLogChunk, + type DaemonControlMessage, + type IDaemonProtocolVersion, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +import { DaemonClient } from '../DaemonClient'; +import { captureDaemonRequest } from '../captureDaemonRequest'; + +describe('DaemonClient', () => { + let server: net.Server; + let connection: DaemonFrameConnection | undefined; + let address: string; + let controls: DaemonControlMessage[]; + let peerVersion: IDaemonProtocolVersion; + let acknowledgeInput: boolean; + let onRequest: (message: DaemonControlMessage) => Promise; + let onStdin: (bytes: Uint8Array) => Promise; + + beforeEach(async () => { + controls = []; + peerVersion = DAEMON_PROTOCOL_VERSION; + acknowledgeInput = true; + address = + process.platform === 'win32' + ? `\\\\.\\pipe\\rush-client-test-${process.pid}-${Math.random()}` + : `/tmp/rush-client-test-${process.pid}-${Math.random()}.sock`; + server = net.createServer((socket) => { + connection = new DaemonFrameConnection(socket); + connection.onFrame(async (frame) => { + if (frame.kind === DaemonFrameType.stdin) { + const { chunk, requestId } = decodeDaemonStdinChunk(frame.payload); + if (peerVersion.minor >= 7 && acknowledgeInput) { + await sendAsync({ kind: 'stdinReady', payload: { requestId } }); + } + await onStdin(chunk); + return; + } + if (frame.kind !== DaemonFrameType.controlJson) return; + const message: DaemonControlMessage = decodeDaemonControlMessage(frame.payload); + controls.push(message); + if (message.kind === 'hello') { + await sendAsync({ + kind: 'helloAck', + payload: { protocolVersion: peerVersion, sessionId: 'test' } + }); + } else if (message.kind === 'ping') { + await sendAsync({ kind: 'pong', payload: { uptimeMs: 1, daemonVersion: 'test' } }); + } else { + await onRequest(message); + } + }); + }); + onRequest = async () => {}; + onStdin = async () => {}; + await new Promise((resolve) => server.listen(address, resolve)); + }); + + afterEach(async () => { + await connection?.closeAsync(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + }); + + async function sendAsync(message: DaemonControlMessage): Promise { + await connection!.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + } + + function request(): IDaemonRequestEnvelope { + return captureDaemonRequest({ + argv: ['build'], + commandName: 'build', + commandOrigin: 'custom', + cwd: '/repo', + environment: { TEST: 'one' }, + terminal: { isTTY: false, supportsColor: false } + }); + } + + it('captures the parser discriminator without coupling core to Rush parsing', () => { + const original = { ...request(), invocationKind: 'rushx' as const }; + const captured = captureDaemonRequest(original); + expect(captured.invocationKind).toBe('rushx'); + expect(Object.isFrozen(captured)).toBe(true); + }); + + it.each([5, 6, 7])('does not send or consume Rushx input on protocol 0.%s', async (minor) => { + peerVersion = { major: 0, minor }; + const input = new PassThrough(); + input.end('unconsumed'); + const client = await DaemonClient.connectAsync({ socketPath: address }); + const envelope = { ...request(), invocationKind: 'rushx' as const }; + const result = await client.executeAsync({ request: envelope, stdin: input }); + expect(result).toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(controls.some((message) => message.kind === 'requestStart')).toBe(false); + expect(input.read().toString()).toBe('unconsumed'); + }); + + it('subscribes, proves readiness and drains output before returning the exit code', async () => { + const seen: string[] = []; + onRequest = async (message) => { + if (message.kind !== 'requestStart') return; + await connection!.sendFrameAsync({ + kind: DaemonFrameType.logStdout, + payload: encodeDaemonLogChunk({ operationId: 'op', chunk: Buffer.from('hello') }) + }); + await connection!.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion: { major: 0, minor: 1 }, + eventId: 'event', + sessionId: 'test', + sequence: 1, + timestamp: new Date().toISOString(), + source: { packageName: 'test', packageVersion: '1.0.0' }, + privacy: 'public', + required: true, + type: 'commandStarted', + payload: {} + }) + }); + await connection!.sendFrameAsync({ + kind: DaemonFrameType.logStderr, + payload: encodeDaemonLogChunk({ operationId: 'op', chunk: Buffer.from('error') }) + }); + await sendAsync({ + kind: 'requestResult', + payload: { requestId: message.payload.requestId, exitCode: 7, outcome: 'failure', aborted: false } + }); + }; + const client = await DaemonClient.connectAsync({ socketPath: address, expectedDaemonVersion: 'test' }); + const result = await client.executeAsync({ + request: request(), + onStdoutAsync: async (bytes) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + seen.push(Buffer.from(bytes).toString()); + }, + onStderrAsync: async (bytes) => { + seen.push(Buffer.from(bytes).toString()); + }, + onEventAsync: async (event) => { + await new Promise((resolve) => setTimeout(resolve, 20)); + seen.push(event.eventId); + } + }); + expect(result).toMatchObject({ kind: 'result', result: { exitCode: 7 } }); + expect(seen).toEqual(['hello', 'event', 'error']); + expect(controls.slice(0, 4).map((message) => message.kind)).toEqual([ + 'hello', + 'subscribe', + 'ping', + 'requestStart' + ]); + }); + + it.each([['install', 9], ['update', 9]])('does not send native %s to protocol 0.%s', async (command, minor) => { + peerVersion = { major: 0, minor: Number(minor) }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + const stdin = new PassThrough(); + stdin.end('untouched'); + const envelope = { ...request(), commandName: String(command), commandOrigin: 'built-in' as const }; + expect(await client.executeAsync({ request: envelope, stdin })) + .toMatchObject({ kind: 'fallback', reason: 'unsupported' }); + expect(controls.some((message) => message.kind === 'requestStart')).toBe(false); + expect(stdin.read().toString()).toBe('untouched'); + }); + + it.each(['output', 'event', 'stdin', 'raw-mode', 'old-peer'])( + 'does not authorize restart replay after %s', + async (mode) => { + if (mode === 'old-peer') peerVersion = { major: 0, minor: 9 }; + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + const stdin = new PassThrough(); + onRequest = async (message) => { + if (message.kind !== 'requestStart') return; + if (mode === 'output') { + await connection!.sendFrameAsync({ + kind: DaemonFrameType.logStdout, + payload: encodeDaemonLogChunk({ operationId: envelope.requestId, chunk: Buffer.from('executed') }) + }); + } else if (mode === 'event') { + await connection!.sendFrameAsync({ + kind: DaemonFrameType.event, + payload: encodeDaemonEventFrame({ + protocolVersion: DAEMON_PROTOCOL_VERSION, eventId: 'event', sessionId: 'test', sequence: 1, + timestamp: new Date().toISOString(), type: 'commandStarted', privacy: 'public', required: true, + source: { packageName: 'test', packageVersion: '1.0.0' }, payload: {} + }) + }); + } else if (mode === 'stdin') { + await sendAsync({ kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + } else if (mode === 'raw-mode') { + await sendAsync({ kind: 'setRawMode', payload: { requestId: envelope.requestId, enabled: true } }); + } + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 1, outcome: 'failure', aborted: false, retryAfterRestart: true } + }); + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.executeAsync({ + request: envelope, stdin, setRawMode: () => {}, onStdoutAsync: async () => {}, onEventAsync: async () => {} + })).rejects.toThrow(mode === 'old-peer' ? 'Unexpected pre-execution restart' : 'not retried'); + stdin.destroy(); + expect(controls.filter((message) => message.kind === 'requestStart')).toHaveLength(1); + } + ); + + it('does not consume stdin when a controlling terminal requires fallback', async () => { + const stdin = new PassThrough(); + stdin.write('still here'); + onRequest = async (message) => { + if (message.kind === 'requestStart') { + await sendAsync({ + kind: 'terminalPolicy', + payload: { + requestId: message.payload.requestId, + decision: 'requiresInProcess', + reason: 'controllingTerminalRequired' + } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + const result = await client.executeAsync({ request: request(), stdin }); + expect(result).toMatchObject({ kind: 'fallback', reason: 'controllingTerminalRequired' }); + expect(stdin.read().toString()).toBe('still here'); + }); + + it('cancels on abort and waits for the authoritative result', async () => { + const abort = new AbortController(); + const envelope = request(); + onRequest = async (message) => { + if (message.kind === 'requestStart') abort.abort(); + if (message.kind === 'requestCancel') { + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 130, aborted: true, outcome: 'aborted' } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + expect(await client.executeAsync({ request: envelope, abortSignal: abort.signal })).toMatchObject({ + kind: 'result', + result: { exitCode: 130 } + }); + expect(controls.filter((message) => message.kind === 'requestCancel')).toHaveLength(1); + }); + + it('forwards raw stdin only after acknowledgement and restores raw mode', async () => { + const stdin = new PassThrough(); + const rawModes: boolean[] = []; + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + onRequest = async (message) => { + if (message.kind === 'requestStart') { + await sendAsync({ kind: 'setRawMode', payload: { requestId: envelope.requestId, enabled: true } }); + await sendAsync({ kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + } + }; + onStdin = async (bytes) => { + expect(Buffer.from(bytes)).toEqual(Buffer.from([0, 255, 13])); + expect(controls.some((message) => message.kind === 'rawModeChanged' && message.payload.enabled)).toBe( + true + ); + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 0, aborted: false, outcome: 'success' } + }); + }; + stdin.write(Buffer.from([0, 255, 13])); + const client = await DaemonClient.connectAsync({ socketPath: address }); + await client.executeAsync({ request: envelope, stdin, setRawMode: (enabled) => rawModes.push(enabled) }); + expect(rawModes).toEqual([true, false]); + expect(stdin.listenerCount('data')).toBe(0); + }); + + it('restores raw mode and rejects instead of replaying after disconnect', async () => { + const rawModes: boolean[] = []; + onRequest = async (message) => { + if (message.kind === 'requestStart') { + await sendAsync({ + kind: 'setRawMode', + payload: { requestId: message.payload.requestId, enabled: true } + }); + } else if (message.kind === 'rawModeChanged') { + await connection!.closeAsync(); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect( + client.executeAsync({ request: request(), setRawMode: (enabled) => rawModes.push(enabled) }) + ).rejects.toThrow('not retried'); + expect(rawModes).toEqual([true, false]); + }); + + it.each(['output', 'input'])('does not replay a late unsupported rejection after %s', async (kind) => { + onRequest = async (message) => { + if (message.kind !== 'requestStart') return; + if (kind === 'output') { + await connection!.sendFrameAsync({ + kind: DaemonFrameType.logStdout, + payload: encodeDaemonLogChunk({ operationId: message.payload.requestId, chunk: Buffer.from('ran') }) + }); + } else { + await sendAsync({ kind: 'stdinReady', payload: { requestId: message.payload.requestId } }); + } + await sendAsync({ + kind: 'requestRejected', + payload: { requestId: message.payload.requestId, code: 'unsupported', message: 'Too late.' } + }); + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.executeAsync({ + request: { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }, + stdin: new PassThrough(), + onStdoutAsync: async () => {} + })).rejects.toThrow('not retried'); + }); + + it('does not replay after terminal control even when no stdin source was supplied', async () => { + const rawModes: boolean[] = []; + const envelope = request(); + onRequest = async (message) => { + if (message.kind === 'requestStart') { + await sendAsync({ + kind: 'setRawMode', payload: { requestId: envelope.requestId, enabled: true } + }); + } else if (message.kind === 'rawModeChanged') { + await sendAsync({ + kind: 'requestRejected', + payload: { requestId: envelope.requestId, code: 'unsupported', message: 'Too late for fallback.' } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.executeAsync({ + request: envelope, setRawMode: (enabled) => rawModes.push(enabled) + })).rejects.toThrow('not retried'); + expect(rawModes).toEqual([true, false]); + expect(controls.filter((message) => message.kind === 'requestStart')).toHaveLength(1); + }); + + it('returns only unsupported rejections as fallback', async () => { + onRequest = async (message) => { + if (message.kind === 'requestStart') + await sendAsync({ + kind: 'requestRejected', + payload: { requestId: message.payload.requestId, code: 'unsupported', message: 'No resolver.' } + }); + + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + expect(await client.executeAsync({ request: request() })).toEqual({ + kind: 'fallback', + reason: 'unsupported', + message: 'No resolver.' + }); + }); + + it.each([5, 6])('preserves piped input when peer 0.%s cannot admit EOF', async (minor) => { + peerVersion = { major: 0, minor }; + const stdin = new PassThrough(); + stdin.end('untouched'); + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + const result = await client.executeAsync({ request: envelope, stdin, requiresStdinEnd: true }); + expect(result).toMatchObject({ kind: 'fallback', reason: 'stdinEndUnsupported' }); + expect(controls.some((message) => message.kind === 'requestStart')).toBe(false); + expect(stdin.read().toString()).toBe('untouched'); + expect(stdin.listenerCount('data')).toBe(0); + }); + + it.each([Buffer.from([0, 3, 255, 13]), Buffer.alloc(0), Buffer.alloc(2 * 1024 * 1024, 3)] + .map((input) => ({ input, length: input.length })))( + 'waits for admission and delivers $length piped bytes followed by EOF', + async ({ input }) => { + const stdin = new PassThrough(); + stdin.end(input); + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + const received: Buffer[] = []; + onStdin = async (bytes) => { received.push(Buffer.from(bytes)); }; + onRequest = async (message) => { + if (message.kind === 'requestStart') { + expect(stdin.readableLength).toBe(input.length); + expect(received).toHaveLength(0); + await sendAsync({ kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + } else if (message.kind === 'stdinEnd') { + expect(Buffer.concat(received).equals(input)).toBe(true); + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 0, outcome: 'success', aborted: false } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.executeAsync({ + request: envelope, stdin, requiresStdinEnd: true + })).resolves.toMatchObject({ kind: 'result', result: { exitCode: 0 } }); + expect(controls.filter((message) => message.kind === 'stdinEnd')).toHaveLength(1); + expect(stdin.listenerCount('end')).toBe(0); + } + ); + + it('keeps control responsive while a large stdin chunk waits for its next write credit', async () => { + acknowledgeInput = false; + const stdin = new PassThrough(); + const input: Buffer = Buffer.alloc(128 * 1024, 3); + stdin.end(input); + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + const received: Buffer[] = []; + onStdin = async (bytes) => { + received.push(Buffer.from(bytes)); + await sendAsync(received.length === 1 + ? { kind: 'setRawMode', payload: { requestId: envelope.requestId, enabled: false } } + : { kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + }; + onRequest = async (message) => { + if (message.kind === 'requestStart' || message.kind === 'rawModeChanged') { + if (message.kind === 'rawModeChanged') expect(received).toHaveLength(1); + await sendAsync({ kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + } else if (message.kind === 'stdinEnd') { + expect(Buffer.concat(received).equals(input)).toBe(true); + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 0, outcome: 'success', aborted: false } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.executeAsync({ + request: envelope, stdin, requiresStdinEnd: true, setRawMode: () => {} + })).resolves.toMatchObject({ kind: 'result', result: { exitCode: 0 } }); + }); + + it('rejects a version mismatch without attempting a request', async () => { + await expect( + DaemonClient.connectAsync({ socketPath: address, expectedDaemonVersion: 'other' }) + ).rejects.toThrow('Expected daemon other'); + expect(controls.some((message) => message.kind === 'requestStart')).toBe(false); + }); + + it('keeps protocol 0.5 request peers compatible with later additive minors', async () => { + peerVersion = { major: 0, minor: 5 }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await client.closeAsync(); + expect(controls.some((message) => message.kind === 'ping')).toBe(true); + }); + + it('rejects peers without the request lifecycle capability', async () => { + peerVersion = { major: 0, minor: 4 }; + await expect(DaemonClient.connectAsync({ socketPath: address })).rejects.toThrow( + 'required request lifecycle protocol' + ); + expect(controls.some((message) => message.kind === 'subscribe')).toBe(false); + }); + + it('waits for both shutdown acknowledgement and EOF', async () => { + let acknowledged: () => void = () => {}; + const ack: Promise = new Promise((resolve) => { + acknowledged = resolve; + }); + onRequest = async (message) => { + if (message.kind === 'shutdown') { + await sendAsync({ kind: 'shutdownAck', payload: {} }); + acknowledged(); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + expect(client.protocolVersion.minor).toBeGreaterThanOrEqual(6); + let completed: boolean = false; + const shutdown: Promise = client.shutdownAsync().then(() => { + completed = true; + }); + await ack; + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(completed).toBe(false); + await connection!.closeAsync(); + await shutdown; + expect(completed).toBe(true); + expect(controls.filter((message) => message.kind === 'shutdown')).toHaveLength(1); + }); + + it('rejects shutdown before sending a frame to a protocol 0.5 peer', async () => { + peerVersion = { major: 0, minor: 5 }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.shutdownAsync()).rejects.toThrow('protocol 0.6'); + expect(controls.some((message) => message.kind === 'shutdown')).toBe(false); + }); + + it('does not mistake an unacknowledged EOF for successful shutdown', async () => { + onRequest = async (message) => { + if (message.kind === 'shutdown') await connection!.closeAsync(); + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.shutdownAsync()).rejects.toThrow('before acknowledging shutdown'); + }); + + it('bounds the wait when an acknowledged shutdown does not close', async () => { + onRequest = async (message) => { + if (message.kind === 'shutdown') await sendAsync({ kind: 'shutdownAck', payload: {} }); + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + await expect(client.shutdownAsync(40)).rejects.toThrow( + 'Timed out waiting for shutdown acknowledgement and EOF' + ); + }); + + it('does not send an already cancelled request or leak a rejected completion promise', async () => { + const client = await DaemonClient.connectAsync({ socketPath: address }); + expect(await client.executeAsync({ request: request(), abortSignal: AbortSignal.abort() })).toMatchObject( + { + kind: 'result', + result: { exitCode: 130, aborted: true } + } + ); + expect(controls.some((message) => message.kind === 'requestStart')).toBe(false); + }); + + it('turns raw Ctrl+C into cancellation when enabled by the CLI', async () => { + const stdin = new PassThrough(); + const envelope = { ...request(), terminal: { ...request().terminal, acceptsStdin: true } }; + stdin.write(Buffer.from([3])); + onRequest = async (message) => { + if (message.kind === 'requestStart') { + await sendAsync({ kind: 'setRawMode', payload: { requestId: envelope.requestId, enabled: true } }); + await sendAsync({ kind: 'stdinReady', payload: { requestId: envelope.requestId } }); + } else if (message.kind === 'requestCancel') { + await sendAsync({ + kind: 'requestResult', + payload: { requestId: envelope.requestId, exitCode: 130, aborted: true, outcome: 'aborted' } + }); + } + }; + const client = await DaemonClient.connectAsync({ socketPath: address }); + expect( + await client.executeAsync({ + request: envelope, + stdin, + cancelOnCtrlC: true, + setRawMode: () => {} + }) + ).toMatchObject({ kind: 'result', result: { exitCode: 130 } }); + }); +}); + +it('captures an immutable request snapshot without undefined environment entries', () => { + const environment = { A: 'before', B: undefined, ['__proto__']: 'preserved' }; + const argv = ['build']; + const terminal = { isTTY: false, supportsColor: false, columns: 80 }; + const request = captureDaemonRequest({ + argv, + environment, + terminal, + cwd: '/repo', + commandName: 'build', + commandOrigin: 'custom' + }); + environment.A = 'after'; + argv.push('--changed'); + terminal.columns = 120; + expect(request.environment).toEqual({ A: 'before', ['__proto__']: 'preserved' }); + expect(request.argv).toEqual(['build']); + expect(request.terminal.columns).toBe(80); + expect(Object.isFrozen(request.environment)).toBe(true); +}); diff --git a/libraries/rush-client-core/src/test/StartupLock.test.ts b/libraries/rush-client-core/src/test/StartupLock.test.ts new file mode 100644 index 0000000000..60e39c852c --- /dev/null +++ b/libraries/rush-client-core/src/test/StartupLock.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { tryAcquireStartupLockAsync, type IStartupLock } from '../StartupLock'; +import { removeTestFolderAsync } from './TestProcessExit'; + +it('holds startup exclusively until the owning lock is released', async () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-start-lock-')); + const paths: IDaemonPaths = { + lockfilePath: path.join(folder, 'daemon.pid.json'), + socketPath: + process.platform === 'win32' + ? `\\\\.\\pipe\\${path.basename(folder)}` + : path.join(folder, 'daemon.sock') + }; + let lock: IStartupLock | undefined = await tryAcquireStartupLockAsync(paths); + try { + expect(lock).toBeDefined(); + expect(await tryAcquireStartupLockAsync(paths)).toBeUndefined(); + await lock!.releaseAsync(); + lock = undefined; + lock = await tryAcquireStartupLockAsync(paths); + expect(lock).toBeDefined(); + } finally { + await lock?.releaseAsync(); + await removeTestFolderAsync(folder); + } +}); diff --git a/libraries/rush-client-core/src/test/TestProcessExit.ts b/libraries/rush-client-core/src/test/TestProcessExit.ts new file mode 100644 index 0000000000..bf684d1144 --- /dev/null +++ b/libraries/rush-client-core/src/test/TestProcessExit.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +/** Call after resource joins. Windows sharing retries wait at most 1.5 seconds in total; other errors fail. */ +export async function removeTestFolderAsync(folder: string, force: boolean = false): Promise { + for (let attempt: number = 0; ; attempt++) { + try { + await fs.promises.rm(folder, { recursive: true, force }); + return; + } catch (error) { + if ( + process.platform !== 'win32' || + attempt >= 5 || + typeof error !== 'object' || + error === null || + !('code' in error) || + (error.code !== 'EPERM' && error.code !== 'EBUSY' && error.code !== 'ENOTEMPTY') + ) + throw error; + } + await delayAsync((attempt + 1) * 100); + } +} + +/** Only for PIDs captured from this test's own spawned fixtures, never for daemon ownership reclamation. */ +export async function waitForTestProcessExitAsync(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) { + throw new Error(`Invalid fixture process PID: ${pid}`); + } + const deadline: number = Date.now() + 5000; + while (isRunning(pid)) { + if (Date.now() >= deadline) throw new Error(`Fixture process ${pid} did not exit before cleanup.`); + await delayAsync(10); + } +} + +function isRunning(pid: number): boolean { + try { + process.kill(pid, 0); + if (process.platform === 'linux') { + const stat: string = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const separator: number = stat.lastIndexOf(') '); + if (separator < 0) throw new Error(`Cannot inspect fixture process ${pid}.`); + // Orphans may await reaping after their starter dies, but zombies have released their resources. + return stat[separator + 2] !== 'Z'; + } + return true; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'ESRCH' || (process.platform === 'linux' && error.code === 'ENOENT')) + ) + return false; + throw error; + } +} diff --git a/libraries/rush-client-core/src/test/WindowsOwnershipRead.test.ts b/libraries/rush-client-core/src/test/WindowsOwnershipRead.test.ts new file mode 100644 index 0000000000..a3ded5d16f --- /dev/null +++ b/libraries/rush-client-core/src/test/WindowsOwnershipRead.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { connectOrStartDaemonAsync } from '../connectOrStartDaemon'; +import { removeTestFolderAsync } from './TestProcessExit'; + +const windowsIt: typeof it = process.platform === 'win32' ? it : it.skip; + +windowsIt.each([false, true])( + 'never treats a sharing-denied ownership record as released (persistent denial: %s)', + async (persistent) => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-owner-read-')); + const paths: IDaemonPaths = { + lockfilePath: path.join(folder, 'daemon.pid.json'), + socketPath: `\\\\.\\pipe\\${path.basename(folder)}` + }; + const previousDaemon = { pid: process.pid, startedAt: new Date().toISOString() }; + const original: string = JSON.stringify(previousDaemon); + fs.writeFileSync(paths.lockfilePath, original); + const holder: ChildProcessWithoutNullStreams = spawn( + path.join(process.env.SystemRoot!, 'System32/WindowsPowerShell/v1.0/powershell.exe'), + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '$s=[IO.File]::Open($env:RUSHD_TEST_OWNER,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::None); [Console]::WriteLine("held"); [Console]::ReadLine() | Out-Null; $s.Dispose();' + ], + { env: { ...process.env, RUSHD_TEST_OWNER: paths.lockfilePath }, stdio: 'pipe' } + ); + const closed: Promise = once(holder, 'close'); + let stderr: string = ''; + holder.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + let failure: unknown; + let settled: boolean = false; + let waiting: Promise | undefined; + try { + await Promise.race([ + once(holder.stdout, 'data'), + closed.then(() => { + throw new Error(`Ownership holder exited before locking: ${stderr}`); + }) + ]); + waiting = connectOrStartDaemonAsync({ + paths, + previousDaemon, + startupTimeoutMs: persistent ? 100 : 1000 + }).then( + async (client) => { + await client.closeAsync(); + settled = true; + }, + (error: unknown) => { + failure = error; + settled = true; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + if (persistent) { + await waiting; + expect(failure).toMatchObject({ message: expect.stringContaining('Cannot safely read') }); + await expect(removeTestFolderAsync(folder)).rejects.toMatchObject({ + code: expect.stringMatching(/^(EPERM|EBUSY)$/) + }); + } + holder.stdin.end('release\n'); + await closed; + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe(original); + if (!persistent) { + fs.unlinkSync(paths.lockfilePath); + await waiting; + expect(failure).toMatchObject({ message: expect.stringContaining('auto-start is disabled') }); + } + } finally { + if (holder.exitCode === null && holder.signalCode === null) holder.stdin.end('release\n'); + await closed; + await waiting; + await removeTestFolderAsync(folder); + } + } +); diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts new file mode 100644 index 0000000000..8b4d95c498 --- /dev/null +++ b/libraries/rush-client-core/src/test/connectOrStartDaemon.test.ts @@ -0,0 +1,613 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { FileSystem } from '@rushstack/node-core-library'; +import type { IDaemonLockfile, IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { DaemonClient } from '../DaemonClient'; +import { captureDaemonRequest } from '../captureDaemonRequest'; +import { getDaemonLogFilePath } from '../DaemonLogFile'; +import { connectOrStartDaemonAsync, type IConnectOrStartDaemonOptions } from '../connectOrStartDaemon'; +import { executeWithDaemonRestartAsync } from '../executeWithDaemonRestart'; +import { getDaemonStartupFilePath } from '../DaemonStartup'; +import { removeTestFolderAsync, waitForTestProcessExitAsync } from './TestProcessExit'; + +describe('detached daemon startup', () => { + let folder: string; + let paths: IDaemonPaths; + let options: IConnectOrStartDaemonOptions; + let starterProcesses: ChildProcess[]; + + beforeEach(() => { + starterProcesses = []; + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-client-')); + paths = { + runtimeDir: folder, + socketPath: + process.platform === 'win32' + ? `\\\\.\\pipe\\rush-client-${path.basename(folder)}` + : path.join(folder, 'd.sock'), + lockfilePath: path.join(folder, 'daemon.pid.json') + }; + const environment = captureDaemonRequest({ + argv: [], + commandName: 'test', + commandOrigin: 'custom', + cwd: folder, + environment: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot }, + terminal: { isTTY: false, supportsColor: false } + }).environment; + options = { + paths, + expectedDaemonVersion: 'fixture', + startupTimeoutMs: 7000, + startCommand: { + command: process.execPath, + args: [path.join(__dirname, 'fixtures/daemon.js'), JSON.stringify(paths)], + cwd: folder, + environment + } + }; + }); + + afterEach(async () => { + for (const starter of starterProcesses) { + if (starter.exitCode === null && starter.signalCode === null) { + const closed: Promise = once(starter, 'close'); + starter.kill('SIGKILL'); + await closed; + } + } + if (fs.existsSync(path.join(folder, 'starts'))) { + fs.writeFileSync(path.join(folder, 'stop'), ''); + const pids: string[] = fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n'); + await Promise.all(pids.map((pid) => waitForTestProcessExitAsync(Number(pid)))); + expect(pids.every((pid) => fs.existsSync(path.join(folder, `stopped-${pid}`)))).toBe(true); + } + if (fs.existsSync(path.join(folder, 'parents'))) { + const parents = new Set(fs.readFileSync(path.join(folder, 'parents'), 'utf8').trim().split('\n')); + await Promise.all([...parents].map((pid) => waitForTestProcessExitAsync(Number(pid)))); + } + await removeTestFolderAsync(folder); + }); + + function startClient(startOptions: IConnectOrStartDaemonOptions = options): { + child: ChildProcess; + result: Promise<{ code: number | null; stderr: string }>; + } { + const child: ChildProcess = spawn( + process.execPath, + [path.join(__dirname, 'fixtures/starter.js'), JSON.stringify(startOptions)], + { stdio: ['ignore', 'ignore', 'pipe'] } + ); + starterProcesses.push(child); + let stderr: string = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + return { + child, + result: once(child, 'close').then(([code]) => ({ code, stderr })) + }; + } + + async function killStarterBeforeBindAsync(): Promise { + fs.writeFileSync(path.join(folder, 'hold-prebind'), ''); + const starter = startClient(); + const barrier: string = path.join(folder, 'prebind'); + const deadline: number = Date.now() + 5000; + while (!fs.existsSync(barrier) && Date.now() < deadline) await delayAsync(20); + expect(fs.existsSync(barrier)).toBe(true); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + const daemonPid: number = Number(fs.readFileSync(barrier, 'utf8')); + expect(Number.isSafeInteger(daemonPid)).toBe(true); + expect(daemonPid).toBeGreaterThan(0); + expect(starter.child.kill('SIGKILL')).toBe(true); + expect((await starter.result).code).not.toBe(0); + expect(starter.child.signalCode).toBe('SIGKILL'); + return daemonPid; + } + + it('fails closed for successor starters while the original detached daemon remains pre-bind', async () => { + const daemonPid: number = await killStarterBeforeBindAsync(); + const results = await Promise.all( + Array.from({ length: 4 }, () => startClient({ ...options, startupTimeoutMs: 700 }).result) + ); + expect(results.every(({ code }) => code !== 0)).toBe(true); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8')).toBe(`${daemonPid}\n`); + expect(fs.existsSync(paths.lockfilePath)).toBe(false); + + fs.unlinkSync(path.join(folder, 'hold-prebind')); + const client = await connectOrStartDaemonAsync(options); + expect((await client.status).pid).toBe(daemonPid); + await client.closeAsync(); + }, 15000); + + it('hands startup to the detached owner after the first client dies and reuses exactly one daemon', async () => { + const daemonPid: number = await killStarterBeforeBindAsync(); + const successors = Array.from({ length: 4 }, () => startClient()); + fs.unlinkSync(path.join(folder, 'hold-prebind')); + expect(await Promise.all(successors.map(({ result }) => result))).toEqual( + Array.from({ length: 4 }, () => ({ code: 0, stderr: '' })) + ); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8')).toBe(`${daemonPid}\n`); + const client = await DaemonClient.connectAsync({ + socketPath: paths.socketPath, + expectedDaemonVersion: 'fixture' + }); + expect((await client.status).pid).toBe(daemonPid); + await client.closeAsync(); + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(false); + }, 15000); + + it('preserves an unresolved startup reservation rather than trusting or reclaiming its contents', async () => { + const startupPath: string = getDaemonStartupFilePath(paths); + const contents: string = JSON.stringify({ pid: process.pid, startedAt: 'not an ownership contract' }); + fs.writeFileSync(startupPath, contents); + const { result } = startClient({ ...options, startupTimeoutMs: 200 }); + expect(await result).toMatchObject({ code: 1, stderr: expect.stringContaining('unresolved startup handoff') }); + expect(fs.readFileSync(startupPath, 'utf8')).toBe(contents); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + }); + + it('does not infer safe retry from a launcher exiting before ownership publication', async () => { + const startupPath: string = getDaemonStartupFilePath(paths); + const failing: IConnectOrStartDaemonOptions = { + ...options, + startCommand: { ...options.startCommand!, args: [path.join(folder, 'missing-entry.js')] } + }; + await expect(connectOrStartDaemonAsync(failing)).rejects.toThrow('Unable to start'); + const contents: string = fs.readFileSync(startupPath, 'utf8'); + await expect(connectOrStartDaemonAsync({ ...options, startupTimeoutMs: 100 })) + .rejects.toThrow('unresolved startup handoff'); + expect(fs.readFileSync(startupPath, 'utf8')).toBe(contents); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + }); + + it.each([false, true])('preserves an explicit launcher and environment (relative cwd: %s)', async (relative) => { + const client = await connectOrStartDaemonAsync({ + ...options, + startCommand: { + ...options.startCommand!, + cwd: relative ? path.relative(process.cwd(), folder) : folder, + args: [ + path.join(__dirname, 'fixtures/launcher.js'), + 'an argument with spaces', + JSON.stringify(paths) + ], + environment: { ...options.startCommand!.environment, FIXTURE_VALUE: 'explicit environment' } + } + }); + expect(JSON.parse(fs.readFileSync(path.join(folder, 'launcher-options'), 'utf8'))).toEqual({ + cwd: folder, + argument: 'an argument with spaces', + environment: 'explicit environment' + }); + expect((await client.status).daemonVersion).toBe('fixture'); + await client.closeAsync(); + }); + + it('finishes startup helper resources before returning a ready client', async () => { + const childProcess = jest.requireActual('node:child_process'); + const originalSpawn: typeof spawn = childProcess.spawn; + const helpers: Set = new Set(); + const closed: Set = new Set(); + const observer = jest.spyOn(childProcess, 'spawn').mockImplementation((command, args, spawnOptions) => { + const child = originalSpawn(command, args, spawnOptions); + if (args?.includes(require.resolve('../runDaemonStartup'))) { + helpers.add(child); + child.once('close', () => closed.add(child)); + } + return child; + }); + try { + const client = await connectOrStartDaemonAsync(options); + await client.closeAsync(); + expect(helpers.size).toBe(1); + expect(closed).toEqual(helpers); + for (const helper of helpers) expect(helper.exitCode).toBe(0); + } finally { + observer.mockRestore(); + } + }); + + it('waits for fixture process exit rather than its work-finished marker', async () => { + const child = spawn(process.execPath, ['-e', "process.stdout.write('finished'); process.stdin.resume();"], { + cwd: folder, + stdio: 'pipe' + }); + starterProcesses.push(child); + const closed: Promise = once(child, 'close'); + await once(child.stdout, 'data'); + let exited: boolean = false; + const waiting: Promise = waitForTestProcessExitAsync(child.pid!).then(() => { + exited = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(exited).toBe(false); + child.stdin.end(); + await Promise.all([closed, waiting]); + expect(exited).toBe(true); + }); + + it('releases a helper whose IPC handoff failed without reclaiming its reservation', async () => { + const childProcess = jest.requireActual('node:child_process'); + const originalSpawn: typeof spawn = childProcess.spawn; + const failure: Error = new Error('fixture IPC handoff failure'); + let helper: ChildProcess | undefined; + let exited: boolean = false; + const observer = jest.spyOn(childProcess, 'spawn').mockImplementation((command, args, spawnOptions) => { + const child = originalSpawn(command, args, spawnOptions); + helper = child; + jest.spyOn(child, 'send').mockImplementation(() => { + throw failure; + }); + return child; + }); + try { + await expect(connectOrStartDaemonAsync(options)).rejects.toBe(failure); + expect(helper?.pid).toBeDefined(); + await waitForTestProcessExitAsync(helper!.pid!); + exited = true; + expect(fs.existsSync(getDaemonStartupFilePath(paths))).toBe(true); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + } finally { + observer.mockRestore(); + if (!exited && helper?.pid !== undefined) { + if (helper.exitCode === null && helper.signalCode === null) helper.kill('SIGKILL'); + await waitForTestProcessExitAsync(helper.pid); + } + } + }); + + it('starts exactly once across four processes and survives all starting clients', async () => { + const starters: ChildProcess[] = Array.from({ length: 4 }, () => + spawn(process.execPath, [path.join(__dirname, 'fixtures/starter.js'), JSON.stringify(options)], { + stdio: ['ignore', 'ignore', 'pipe'] + }) + ); + const results = await Promise.all( + starters.map(async (starter) => { + let stderr: string = ''; + starter.stderr!.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const [code] = await once(starter, 'close'); + return { code, stderr }; + }) + ); + expect(results).toEqual(Array.from({ length: 4 }, () => ({ code: 0, stderr: '' }))); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); + const client = await DaemonClient.connectAsync({ + socketPath: paths.socketPath, + expectedDaemonVersion: 'fixture' + }); + expect(await client.status).toMatchObject({ daemonVersion: 'fixture' }); + await client.closeAsync(); + }, 15000); + + it('replaces a mismatched daemon exactly once for concurrent clients before requests start', async () => { + const old = await connectOrStartDaemonAsync(options); + const previous = await old.status; + await old.closeAsync(); + const replacement: IConnectOrStartDaemonOptions = { + ...options, + expectedDaemonVersion: 'replacement', + startCommand: { ...options.startCommand!, args: [...options.startCommand!.args, 'replacement'] } + }; + const clients: DaemonClient[] = await Promise.all( + Array.from({ length: 4 }, () => connectOrStartDaemonAsync(replacement)) + ); + try { + const statuses = await Promise.all(clients.map((client) => client.status)); + expect(statuses.every((status) => status.daemonVersion === 'replacement')).toBe(true); + expect(new Set(statuses.map((status) => status.pid)).size).toBe(1); + expect(statuses[0].pid).not.toBe(previous.pid); + expect(fs.existsSync(path.join(folder, `stopped-${previous.pid}`))).toBe(true); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(2); + const request = captureDaemonRequest({ + argv: ['test'], commandName: 'test', commandOrigin: 'custom', cwd: folder, environment: {}, + terminal: { isTTY: false, supportsColor: false } + }); + await expect(clients[0].executeAsync({ request })).resolves.toMatchObject({ + kind: 'result', result: { exitCode: 0 } + }); + expect(fs.readFileSync(path.join(folder, 'requests'), 'utf8')).toBe('replacement\n'); + } finally { + await Promise.all(clients.map((client) => client.closeAsync())); + } + }, 15000); + + it('does not replace a mismatched daemon without an explicit launcher', async () => { + const running = await connectOrStartDaemonAsync(options); + await running.closeAsync(); + await expect(connectOrStartDaemonAsync({ + paths, expectedDaemonVersion: 'replacement' + })).rejects.toMatchObject({ code: 'versionMismatch' }); + const original = await DaemonClient.connectAsync({ + socketPath: paths.socketPath, expectedDaemonVersion: 'fixture' + }); + await original.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); + }); + + it.each(['restart-once', 'restart-always', 'restart-held'])( + 'retries only the typed pre-execution result for %s after ownership release', + async (mode) => { + const connection: IConnectOrStartDaemonOptions = { + ...options, + startCommand: { ...options.startCommand!, args: [...options.startCommand!.args, 'fixture', mode] } + }; + const client = await connectOrStartDaemonAsync(connection); + const request = captureDaemonRequest({ + argv: ['test'], commandName: 'test', commandOrigin: 'custom', cwd: folder, environment: {}, + terminal: { isTTY: false, supportsColor: false }, admission: { waitTimeoutMs: 1000 } + }); + const pending = executeWithDaemonRestartAsync(client, { + ...connection, startupTimeoutMs: mode === 'restart-held' ? 100 : 7000 + }, { request }); + if (mode === 'restart-once') { + expect(await pending).toMatchObject({ kind: 'result', result: { exitCode: 0 } }); + const waits = fs.readFileSync(path.join(folder, 'waits'), 'utf8').trim().split('\n').map(Number); + expect(waits[0]).toBe(1000); + expect(waits[1]).toBeLessThan(1000); + expect(waits[1]).toBeGreaterThanOrEqual(0); + expect(request.admission?.waitTimeoutMs).toBe(1000); + } else { + await expect(pending).rejects.toThrow( + mode === 'restart-held' ? 'previous daemon still owns' : 'single safe retry was exhausted' + ); + } + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')) + .toHaveLength(mode === 'restart-held' ? 1 : 2); + expect(fs.readFileSync(path.join(folder, 'requests'), 'utf8').trim().split('\n')) + .toHaveLength(mode === 'restart-held' ? 1 : 2); + }, + 15000 + ); + + it.each(['execution', 'connection'])('cancels successor waiting using the %s signal without replay', async (source) => { + const connection: IConnectOrStartDaemonOptions = { + ...options, + startCommand: { ...options.startCommand!, args: [...options.startCommand!.args, 'fixture', 'restart-held'] } + }; + const client = await connectOrStartDaemonAsync(connection); + const abort = new AbortController(); + const request = captureDaemonRequest({ + argv: ['test'], commandName: 'test', commandOrigin: 'custom', cwd: folder, environment: {}, + terminal: { isTTY: false, supportsColor: false } + }); + const timer = setTimeout(() => abort.abort(), 200); + try { + expect(await executeWithDaemonRestartAsync( + client, + { ...connection, abortSignal: source === 'connection' ? abort.signal : undefined }, + { request, abortSignal: source === 'execution' ? abort.signal : undefined } + )) + .toMatchObject({ kind: 'result', result: { exitCode: 130, aborted: true } }); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); + } finally { + clearTimeout(timer); + } + }); + + it('refuses restart retry if ownership was not attested before submitting', async () => { + const connection: IConnectOrStartDaemonOptions = { + ...options, + startCommand: { ...options.startCommand!, args: [...options.startCommand!.args, 'fixture', 'restart-held'] } + }; + const client = await connectOrStartDaemonAsync(connection); + const owner = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + fs.writeFileSync(paths.lockfilePath, JSON.stringify({ ...owner, pid: process.pid })); + const request = captureDaemonRequest({ + argv: ['test'], commandName: 'test', commandOrigin: 'custom', cwd: folder, environment: {}, + terminal: { isTTY: false, supportsColor: false } + }); + try { + await expect(executeWithDaemonRestartAsync(client, connection, { request })).rejects.toThrow('Cannot attest'); + } finally { + fs.writeFileSync(paths.lockfilePath, JSON.stringify(owner)); + } + }); + + it('waits through published ownership handoff even without a captured predecessor', async () => { + const running = await connectOrStartDaemonAsync({ + ...options, + startCommand: { ...options.startCommand!, args: [...options.startCommand!.args, 'fixture', 'restart-once'] } + }); + const previous = await running.status; + await running.shutdownAsync(); + const replacement = await connectOrStartDaemonAsync(options); + try { + expect((await replacement.status).pid).not.toBe(previous.pid); + expect(fs.existsSync(path.join(folder, `stopped-${previous.pid}`))).toBe(true); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(2); + } finally { + await replacement.closeAsync(); + } + }); + + it('waits for an externally started successor without enabling auto-start', async () => { + const running = await connectOrStartDaemonAsync(options); + const previous = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')) as IDaemonLockfile; + await running.shutdownAsync(); + const waiting = connectOrStartDaemonAsync({ + paths, previousDaemon: previous, expectedDaemonVersion: 'fixture', startupTimeoutMs: 7000 + }); + const starter = await connectOrStartDaemonAsync(options); + try { + const passive = await waiting; + expect((await passive.status).pid).toBe((await starter.status).pid); + await passive.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(2); + } finally { + await starter.closeAsync(); + } + }); + + it('does not stop a mismatched daemon with unverifiable ownership', async () => { + const running = await connectOrStartDaemonAsync(options); + await running.closeAsync(); + fs.writeFileSync(paths.lockfilePath, 'corrupt'); + await expect(connectOrStartDaemonAsync({ + ...options, expectedDaemonVersion: 'replacement' + })).rejects.toThrow('shutdown was not sent'); + const original = await DaemonClient.connectAsync({ + socketPath: paths.socketPath, expectedDaemonVersion: 'fixture' + }); + await original.closeAsync(); + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe('corrupt'); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); + }); + + it('does not start when auto-start is absent', async () => { + await expect(connectOrStartDaemonAsync({ paths })).rejects.toThrow('auto-start is disabled'); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + }); + + it('appends both child streams to the private workspace launcher log', async () => { + const logFilePath: string = getDaemonLogFilePath(paths); + fs.writeFileSync(logFilePath, 'previous startup\n', { mode: 0o644 }); + if (process.platform !== 'win32') fs.chmodSync(logFilePath, 0o644); + const client = await connectOrStartDaemonAsync(options); + await client.closeAsync(); + const log: string = fs.readFileSync(logFilePath, 'utf8'); + expect(log).toContain('previous startup\n'); + expect(log).toContain('launcher stdout\n'); + expect(log).toContain('launcher stderr\n'); + if (process.platform !== 'win32') { + expect(FileSystem.formatPosixModeBits(FileSystem.getPosixModeBits(logFilePath))).toBe('-rw-------'); + } + }); + + it('retains actionable child startup errors in the same log', async () => { + const logFilePath: string = getDaemonLogFilePath(paths); + await expect( + connectOrStartDaemonAsync({ + ...options, + startCommand: { + ...options.startCommand!, + args: [path.join(folder, 'missing-entry.js')] + } + }) + ).rejects.toThrow(logFilePath); + expect(fs.readFileSync(logFilePath, 'utf8')).toContain('Cannot find module'); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'refuses linked log destinations without changing their target', + async () => { + const logFilePath: string = getDaemonLogFilePath(paths); + const target: string = path.join(folder, 'not-a-log.txt'); + fs.writeFileSync(target, 'unchanged', { mode: 0o644 }); + fs.chmodSync(target, 0o644); + fs.symlinkSync(target, logFilePath); + await expect(connectOrStartDaemonAsync(options)).rejects.toMatchObject({ code: 'ELOOP' }); + fs.unlinkSync(logFilePath); + fs.linkSync(target, logFilePath); + await expect(connectOrStartDaemonAsync(options)).rejects.toThrow('regular, unshared file'); + expect(fs.readFileSync(target, 'utf8')).toBe('unchanged'); + expect(FileSystem.formatPosixModeBits(FileSystem.getPosixModeBits(target))).toBe('-rw-r--r--'); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + } + ); + + it('never reclaims a live or reused PID', async () => { + const record: string = JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }); + fs.writeFileSync(paths.lockfilePath, record); + await expect(connectOrStartDaemonAsync(options)).rejects.toThrow('may be a reused PID'); + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe(record); + }); + + it('fails closed on corrupt ownership records', async () => { + fs.writeFileSync(paths.lockfilePath, 'not json'); + await expect(connectOrStartDaemonAsync(options)).rejects.toThrow('refusing automatic reclaim'); + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe('not json'); + }); + + it('starts after ownership release even while the original process remains alive', async () => { + const client = await connectOrStartDaemonAsync({ + ...options, + previousDaemon: { pid: process.pid, startedAt: new Date().toISOString() } + }); + await client.closeAsync(); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(true); + }); + + it('preserves a live predecessor lock when restart times out', async () => { + const previousDaemon = { pid: process.pid, startedAt: new Date().toISOString() }; + const record: string = JSON.stringify(previousDaemon); + fs.writeFileSync(paths.lockfilePath, record); + await expect( + connectOrStartDaemonAsync({ + ...options, + previousDaemon, + startupTimeoutMs: 40 + }) + ).rejects.toThrow('previous daemon still owns'); + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe(record); + }); + + it('waits for disposal to release the original lock before starting', async () => { + const previousDaemon = { pid: process.pid, startedAt: new Date().toISOString() }; + fs.writeFileSync(paths.lockfilePath, JSON.stringify(previousDaemon)); + const pending = connectOrStartDaemonAsync({ ...options, previousDaemon }); + await delayAsync(100); + expect(fs.existsSync(path.join(folder, 'starts'))).toBe(false); + fs.unlinkSync(paths.lockfilePath); + const client = await pending; + await client.closeAsync(); + }); + + it('reconnects to a new owner even if it uses the same PID as the predecessor', async () => { + const first = await connectOrStartDaemonAsync(options); + await first.closeAsync(); + const owner: IDaemonLockfile = JSON.parse(fs.readFileSync(paths.lockfilePath, 'utf8')); + const client = await connectOrStartDaemonAsync({ + ...options, + previousDaemon: { + pid: owner.pid, + startedAt: new Date(Date.parse(owner.startedAt) - 1000).toISOString() + } + }); + await client.closeAsync(); + expect(fs.readFileSync(path.join(folder, 'starts'), 'utf8').trim().split('\n')).toHaveLength(1); + }); + + it('safely reclaims a dead predecessor without requiring lockfile removal', async () => { + const exited: ChildProcess = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + const previousDaemon = { pid: exited.pid!, startedAt: new Date().toISOString() }; + await once(exited, 'exit'); + fs.writeFileSync(paths.lockfilePath, JSON.stringify(previousDaemon)); + const client = await connectOrStartDaemonAsync({ ...options, previousDaemon }); + await client.closeAsync(); + }); + + it('does not treat an unreadable ownership record as released', async () => { + fs.writeFileSync(paths.lockfilePath, 'corrupt'); + await expect( + connectOrStartDaemonAsync({ + ...options, + previousDaemon: { pid: process.pid, startedAt: new Date().toISOString() } + }) + ).rejects.toThrow('Cannot safely read'); + expect(fs.readFileSync(paths.lockfilePath, 'utf8')).toBe('corrupt'); + }); + + it('reports spawn failures and releases the start lock for another invocation', async () => { + const invalid = { + ...options, + startCommand: { ...options.startCommand!, command: path.join(folder, 'missing-executable') } + }; + await expect(connectOrStartDaemonAsync(invalid)).rejects.toThrow('Unable to start'); + const client = await connectOrStartDaemonAsync(options); + await client.closeAsync(); + }, 15000); +}); diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemonCancellation.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemonCancellation.test.ts new file mode 100644 index 0000000000..244b5ba8f0 --- /dev/null +++ b/libraries/rush-client-core/src/test/connectOrStartDaemonCancellation.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + decodeDaemonControlMessage, + encodeDaemonControlMessage, + type DaemonControlMessage +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameConnection, type IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +import { connectOrStartDaemonAsync } from '../connectOrStartDaemon'; +import { DaemonClient } from '../DaemonClient'; +import { DaemonClientError } from '../DaemonClientError'; + +describe('startup cancellation during readiness', () => { + let folder: string; + let paths: IDaemonPaths; + let server: net.Server; + let socket: net.Socket; + let connection: DaemonFrameConnection | undefined; + let client: DaemonClient | undefined; + let pingReceived: Promise; + + beforeEach(async () => { + client = undefined; + connection = undefined; + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-startup-cancel-')); + paths = { + runtimeDir: folder, + lockfilePath: path.join(folder, 'daemon.pid.json'), + socketPath: + process.platform === 'win32' + ? `\\\\.\\pipe\\${path.basename(folder)}` + : path.join(folder, 'daemon.sock') + }; + let receivedPing!: () => void; + pingReceived = new Promise((resolve) => { + receivedPing = resolve; + }); + server = net.createServer((accepted) => { + socket = accepted; + connection = new DaemonFrameConnection(accepted); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await sendAsync({ + kind: 'helloAck', + payload: { protocolVersion: DAEMON_PROTOCOL_VERSION, sessionId: 'cancel-test' } + }); + } else if (message.kind === 'ping') { + receivedPing(); + } + }); + }); + await new Promise((resolve) => server.listen(paths.socketPath, resolve)); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await client?.closeAsync(); + await connection?.closeAsync(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + fs.rmSync(folder, { recursive: true }); + }); + + async function sendAsync(message: DaemonControlMessage): Promise { + await connection!.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + } + + function connectAsync(abortSignal: AbortSignal): Promise { + return connectOrStartDaemonAsync({ paths, abortSignal }).then((ready) => { + client = ready; + return ready; + }); + } + + it.each(['default', 'timeout-shaped'])( + 'rejects %s cancellation and closes the client when a delayed pong arrives', + async (reasonKind) => { + const abort: AbortController = new AbortController(); + const pending: Promise = connectAsync(abort.signal); + await pingReceived; + const closed: Promise = once(socket, 'close'); + abort.abort( + reasonKind === 'timeout-shaped' ? new DaemonClientError('timeout', 'cancelled by host') : undefined + ); + + await Promise.all([ + expect(pending).rejects.toBe(abort.signal.reason), + sendAsync({ kind: 'pong', payload: { uptimeMs: 1, daemonVersion: 'test' } }), + closed + ]); + expect(client).toBeUndefined(); + } + ); + + it('preserves cancellation when the pending handshake disconnects without a pong', async () => { + const abort: AbortController = new AbortController(); + const pending: Promise = connectAsync(abort.signal); + await pingReceived; + abort.abort(new Error('cancelled before readiness')); + + await Promise.all([expect(pending).rejects.toBe(abort.signal.reason), connection!.closeAsync()]); + expect(client).toBeUndefined(); + }); + + it.each(['ECONNRESET', 'EPIPE'])( + 'retries a pre-request %s from a closing listener without starting a second daemon', + async (code) => { + const failure = Object.assign(new Error('Owner closed before readiness'), { code }); + const connect = jest.spyOn(DaemonClient, 'connectAsync').mockRejectedValueOnce(failure); + const pending = connectOrStartDaemonAsync({ + paths, + startupTimeoutMs: 1000, + startCommand: { + command: process.execPath, + args: ['-e', "throw new Error('Unexpected competing daemon launch')"], + cwd: folder, + environment: {} + } + }).then((ready) => { + client = ready; + return ready; + }); + await pingReceived; + await sendAsync({ kind: 'pong', payload: { uptimeMs: 1, daemonVersion: 'closing-successor' } }); + expect((await (await pending).status).daemonVersion).toBe('closing-successor'); + expect(connect).toHaveBeenCalledTimes(2); + expect(fs.existsSync(`${paths.lockfilePath}.starting`)).toBe(false); + } + ); +}); diff --git a/libraries/rush-client-core/src/test/fixtures/daemon.ts b/libraries/rush-client-core/src/test/fixtures/daemon.ts new file mode 100644 index 0000000000..118299fe44 --- /dev/null +++ b/libraries/rush-client-core/src/test/fixtures/daemon.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + decodeDaemonControlMessage, + encodeDaemonControlMessage +} from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, type DaemonFrameConnection, type IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +async function mainAsync(): Promise { + const paths: IDaemonPaths = JSON.parse(process.argv[2]); + const folder: string = path.dirname(paths.lockfilePath); + const daemonVersion: string = process.argv[3] ?? 'fixture'; + const restartMode: string | undefined = process.argv[4]; + const connections: Set = new Set(); + let closing: Promise | undefined; + fs.appendFileSync(path.join(folder, 'starts'), `${process.pid}\n`); + fs.appendFileSync(path.join(folder, 'parents'), `${process.ppid}\n`); + process.stdout.write('launcher stdout\n'); + process.stderr.write('launcher stderr\n'); + if (fs.existsSync(path.join(folder, 'hold-prebind'))) { + const marker: string = path.join(folder, `prebind-${process.pid}.tmp`); + fs.writeFileSync(marker, String(process.pid)); + fs.renameSync(marker, path.join(folder, 'prebind')); + while (fs.existsSync(path.join(folder, 'hold-prebind'))) { + if (fs.existsSync(path.join(folder, 'stop'))) { + fs.writeFileSync(path.join(folder, `stopped-${process.pid}`), ''); + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + const listener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection) => { + connections.add(connection); + connection.onClosed(() => connections.delete(connection)); + connection.onFrame(async (frame) => { + const message = decodeDaemonControlMessage(frame.payload); + if (message.kind === 'hello') { + await connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage({ + kind: 'helloAck', + payload: { protocolVersion: DAEMON_PROTOCOL_VERSION, sessionId: 'fixture' } + }) + }); + } else if (message.kind === 'ping') { + await connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage({ + kind: 'pong', + payload: { daemonVersion, uptimeMs: 1, pid: process.pid } + }) + }); + } else if (message.kind === 'requestStart') { + fs.appendFileSync(path.join(folder, 'requests'), `${daemonVersion}\n`); + if (message.payload.admission?.waitTimeoutMs !== undefined) { + fs.appendFileSync(path.join(folder, 'waits'), `${message.payload.admission.waitTimeoutMs}\n`); + } + const restart: boolean = restartMode !== undefined && + (restartMode !== 'restart-once' || !fs.existsSync(path.join(folder, 'restarted'))); + await connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage({ + kind: 'requestResult', + payload: { + requestId: message.payload.requestId, + exitCode: restart ? 1 : 0, + outcome: restart ? 'failure' : 'success', + aborted: false, + ...(restart ? { retryAfterRestart: true as const } : {}) + } + }) + }); + if (restart && restartMode !== 'restart-held') { + fs.writeFileSync(path.join(folder, 'restarted'), ''); + await stopAsync(); + } + } else if (message.kind === 'shutdown') { + await connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage({ kind: 'shutdownAck', payload: {} }) + }); + await stopAsync(); + } + }); + } + }); + const expiry: number = Date.now() + 10000; + const timer = setInterval(() => { + if (!fs.existsSync(path.join(folder, 'stop')) && Date.now() < expiry) return; + void stopAsync().catch((error: Error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; + }); + }, 50); + + function stopAsync(): Promise { + closing ??= closeOnceAsync(); + return closing; + } + + async function closeOnceAsync(): Promise { + clearInterval(timer); + const stopped: Promise = listener.stopAcceptingAsync(); + await Promise.all([...connections].map((connection) => connection.closeAsync())); + await stopped; + if (restartMode) await new Promise((resolve) => setTimeout(resolve, 150)); + await listener.closeAsync(); + fs.writeFileSync(path.join(folder, `stopped-${process.pid}`), ''); + } +} + +mainAsync().catch((error: Error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +}); diff --git a/libraries/rush-client-core/src/test/fixtures/launcher.ts b/libraries/rush-client-core/src/test/fixtures/launcher.ts new file mode 100644 index 0000000000..4b1f6cccee --- /dev/null +++ b/libraries/rush-client-core/src/test/fixtures/launcher.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +async function mainAsync(): Promise { + fs.appendFileSync(path.join(process.cwd(), 'parents'), `${process.ppid}\n`); + fs.writeFileSync( + path.join(process.cwd(), 'launcher-options'), + JSON.stringify({ cwd: process.cwd(), argument: process.argv[2], environment: process.env.FIXTURE_VALUE }) + ); + const child = spawn(process.execPath, [path.join(__dirname, 'daemon.js'), process.argv[3]], { + stdio: 'inherit' + }); + const [code] = await once(child, 'close'); + process.exitCode = code ?? 1; +} + +mainAsync().catch((error: Error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +}); diff --git a/libraries/rush-client-core/src/test/fixtures/starter.ts b/libraries/rush-client-core/src/test/fixtures/starter.ts new file mode 100644 index 0000000000..f1f395c1b9 --- /dev/null +++ b/libraries/rush-client-core/src/test/fixtures/starter.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { connectOrStartDaemonAsync, type IConnectOrStartDaemonOptions } from '../../connectOrStartDaemon'; + +async function mainAsync(): Promise { + const options: IConnectOrStartDaemonOptions = JSON.parse(process.argv[2]); + const client = await connectOrStartDaemonAsync(options); + await client.closeAsync(); +} + +mainAsync().catch((error: Error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +}); diff --git a/libraries/rush-client-core/tsconfig.json b/libraries/rush-client-core/tsconfig.json new file mode 100644 index 0000000000..dac21d0408 --- /dev/null +++ b/libraries/rush-client-core/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json" +} diff --git a/libraries/rush-daemon-protocol/README.md b/libraries/rush-daemon-protocol/README.md index e120c5cb7b..f88dbe9636 100644 --- a/libraries/rush-daemon-protocol/README.md +++ b/libraries/rush-daemon-protocol/README.md @@ -29,6 +29,40 @@ The engine-agnostic **wire layer** spoken by every client of the Rush daemon (`r - **Request lifecycle contracts** — a validated presentation-free command envelope, cancellation, typed routing rejection/fallback, and one authoritative terminal result control. Command parsing and Rush action construction remain outside the protocol. +- **Daemon lifecycle controls (0.6)** — after a compatible `hello`, a client can send + `shutdown` and receive `shutdownAck` before connection closure. Clients must negotiate at least + `DAEMON_LIFECYCLE_PROTOCOL_MINOR` before sending this control. `pong` can also report the daemon's + PID and resident memory in bytes; older peers may omit these fields. +- **Input lifecycle controls (0.7)** — `supportsInputLifecycle` negotiates `stdinReady` + and `stdinEnd`. The host grants the first write credit only after the request attaches + its input destination, then grants another after each write drains. The client sends + one bounded chunk per credit and EOF after all chunks. Empty data is never interpreted + as EOF. Peers that did not negotiate the capability receive no new controls. +- **Invocation kind (0.8)** - optional `invocationKind: "rush" | "rushx"` selects the + native parser independently of `commandOrigin`. Omission retains legacy Rush + routing; custom workspace commands are never inferred to be package scripts. + A Rushx client must negotiate at least `DAEMON_INVOCATION_KIND_PROTOCOL_MINOR` + before submitting its request. Older peers could ignore the discriminator, + so the client falls back before `requestStart` or input consumption. +- **Graph generation fencing (0.9)** - graph snapshots carry an opaque + `workspaceGeneration` token. Mutation requests must echo it in + `expectedWorkspaceGeneration`; the server checks it under exclusive admission + before touching operations. Tokens change on session or process replacement. + Clients must negotiate `DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR` before mutation; + an older server could otherwise ignore the reference. +- **Workspace restart (0.10)** - native `install`/`update` routing is capability-gated. + A failure result may include `retryAfterRestart: true` only when no execution or + request IO occurred and an available successor was selected. It cannot coexist + with cancellation, admission errors or operation results. Clients may retry once + after attested predecessor ownership release, never on transport loss or an error + string. The ordinary mutation result has no retry flag and drains before restart. +- **Read-only workspace status** - optional `pong.payload.workspace` reports the provider generation, + installed session token, graph existence and real warm accounting. An absent token means no session is + installed; an absent `warmSet` means no controller is attached, not zero memory. Warm status includes + effective configuration, maintenance state/failure, retained/protected/watched projects, measured RSS, + unmeasured runners, pressure and cleanup diagnostics. Child RSS is a last-completion sample, not a + process-tree ceiling. Nested records and numeric fields are validated. Older pong shapes remain valid; + this additive field does not change the 0.9 request, generation-fencing or retry contracts. Part of the Rush 6 / rushd re-architecture: [microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894). diff --git a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts index 7059700ac8..83498713bf 100644 --- a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts +++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts @@ -3,6 +3,7 @@ import { isDaemonControlRecord } from './ControlRecord'; import { isDaemonControlMessageKind } from './DaemonControlKinds'; +import { validateDaemonPong } from './DaemonPongValidation'; import { DaemonProtocolError } from './DaemonProtocolError'; import { isDaemonVerbosity } from './DaemonVerbosity'; import { @@ -12,7 +13,7 @@ import { } from './InteractiveControlValidation'; import { validateRequestAdmissionCapability, validateRequestQueuePositionControl } from './RequestAdmissionControlValidation'; import { validateRequestCancelControl, validateRequestRejectedControl, validateRequestResultControl, validateRequestStartControl } from './RequestControlValidation'; -import { validateRequestLifecycleCapability } from './RequestLifecycleCapabilityValidation'; +import { validateInputLifecycleCapability, validateRequestLifecycleCapability } from './RequestLifecycleCapabilityValidation'; function fail(reason: string): never { throw new DaemonProtocolError('malformedControlMessage', reason); } @@ -42,16 +43,12 @@ function validateHelloAck(payload: Record): void { requireVersion(payload); requireStringField(payload, 'sessionId'); } -function validatePong(payload: Record): void { - if (payload.daemonVersion !== undefined) requireStringField(payload, 'daemonVersion'); - if (payload.protocolVersion !== undefined) requireVersion(payload); - requireNumberField(payload, 'uptimeMs'); -} function validateSubscribe(payload: Record): void { if (typeof payload.isTTY !== 'boolean') { fail('Subscribe message payload.isTTY must be a boolean.'); } validateInteractiveCapability(payload); + validateInputLifecycleCapability(payload); validateRequestAdmissionCapability(payload); validateRequestLifecycleCapability(payload); requireSubscribeVerbosity(payload); @@ -74,7 +71,7 @@ const VALIDATORS_BY_KIND: Record = { subscribe: validateSubscribe, unsubscribe: noopValidator, ping: noopValidator, - pong: validatePong, + pong: validateDaemonPong, error: validateError, setRawMode: validateRawModeControl, rawModeChanged: validateRawModeControl, @@ -83,7 +80,11 @@ const VALIDATORS_BY_KIND: Record = { requestStart: validateRequestStartControl, requestCancel: validateRequestCancelControl, requestRejected: validateRequestRejectedControl, - requestResult: validateRequestResultControl + requestResult: validateRequestResultControl, + shutdown: noopValidator, + shutdownAck: noopValidator, + stdinReady: validateRequestCancelControl, + stdinEnd: validateRequestCancelControl }; /** Structurally validates a parsed control message. @beta */ diff --git a/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts b/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts index 994beb443a..77237f3304 100644 --- a/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts +++ b/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts @@ -18,6 +18,8 @@ export interface IDaemonClientCaps { readonly isTTY: boolean; /** Whether the client supports request-scoped stdin and acknowledged raw-mode control. */ readonly supportsInteractiveIO?: boolean; + /** Whether the client waits for stdin admission and can send request-scoped EOF. */ + readonly supportsInputLifecycle?: boolean; /** Whether the client supports request admission progress controls and typed failures. */ readonly supportsRequestAdmission?: boolean; /** Whether the client supports the request start, cancellation, and terminal outcome controls. */ diff --git a/libraries/rush-daemon-protocol/src/DaemonCommandResult.ts b/libraries/rush-daemon-protocol/src/DaemonCommandResult.ts index d97caf3964..c3726e2b70 100644 --- a/libraries/rush-daemon-protocol/src/DaemonCommandResult.ts +++ b/libraries/rush-daemon-protocol/src/DaemonCommandResult.ts @@ -16,6 +16,11 @@ export type DaemonCommandOutcome = 'success' | 'success-with-warning' | 'failure * @beta */ export interface IDaemonCommandResult { + /** + * Protocol 0.10: no execution or request IO occurred, and a successor has been selected. + * Retry at most once, after attested predecessor ownership release. Never infer this from an error. + */ + readonly retryAfterRestart?: true; /** Whether cancellation or disconnect was observed, even if a cleanup failure determines the outcome. */ readonly aborted: boolean; /** The typed admission failure, when execution never started. */ diff --git a/libraries/rush-daemon-protocol/src/DaemonControlKinds.ts b/libraries/rush-daemon-protocol/src/DaemonControlKinds.ts index 970c76160d..f591796345 100644 --- a/libraries/rush-daemon-protocol/src/DaemonControlKinds.ts +++ b/libraries/rush-daemon-protocol/src/DaemonControlKinds.ts @@ -17,11 +17,16 @@ export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [ 'requestStart', 'requestCancel', 'requestRejected', - 'requestResult' + 'requestResult', + 'shutdown', + 'shutdownAck', + 'stdinReady', + 'stdinEnd' ] = [ 'hello', 'helloAck', 'subscribe', 'unsubscribe', 'ping', 'pong', 'error', 'setRawMode', 'rawModeChanged', 'terminalPolicy', 'queuePosition', - 'requestStart', 'requestCancel', 'requestRejected', 'requestResult' + 'requestStart', 'requestCancel', 'requestRejected', 'requestResult', 'shutdown', 'shutdownAck', + 'stdinReady', 'stdinEnd' ]; /** The union of control message `kind` discriminants. @beta */ diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts index a351ffcedd..a21e8d422a 100644 --- a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -5,8 +5,11 @@ import type { IDaemonClientCaps } from './DaemonClientCaps'; import type { IDaemonRawModeChangedMessage, IDaemonSetRawModeMessage, + IDaemonStdinEndMessage, + IDaemonStdinReadyMessage, IDaemonTerminalPolicyMessage } from './DaemonInteractiveControl'; +import type { IDaemonShutdownAckMessage, IDaemonShutdownMessage } from './DaemonLifecycleControl'; import type { IDaemonPongMessage } from './DaemonPongMessage'; import type { DaemonProtocolErrorCode } from './DaemonProtocolError'; import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; @@ -78,8 +81,12 @@ export type DaemonControlMessage = | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage + | IDaemonShutdownMessage + | IDaemonShutdownAckMessage | IDaemonErrorMessage | IDaemonSetRawModeMessage + | IDaemonStdinEndMessage + | IDaemonStdinReadyMessage | IDaemonRawModeChangedMessage | IDaemonTerminalPolicyMessage | IDaemonRequestQueuePositionMessage diff --git a/libraries/rush-daemon-protocol/src/DaemonGraphSnapshot.ts b/libraries/rush-daemon-protocol/src/DaemonGraphSnapshot.ts new file mode 100644 index 0000000000..da05c303c8 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonGraphSnapshot.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonPhasedOperationEnabledState } from './DaemonPhasedRequest'; + +/** Experimental extension carried by the existing request event stream. @beta */ +export const RUSHD_GRAPH_SNAPSHOT: 'rushd.graph-snapshot' = 'rushd.graph-snapshot'; + +/** JSON-safe operation metadata; never contains runners, environments or terminal output. @beta */ +export interface IDaemonGraphOperation { + readonly operationId: string; + readonly projectName: string; + readonly phaseName: string; + readonly enabled: false | DaemonPhasedOperationEnabledState; + /** Latest observed execution status, or null if no execution has been observed. */ + readonly status: string | null; + readonly dependencyIds: ReadonlyArray; +} + +/** Summary of retained watcher changes, without filesystem paths. @beta */ +export interface IDaemonGraphInvalidations { + readonly sequence: number; + readonly changedPathCount: number; + readonly hasUnknownChanges: boolean; + readonly isWatcherHealthy: boolean; +} + +/** A cold workspace does not pretend to contain an empty, usable operation graph. @beta */ +export interface IDaemonUninitializedGraphSnapshot { + /** Opaque generation token, present on protocol 0.9 or newer. */ + readonly workspaceGeneration?: string; + readonly initialized: false; + readonly invalidations: IDaemonGraphInvalidations; +} + +/** Point-in-time metadata for an initialized native graph. @beta */ +export interface IDaemonInitializedGraphSnapshot { + /** Echo this token when mutating operations retained from this snapshot. */ + readonly workspaceGeneration?: string; + readonly initialized: true; + readonly operations: ReadonlyArray; + readonly status: string; + /** Manual mode applies to automatic iterations, not explicit build requests. */ + readonly pauseNextIteration: boolean; + readonly hasScheduledIteration: boolean; + readonly invalidations: IDaemonGraphInvalidations; +} + +/** Data in a {@link RUSHD_GRAPH_SNAPSHOT} extension event. @beta */ +export interface IDaemonGraphSnapshotPayload { + readonly requestId: string; + readonly snapshot: IDaemonInitializedGraphSnapshot | IDaemonUninitializedGraphSnapshot; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonInteractiveControl.ts b/libraries/rush-daemon-protocol/src/DaemonInteractiveControl.ts index 9748cbac97..4b59910563 100644 --- a/libraries/rush-daemon-protocol/src/DaemonInteractiveControl.ts +++ b/libraries/rush-daemon-protocol/src/DaemonInteractiveControl.ts @@ -3,6 +3,18 @@ import type { IDaemonTerminalPolicyResult } from './DaemonTerminalPolicy'; +/** Admits one stdin chunk, initially on attachment and again after the preceding write drains. @beta */ +export interface IDaemonStdinReadyMessage { + readonly kind: 'stdinReady'; + readonly payload: { readonly requestId: string }; +} + +/** Ends one request's stdin after all preceding stdin frames. @beta */ +export interface IDaemonStdinEndMessage { + readonly kind: 'stdinEnd'; + readonly payload: { readonly requestId: string }; +} + /** Asks the thin client to change raw mode for one active request. @beta */ export interface IDaemonSetRawModeMessage { readonly kind: 'setRawMode'; diff --git a/libraries/rush-daemon-protocol/src/DaemonInvocationKind.ts b/libraries/rush-daemon-protocol/src/DaemonInvocationKind.ts new file mode 100644 index 0000000000..d927e9afaf --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonInvocationKind.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonProtocolError } from './DaemonProtocolError'; +import { validateRequestId } from './RequestIdentifierValidation'; + +/** The native parser to use, independently of a command's built-in/custom origin. @beta */ +export type DaemonInvocationKind = 'rush' | 'rushx'; + +/** Validates the optional invocation discriminator. Omission retains legacy Rush routing. @internal */ +export function validateDaemonInvocationKind(value: unknown): void { + const kinds: ReadonlySet = new Set([undefined, 'rush', 'rushx']); + if (!kinds.has(value)) { + throw new DaemonProtocolError('malformedControlMessage', 'Request invocation kind is not recognized.'); + } +} + +/** Validates the optional opaque workspace-generation reference. @internal */ +export function validateExpectedWorkspaceGeneration(value: unknown): void { + if (value !== undefined) validateRequestId(value); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonLifecycleControl.ts b/libraries/rush-daemon-protocol/src/DaemonLifecycleControl.ts new file mode 100644 index 0000000000..31921111a2 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonLifecycleControl.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** Requests shutdown of the connected workspace endpoint after hello. @beta */ +export interface IDaemonShutdownMessage { + readonly kind: 'shutdown'; + readonly payload: Record; +} + +/** Acknowledges shutdown before the host closes connections and releases its endpoint. @beta */ +export interface IDaemonShutdownAckMessage { + readonly kind: 'shutdownAck'; + readonly payload: Record; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts index 0538bf9189..e964c89de5 100644 --- a/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts +++ b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; +import type { IDaemonWorkspaceStatus } from './DaemonWorkspaceStatus'; /** The liveness reply. @beta */ export interface IDaemonPongMessage { @@ -11,6 +12,12 @@ export interface IDaemonPongMessage { readonly daemonVersion?: string; /** The daemon wire protocol version, when reported by protocol 0.2 or newer. */ readonly protocolVersion?: IDaemonProtocolVersion; + /** The daemon process ID, when reported by protocol 0.6 or newer. */ + readonly pid?: number; + /** The process's resident memory in bytes, when reported by protocol 0.6 or newer. */ + readonly residentMemoryBytes?: number; + /** Optional generation and warm accounting; older peers may omit this snapshot. */ + readonly workspace?: IDaemonWorkspaceStatus; readonly uptimeMs: number; }; } diff --git a/libraries/rush-daemon-protocol/src/DaemonPongValidation.ts b/libraries/rush-daemon-protocol/src/DaemonPongValidation.ts new file mode 100644 index 0000000000..54fc61970e --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonPongValidation.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonControlRecord } from './ControlRecord'; +import { DaemonProtocolError } from './DaemonProtocolError'; +import { validateWorkspaceStatus } from './WorkspaceStatusValidation'; + +const ZERO: number = 0; + +function fail(field: string): never { + throw new DaemonProtocolError('malformedControlMessage', `Invalid pong field "${field}".`); +} + +export function validateDaemonPong(payload: Record): void { + validateDaemonVersion(payload.daemonVersion); + validateProtocolVersion(payload.protocolVersion); + if (typeof payload.uptimeMs !== 'number') fail('uptimeMs'); + validatePid(payload.pid); + validateResidentMemory(payload.residentMemoryBytes); + validateWorkspaceStatus(payload.workspace); +} + +function validateDaemonVersion(value: unknown): void { + if (value !== undefined && typeof value !== 'string') fail('daemonVersion'); +} + +function validateProtocolVersion(value: unknown): void { + if (value === undefined) return; + if (!isDaemonControlRecord(value)) fail('protocolVersion'); + validateVersionNumbers(value); +} + +function validateVersionNumbers(value: Record): void { + if (typeof value.major !== 'number' || typeof value.minor !== 'number') fail('protocolVersion'); +} + +function isNonnegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= ZERO; +} + +function validatePid(value: unknown): void { + if (value === undefined) return; + requirePositivePid(value); +} + +function requirePositivePid(value: unknown): void { + if (!isNonnegativeInteger(value) || value === ZERO) fail('pid'); +} + +function validateResidentMemory(value: unknown): void { + if (value !== undefined && !isNonnegativeInteger(value)) fail('residentMemoryBytes'); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts index 55b1cef471..a687dae853 100644 --- a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts @@ -10,6 +10,21 @@ export const DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR: number = 4; /** The first additive protocol minor that supports request lifecycle controls. @beta */ export const DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR: number = 5; +/** The first additive protocol minor that supports acknowledged daemon shutdown. @beta */ +export const DAEMON_LIFECYCLE_PROTOCOL_MINOR: number = 6; + +/** The first additive protocol minor supporting stdin admission and EOF. @beta */ +export const DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR: number = 7; + +/** The first additive protocol minor supporting graph generation fencing. @beta */ +export const DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR: number = 9; + +/** The first additive protocol minor supporting explicit Rush/Rushx invocation selection. @beta */ +export const DAEMON_INVOCATION_KIND_PROTOCOL_MINOR: number = 8; + +/** The first minor supporting native mutations and guaranteed pre-execution restart outcomes. @beta */ +export const DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR: number = 10; + /** * A rushd wire protocol version. * @@ -43,7 +58,7 @@ export interface IDaemonProtocolVersion { */ export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = { major: 0, - minor: DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR + minor: DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR }; /** diff --git a/libraries/rush-daemon-protocol/src/DaemonRequestEnvelope.ts b/libraries/rush-daemon-protocol/src/DaemonRequestEnvelope.ts index 90e230ca2e..9db2a24707 100644 --- a/libraries/rush-daemon-protocol/src/DaemonRequestEnvelope.ts +++ b/libraries/rush-daemon-protocol/src/DaemonRequestEnvelope.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { DaemonInvocationKind } from './DaemonInvocationKind'; import type { IDaemonRequestAdmissionOptions } from './DaemonRequestAdmission'; import type { DaemonRushCommandOrigin } from './DaemonRushCommand'; import type { DaemonTerminalRequirement } from './DaemonTerminalPolicy'; @@ -33,6 +34,8 @@ export interface IDaemonRequestTerminal { * @beta */ export interface IDaemonRequestEnvelope { + /** Required by graph mutations to reject stale client-held operation references (protocol 0.9). */ + readonly expectedWorkspaceGeneration?: string; /** Queue-and-wait behavior requested by the client integration. */ readonly admission?: IDaemonRequestAdmissionOptions; /** The original command arguments, excluding the Rush executable. */ @@ -45,6 +48,8 @@ export interface IDaemonRequestEnvelope { readonly cwd: string; /** A complete request-local environment snapshot. */ readonly environment: Readonly>; + /** Native parser selection (protocol 0.8). Omission means Rush, never an inferred package script. */ + readonly invocationKind?: DaemonInvocationKind; /** A client-generated identifier unique within this connection. */ readonly requestId: string; /** Request-local terminal capabilities. */ diff --git a/libraries/rush-daemon-protocol/src/DaemonWorkspaceStatus.ts b/libraries/rush-daemon-protocol/src/DaemonWorkspaceStatus.ts new file mode 100644 index 0000000000..d32675bd98 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonWorkspaceStatus.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** Effective runtime policy, not a hard process/tree RSS limit. @beta */ +export interface IDaemonWarmSetConfiguration { + /** Persistent host project observation; omitted by older peers. Never schedules builds. */ + readonly watch?: boolean; + readonly warmIdleTimeoutSeconds: number; + readonly warmMemoryBudgetMB: number; + readonly warmSetMaxProjects: number; + readonly autoWarmByTelemetry: boolean; +} + +/** Actual retained resources and available producer measurements. @beta */ +export interface IDaemonWarmSetStatus { + readonly configuration: IDaemonWarmSetConfiguration; + /** Stopping maintenance does not itself release graph/watcher resources. */ + readonly maintenanceState: 'running' | 'quiescing' | 'stopped' | 'failed'; + readonly maintenanceFailure?: string; + /** Highest retention priority first. */ + readonly retainedProjectNames: ReadonlyArray; + /** Raw ranking inputs for retained projects; absent on older peers. Missing measurements remain unknown. */ + readonly projectRanks?: ReadonlyArray; + readonly protectedProjectNames: ReadonlyArray; + /** Actual recursive project watchers, including pending closes. */ + readonly watchedProjectNames: ReadonlyArray; + readonly daemonResidentMemoryBytes: number; + /** Last-completion RSS samples, excluding descendants. */ + readonly measuredRunnerMemoryBytes: number; + /** Resident runners without a producer sample, not zero-memory runners. */ + readonly unmeasuredRunnerCount: number; + readonly overMemoryBudget: boolean; + readonly overProjectLimit: boolean; + readonly deferredReason: 'workspace-busy' | 'native-busy' | 'graph-busy' | 'disposed' | undefined; + readonly cleanupFailures: ReadonlyArray; +} + +/** Measured inputs to retention policy, not estimates of a project's total memory allocation. @beta */ +export interface IDaemonWarmProjectRank { + readonly projectName: string; + readonly frequency: number; + /** Monotonic daemon-local milliseconds. */ + readonly lastUsed: number; + readonly timeSavedMs?: number; + /** Present only when all resource-owning retained operations supply a measurement. */ + readonly measuredRunnerMemoryBytes?: number; +} + +/** A non-initializing snapshot of the provider's current generation. @beta */ +export interface IDaemonWorkspaceStatus { + readonly generation: number; + /** Last lifecycle tier: 0 reuse/initial, 1 successful reload, 2 requested restart. Older peers may omit it. */ + readonly lastReloadTier?: number; + /** Current installed session token; absent while no session is installed. */ + readonly generationToken?: string; + /** Graph existence, not a claim of successful execution or resident children. */ + readonly graphInitialized: boolean; + /** Absent when no warm controller is attached; absence never means measured zero memory. */ + readonly warmSet?: IDaemonWarmSetStatus; +} diff --git a/libraries/rush-daemon-protocol/src/RequestControlValidation.ts b/libraries/rush-daemon-protocol/src/RequestControlValidation.ts index a4f1bfdf0c..5f628a2cda 100644 --- a/libraries/rush-daemon-protocol/src/RequestControlValidation.ts +++ b/libraries/rush-daemon-protocol/src/RequestControlValidation.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { isDaemonControlRecord } from './ControlRecord'; +import { validateDaemonInvocationKind, validateExpectedWorkspaceGeneration } from './DaemonInvocationKind'; import { DaemonProtocolError } from './DaemonProtocolError'; import { validateRequestAdmission, validateRequestTerminal } from './RequestEnvelopeValidation'; import { validateRequestId } from './RequestIdentifierValidation'; @@ -25,6 +26,8 @@ const REJECTION_CODES: ReadonlySet = new Set([ /** Validates a request-start payload. @internal */ export function validateRequestStartControl(payload: Record): void { validateRequestId(payload.requestId); + validateDaemonInvocationKind(payload.invocationKind); + validateExpectedWorkspaceGeneration(payload.expectedWorkspaceGeneration); requireString(payload.commandName, 'requestStart payload.commandName'); if (!COMMAND_ORIGINS.has(payload.commandOrigin)) fail('Request command origin is not recognized.'); requireString(payload.cwd, 'requestStart payload.cwd'); diff --git a/libraries/rush-daemon-protocol/src/RequestLifecycleCapabilityValidation.ts b/libraries/rush-daemon-protocol/src/RequestLifecycleCapabilityValidation.ts index aeeef7c6e3..03b3d21b71 100644 --- a/libraries/rush-daemon-protocol/src/RequestLifecycleCapabilityValidation.ts +++ b/libraries/rush-daemon-protocol/src/RequestLifecycleCapabilityValidation.ts @@ -3,6 +3,19 @@ import { DaemonProtocolError } from './DaemonProtocolError'; +/** Validates optional stdin admission/EOF capability negotiation. @internal */ +export function validateInputLifecycleCapability(payload: Record): void { + if ( + payload.supportsInputLifecycle !== undefined && + typeof payload.supportsInputLifecycle !== 'boolean' + ) { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'Subscribe message payload.supportsInputLifecycle must be a boolean.' + ); + } +} + /** Validates optional request-lifecycle capability negotiation. @internal */ export function validateRequestLifecycleCapability(payload: Record): void { if ( diff --git a/libraries/rush-daemon-protocol/src/RequestResultValidation.ts b/libraries/rush-daemon-protocol/src/RequestResultValidation.ts index a973099f54..ff6c8e6fb7 100644 --- a/libraries/rush-daemon-protocol/src/RequestResultValidation.ts +++ b/libraries/rush-daemon-protocol/src/RequestResultValidation.ts @@ -3,6 +3,7 @@ import { isDaemonControlRecord } from './ControlRecord'; import { DaemonProtocolError } from './DaemonProtocolError'; +import { validateRestartResult } from './RestartResultValidation'; const ADMISSION_ERROR_CODES: ReadonlySet = new Set(['aborted', 'no-wait', 'wait-timeout']); const EMPTY_STRING_LENGTH: number = 0; @@ -12,6 +13,7 @@ export function validateRequestResultFields(payload: Record): v validateOptionalString(payload.errorMessage, 'errorMessage'); validateAdmissionErrorCode(payload.admissionErrorCode); validatePhasedResultShape(payload); + validateRestartResult(payload); } function validateAdmissionErrorCode(value: unknown): void { diff --git a/libraries/rush-daemon-protocol/src/RestartResultValidation.ts b/libraries/rush-daemon-protocol/src/RestartResultValidation.ts new file mode 100644 index 0000000000..4e98dc64f6 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/RestartResultValidation.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonProtocolError } from './DaemonProtocolError'; + +const PRE_EXECUTION_FAILURE_EXIT_CODE: number = 1; + +/** Rejects contradictory claims of guaranteed pre-execution restart. @internal */ +export function validateRestartResult(payload: Record): void { + if (payload.retryAfterRestart === undefined) return; + const expected: Readonly> = { + retryAfterRestart: true, + outcome: 'failure', + exitCode: PRE_EXECUTION_FAILURE_EXIT_CODE, + aborted: false, + admissionErrorCode: undefined, + scheduled: undefined, + operationResults: undefined + }; + if (Object.entries(expected).some(([key, value]) => payload[key] !== value)) { + throw new DaemonProtocolError( + 'malformedControlMessage', 'Restart retry requires a guaranteed pre-execution failure without operation results.' + ); + } +} diff --git a/libraries/rush-daemon-protocol/src/StatusValidation.ts b/libraries/rush-daemon-protocol/src/StatusValidation.ts new file mode 100644 index 0000000000..2a805afdd0 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/StatusValidation.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonControlRecord } from './ControlRecord'; +import { DaemonProtocolError } from './DaemonProtocolError'; + +const ZERO: number = 0; + +export function failStatus(field: string): never { + throw new DaemonProtocolError('malformedControlMessage', `Invalid workspace status field "${field}".`); +} + +export function requireStatusRecord(value: unknown, field: string): Record { + if (!isDaemonControlRecord(value) || Array.isArray(value)) failStatus(field); + return value; +} + +export function requireStatusBoolean(value: unknown, field: string): void { + if (typeof value !== 'boolean') failStatus(field); +} + +export function requireStatusText(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === ZERO) failStatus(field); +} + +export function requireStatusStrings(value: unknown, field: string): void { + if (!Array.isArray(value)) failStatus(field); + for (const entry of value) requireStatusText(entry, field); +} + +function requireFiniteNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) failStatus(field); + return value; +} + +export function requireStatusPositive(value: unknown, field: string): void { + if (requireFiniteNumber(value, field) <= ZERO) failStatus(field); +} + +export function requireStatusCount(value: unknown, field: string): asserts value is number { + const number: number = requireFiniteNumber(value, field); + if (!Number.isSafeInteger(number) || number < ZERO) failStatus(field); +} diff --git a/libraries/rush-daemon-protocol/src/WarmProjectRankValidation.ts b/libraries/rush-daemon-protocol/src/WarmProjectRankValidation.ts new file mode 100644 index 0000000000..f23506c6c4 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/WarmProjectRankValidation.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + failStatus, + requireStatusCount, + requireStatusPositive, + requireStatusRecord, + requireStatusText +} from './StatusValidation'; + +const ZERO: number = 0; + +export function validateWarmProjectRanks(value: unknown): void { + if (value === undefined) return; + for (const rank of requireRanks(value)) validateRank(rank); +} + +function requireRanks(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) failStatus('projectRanks'); + return value; +} + +function validateRank(value: unknown): void { + const rank: Record = requireStatusRecord(value, 'projectRank'); + requireStatusText(rank.projectName, 'projectName'); + requireStatusCount(rank.frequency, 'frequency'); + requireNonnegative(rank.lastUsed, 'lastUsed'); + validateSavings(rank.timeSavedMs); + validateMemory(rank.measuredRunnerMemoryBytes); +} + +function requireNonnegative(value: unknown, field: string): void { + if (value !== ZERO) requireStatusPositive(value, field); +} + +function validateSavings(value: unknown): void { + if (value !== undefined) requireNonnegative(value, 'timeSavedMs'); +} + +function validateMemory(value: unknown): void { + if (value === undefined) return; + requireStatusPositive(value, 'measuredRunnerMemoryBytes'); + requireStatusCount(value, 'measuredRunnerMemoryBytes'); +} diff --git a/libraries/rush-daemon-protocol/src/WarmSetStatusValidation.ts b/libraries/rush-daemon-protocol/src/WarmSetStatusValidation.ts new file mode 100644 index 0000000000..3442f35712 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/WarmSetStatusValidation.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + failStatus, + requireStatusBoolean, + requireStatusCount, + requireStatusPositive, + requireStatusRecord, + requireStatusStrings, + requireStatusText +} from './StatusValidation'; +import { validateWarmProjectRanks } from './WarmProjectRankValidation'; + +const STATES: ReadonlySet = new Set(['running', 'quiescing', 'stopped', 'failed']); +const DEFERRED: ReadonlySet = new Set(['workspace-busy', 'native-busy', 'graph-busy', 'disposed']); + +export function validateWarmSetStatus(value: unknown): void { + if (value === undefined) return; + const status: Record = requireStatusRecord(value, 'warmSet'); + validateConfiguration(status.configuration); + requireStatusStrings(status.retainedProjectNames, 'retainedProjectNames'); + validateWarmProjectRanks(status.projectRanks); + requireStatusStrings(status.protectedProjectNames, 'protectedProjectNames'); + requireStatusStrings(status.watchedProjectNames, 'watchedProjectNames'); + requireStatusStrings(status.cleanupFailures, 'cleanupFailures'); + requireStatusCount(status.daemonResidentMemoryBytes, 'daemonResidentMemoryBytes'); + requireStatusCount(status.measuredRunnerMemoryBytes, 'measuredRunnerMemoryBytes'); + requireStatusCount(status.unmeasuredRunnerCount, 'unmeasuredRunnerCount'); + requireStatusBoolean(status.overMemoryBudget, 'overMemoryBudget'); + requireStatusBoolean(status.overProjectLimit, 'overProjectLimit'); + validateChoice(status.maintenanceState, 'maintenanceState', STATES); + validateDeferred(status.deferredReason); + validateFailure(status.maintenanceFailure); +} + +function validateConfiguration(value: unknown): void { + const config: Record = requireStatusRecord(value, 'configuration'); + requireStatusPositive(config.warmIdleTimeoutSeconds, 'warmIdleTimeoutSeconds'); + requireStatusPositive(config.warmMemoryBudgetMB, 'warmMemoryBudgetMB'); + requireStatusPositive(config.warmSetMaxProjects, 'warmSetMaxProjects'); + requireStatusCount(config.warmSetMaxProjects, 'warmSetMaxProjects'); + requireStatusBoolean(config.autoWarmByTelemetry, 'autoWarmByTelemetry'); + if (config.watch !== undefined) requireStatusBoolean(config.watch, 'watch'); +} + +function validateChoice(value: unknown, field: string, choices: ReadonlySet): void { + requireStatusText(value, field); + if (!choices.has(value)) failStatus(field); +} + +function validateDeferred(value: unknown): void { + if (value !== undefined) validateChoice(value, 'deferredReason', DEFERRED); +} + +function validateFailure(value: unknown): void { + if (value !== undefined) requireStatusText(value, 'maintenanceFailure'); +} diff --git a/libraries/rush-daemon-protocol/src/WorkspaceStatusValidation.ts b/libraries/rush-daemon-protocol/src/WorkspaceStatusValidation.ts new file mode 100644 index 0000000000..c64e615573 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/WorkspaceStatusValidation.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { validateRequestId } from './RequestIdentifierValidation'; +import { + failStatus, + requireStatusBoolean, + requireStatusCount, + requireStatusPositive, + requireStatusRecord +} from './StatusValidation'; +import { validateWarmSetStatus } from './WarmSetStatusValidation'; + +const MAX_RELOAD_TIER: number = 2; + +export function validateWorkspaceStatus(value: unknown): void { + if (value === undefined) return; + const status: Record = requireStatusRecord(value, 'workspace'); + requireStatusCount(status.generation, 'generation'); + requireStatusPositive(status.generation, 'generation'); + validateReloadTier(status.lastReloadTier); + requireStatusBoolean(status.graphInitialized, 'graphInitialized'); + validateGenerationToken(status); + validateWarmGraph(status); + validateWarmSetStatus(status.warmSet); +} + +function validateReloadTier(value: unknown): void { + if (value === undefined) return; + requireStatusCount(value, 'lastReloadTier'); + if (value > MAX_RELOAD_TIER) failStatus('lastReloadTier'); +} + +function validateGenerationToken(status: Record): void { + if (status.graphInitialized === true || status.generationToken !== undefined) { + validateRequestId(status.generationToken); + } +} + +function validateWarmGraph(status: Record): void { + if (status.warmSet !== undefined && status.graphInitialized !== true) failStatus('warmSet'); +} diff --git a/libraries/rush-daemon-protocol/src/index.ts b/libraries/rush-daemon-protocol/src/index.ts index c5a35773cd..28384e96cf 100644 --- a/libraries/rush-daemon-protocol/src/index.ts +++ b/libraries/rush-daemon-protocol/src/index.ts @@ -13,34 +13,40 @@ */ export type { IDaemonFrame } from './DaemonFrame'; export { DaemonFrameType, isDaemonFrameType } from './DaemonFrameType'; -export { - DEFAULT_MAX_PAYLOAD_BYTES, FRAME_HEADER_BYTES, LENGTH_FIELD_BYTES, LENGTH_FIELD_OFFSET, - MAX_OPERATION_ID_BYTES, MAX_REQUEST_ID_BYTES, OPERATION_ID_LENGTH_BYTES, OPERATION_ID_LENGTH_OFFSET, - PAYLOAD_OFFSET, REQUEST_ID_LENGTH_BYTES, REQUEST_ID_LENGTH_OFFSET, TYPE_FIELD_BYTES, TYPE_FIELD_OFFSET -} from './FrameConstants'; +export { DEFAULT_MAX_PAYLOAD_BYTES, FRAME_HEADER_BYTES, LENGTH_FIELD_BYTES } from './FrameConstants'; +export { LENGTH_FIELD_OFFSET, MAX_OPERATION_ID_BYTES, MAX_REQUEST_ID_BYTES } from './FrameConstants'; +export { OPERATION_ID_LENGTH_BYTES, OPERATION_ID_LENGTH_OFFSET, PAYLOAD_OFFSET } from './FrameConstants'; +export { REQUEST_ID_LENGTH_BYTES, REQUEST_ID_LENGTH_OFFSET, TYPE_FIELD_BYTES } from './FrameConstants'; +export { TYPE_FIELD_OFFSET } from './FrameConstants'; export { encodeDaemonFrame, encodeDaemonFrames } from './FrameEncoder'; export { DaemonFrameDecoder, type IDaemonFrameDecoderOptions } from './FrameDecoder'; export { DaemonProtocolError, ProtocolVersionMismatchError } from './DaemonProtocolError'; export type { DaemonProtocolErrorCode, IDaemonProtocolErrorOptions } from './DaemonProtocolError'; -export { - DAEMON_INTERACTIVE_IO_PROTOCOL_MINOR, DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR, - DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR, - DAEMON_PROTOCOL_VERSION, - isDaemonProtocolCompatible -} from './DaemonProtocolVersion'; +export { DAEMON_GRAPH_GENERATION_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_INTERACTIVE_IO_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_INVOCATION_KIND_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_LIFECYCLE_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR, DAEMON_PROTOCOL_VERSION } from './DaemonProtocolVersion'; +export { DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR } from './DaemonProtocolVersion'; +export { isDaemonProtocolCompatible } from './DaemonProtocolVersion'; export type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; export type { IDaemonClientCaps } from './DaemonClientCaps'; export { DAEMON_CONTROL_MESSAGE_KINDS, isDaemonControlMessageKind } from './DaemonControlKinds'; export type { DaemonControlMessageKind } from './DaemonControlKinds'; export type { DaemonControlMessage, DaemonEmptyPayload } from './DaemonControlMessage'; -export type { IDaemonErrorMessage, IDaemonHelloAckMessage, IDaemonHelloMessage } from './DaemonControlMessage'; -export type { IDaemonPingMessage, IDaemonSubscribeMessage, IDaemonUnsubscribeMessage } from './DaemonControlMessage'; -export type { - IDaemonRawModeChangedMessage, - IDaemonSetRawModeMessage, - IDaemonTerminalPolicyMessage -} from './DaemonInteractiveControl'; +export type { IDaemonErrorMessage, IDaemonHelloAckMessage } from './DaemonControlMessage'; +export type { IDaemonHelloMessage } from './DaemonControlMessage'; +export type { IDaemonPingMessage, IDaemonSubscribeMessage } from './DaemonControlMessage'; +export type { IDaemonUnsubscribeMessage } from './DaemonControlMessage'; +export type { IDaemonRawModeChangedMessage, IDaemonSetRawModeMessage } from './DaemonInteractiveControl'; +export type { IDaemonStdinEndMessage, IDaemonStdinReadyMessage } from './DaemonInteractiveControl'; +export type { IDaemonTerminalPolicyMessage } from './DaemonInteractiveControl'; export type { IDaemonPongMessage } from './DaemonPongMessage'; +export type { IDaemonWarmProjectRank, IDaemonWarmSetConfiguration, IDaemonWarmSetStatus } from './DaemonWorkspaceStatus'; +export type { IDaemonWorkspaceStatus } from './DaemonWorkspaceStatus'; +export type { IDaemonShutdownAckMessage, IDaemonShutdownMessage } from './DaemonLifecycleControl'; export { isDaemonControlRecord } from './ControlRecord'; export { validateDaemonControlMessage } from './ControlMessageValidation'; export { decodeDaemonControlMessage, encodeDaemonControlMessage } from './ControlFrameCodec'; @@ -49,52 +55,41 @@ export { createDaemonHello, createDaemonHelloAck, negotiateDaemonHello } from '. export type { DaemonHandshakeOutcome } from './DaemonHandshake'; export type { DaemonJsonNull, DaemonJsonValue } from './DaemonJsonValue'; export type { DaemonCommandOutcome, IDaemonCommandResult } from './DaemonCommandResult'; -export { - MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS, - validateDaemonRequestAdmissionOptions -} from './DaemonRequestAdmission'; -export type { - DaemonRequestRejectionCode, - IDaemonRequestCancelMessage, - IDaemonRequestRejectedMessage, - IDaemonRequestResultMessage, - IDaemonRequestStartMessage -} from './DaemonRequestControl'; +export { MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS } from './DaemonRequestAdmission'; +export { validateDaemonRequestAdmissionOptions } from './DaemonRequestAdmission'; +export type { DaemonRequestRejectionCode, IDaemonRequestCancelMessage } from './DaemonRequestControl'; +export type { IDaemonRequestRejectedMessage, IDaemonRequestResultMessage } from './DaemonRequestControl'; +export type { IDaemonRequestStartMessage } from './DaemonRequestControl'; export type { IDaemonRequestEnvelope, IDaemonRequestTerminal } from './DaemonRequestEnvelope'; -export type { - DaemonRequestAdmissionErrorCode, - IDaemonRequestAdmissionOptions, - IDaemonRequestQueuePositionMessage -} from './DaemonRequestAdmission'; +export type { DaemonInvocationKind } from './DaemonInvocationKind'; +export type { DaemonRequestAdmissionErrorCode } from './DaemonRequestAdmission'; +export type { IDaemonRequestAdmissionOptions } from './DaemonRequestAdmission'; +export type { IDaemonRequestQueuePositionMessage } from './DaemonRequestAdmission'; export type { DaemonRushCommandOrigin } from './DaemonRushCommand'; -export type { - DaemonTerminalPolicyDecision, - DaemonTerminalPolicyReason, - DaemonTerminalRequirement, - IDaemonTerminalPolicyResult -} from './DaemonTerminalPolicy'; -export { - decodeDaemonStdinChunk, - encodeDaemonStdinChunk, - type IDaemonStdinChunk -} from './StdinFrameCodec'; +export { RUSHD_GRAPH_SNAPSHOT } from './DaemonGraphSnapshot'; +export type { IDaemonGraphInvalidations, IDaemonGraphOperation } from './DaemonGraphSnapshot'; +export type { IDaemonGraphSnapshotPayload, IDaemonInitializedGraphSnapshot } from './DaemonGraphSnapshot'; +export type { IDaemonUninitializedGraphSnapshot } from './DaemonGraphSnapshot'; +export type { DaemonTerminalPolicyDecision, DaemonTerminalPolicyReason } from './DaemonTerminalPolicy'; +export type { DaemonTerminalRequirement, IDaemonTerminalPolicyResult } from './DaemonTerminalPolicy'; +export { decodeDaemonStdinChunk, encodeDaemonStdinChunk, type IDaemonStdinChunk } from './StdinFrameCodec'; export { DAEMON_EVENT_TYPES, isDaemonEventType, type DaemonEventType } from './DaemonEventType'; -export type { DaemonEventPrivacy, IDaemonEventEnvelope, IDaemonEventScope, IDaemonEventSource } from './DaemonEventEnvelope'; +export type { DaemonEventPrivacy, IDaemonEventEnvelope, IDaemonEventScope } from './DaemonEventEnvelope'; +export type { IDaemonEventSource } from './DaemonEventEnvelope'; export { isDaemonEventEnvelope, validateDaemonEventEnvelope } from './DaemonEventValidation'; -export { isDaemonExtensionEventName, isRushdExtensionEventName, RUSHD_EXTENSION_NAMESPACE } from './DaemonExtensionEventName'; +export { isDaemonExtensionEventName, isRushdExtensionEventName } from './DaemonExtensionEventName'; +export { RUSHD_EXTENSION_NAMESPACE } from './DaemonExtensionEventName'; export type { DaemonExtensionEventName } from './DaemonExtensionEventName'; export { compareDaemonVerbosity, isDaemonVerbosity, type DaemonVerbosity } from './DaemonVerbosity'; export { shouldSerializeDaemonEvent } from './DaemonVerbosityFilter'; export type { DaemonDiagnosticSeverity, IDaemonDiagnosticPayload } from './DaemonVerbosityFilter'; -export { decodeDaemonEventFrame, encodeDaemonEventFrame, serializeDaemonEventForSubscription } from './DaemonEventFrameCodec'; -export type { IDaemonActivityPayload, IDaemonOperationRegisteredPayload, IDaemonOperationStatusChangedPayload } from './DaemonOperationPayloads'; +export { decodeDaemonEventFrame, encodeDaemonEventFrame } from './DaemonEventFrameCodec'; +export { serializeDaemonEventForSubscription } from './DaemonEventFrameCodec'; +export type { IDaemonActivityPayload, IDaemonOperationRegisteredPayload } from './DaemonOperationPayloads'; +export type { IDaemonOperationStatusChangedPayload } from './DaemonOperationPayloads'; export { RUSHD_OPERATION_HEADER, RUSHD_OPERATION_STREAM_CLOSED } from './DaemonRushdExtensions'; -export type { IDaemonExtensionEventPayload, IDaemonOperationHeaderPayload, IDaemonOperationStreamClosedPayload } from './DaemonRushdExtensions'; -export type { - DaemonPhasedOperationEnabledState, - IDaemonPhasedEngineShape, - IDaemonPhasedOperationResult, - IDaemonPhasedOperationSelection, - IDaemonPhasedRequest, - IDaemonPhasedRequestResult -} from './DaemonPhasedRequest'; +export type { IDaemonExtensionEventPayload, IDaemonOperationHeaderPayload } from './DaemonRushdExtensions'; +export type { IDaemonOperationStreamClosedPayload } from './DaemonRushdExtensions'; +export type { DaemonPhasedOperationEnabledState, IDaemonPhasedEngineShape } from './DaemonPhasedRequest'; +export type { IDaemonPhasedOperationResult, IDaemonPhasedOperationSelection } from './DaemonPhasedRequest'; +export type { IDaemonPhasedRequest, IDaemonPhasedRequestResult } from './DaemonPhasedRequest'; diff --git a/libraries/rush-daemon-protocol/src/test/GraphGeneration.test.ts b/libraries/rush-daemon-protocol/src/test/GraphGeneration.test.ts new file mode 100644 index 0000000000..1877d4551f --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/GraphGeneration.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { IDaemonRequestStartMessage } from '../DaemonRequestControl'; +import { MAX_REQUEST_ID_BYTES } from '../FrameConstants'; + +const OUT_OF_RANGE_INCREMENT: number = 1; + +function request(expectedWorkspaceGeneration: unknown): IDaemonRequestStartMessage { + const message: unknown = { + kind: 'requestStart', + payload: { + argv: ['daemon', 'graph', 'pause'], commandName: 'daemon', commandOrigin: 'built-in', + cwd: '/repo', environment: {}, expectedWorkspaceGeneration, requestId: 'graph', + terminal: { isTTY: false, supportsColor: false } + } + }; + return message as IDaemonRequestStartMessage; +} + +it.each([undefined, 'session-generation-token'])('round-trips generation %s', (token: unknown) => { + const message: IDaemonRequestStartMessage = request(token); + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); +}); + +it.each(['', null, true, {}, 'x'.repeat(MAX_REQUEST_ID_BYTES + OUT_OF_RANGE_INCREMENT)])( + 'rejects invalid generation %s', (token: unknown) => { + expect(() => decodeDaemonControlMessage(encodeDaemonControlMessage(request(token)))).toThrow(); + } +); diff --git a/libraries/rush-daemon-protocol/src/test/InputLifecycleControl.test.ts b/libraries/rush-daemon-protocol/src/test/InputLifecycleControl.test.ts new file mode 100644 index 0000000000..2413e653ab --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/InputLifecycleControl.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import { validateDaemonControlMessage } from '../ControlMessageValidation'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; + +const REQUEST_ID: string = 'input-request'; +const MESSAGES: readonly DaemonControlMessage[] = [ + { kind: 'stdinReady', payload: { requestId: REQUEST_ID } }, + { kind: 'stdinEnd', payload: { requestId: REQUEST_ID } } +]; + +it.each(MESSAGES)('round-trips $kind', (message: DaemonControlMessage) => { + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); +}); + +it.each(['stdinReady', 'stdinEnd'])('requires a valid request identifier for %s', (kind: string) => { + expect(() => validateDaemonControlMessage({ kind, payload: {} })).toThrow(); + expect(() => validateDaemonControlMessage({ kind, payload: { requestId: '' } })).toThrow(); + expect(() => validateDaemonControlMessage({ kind, payload: { requestId: ' invalid ' } })).toThrow(); +}); + +it.each([true, false, undefined])('accepts input capability %s', (supportsInputLifecycle) => { + expect(() => validateDaemonControlMessage({ + kind: 'subscribe', + payload: { isTTY: false, supportsInputLifecycle } + })).not.toThrow(); +}); + +it('rejects an invalid input lifecycle capability', () => { + expect(() => validateDaemonControlMessage({ + kind: 'subscribe', + payload: { isTTY: false, supportsInputLifecycle: 'yes' } + })).toThrow('supportsInputLifecycle'); +}); diff --git a/libraries/rush-daemon-protocol/src/test/InvocationKind.test.ts b/libraries/rush-daemon-protocol/src/test/InvocationKind.test.ts new file mode 100644 index 0000000000..307c1eb77f --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/InvocationKind.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; +import type { IDaemonRequestStartMessage } from '../DaemonRequestControl'; + +function request(invocationKind: unknown): IDaemonRequestStartMessage { + const message: unknown = { + kind: 'requestStart', + payload: { + argv: ['build'], commandName: 'build', commandOrigin: 'custom', + cwd: '/repo/project', environment: {}, invocationKind, requestId: 'rushx', + terminal: { isTTY: false, supportsColor: false } + } + }; + return message as IDaemonRequestStartMessage; +} + +it.each([undefined, 'rush', 'rushx'])('round-trips invocation kind %s', (kind: unknown) => { + const message: DaemonControlMessage = request(kind); + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); +}); + +it.each(['script', '', null, true, {}])('rejects invalid invocation kind %s', (kind: unknown) => { + expect(() => decodeDaemonControlMessage(encodeDaemonControlMessage(request(kind)))).toThrow( + 'Request invocation kind is not recognized.' + ); +}); diff --git a/libraries/rush-daemon-protocol/src/test/LifecycleControl.test.ts b/libraries/rush-daemon-protocol/src/test/LifecycleControl.test.ts new file mode 100644 index 0000000000..00c5363e2d --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/LifecycleControl.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; + +const PID: number = 42; +const MEMORY_BYTES: number = 1024; +const UPTIME_MS: number = 100; +const ZERO: number = 0; +const NEGATIVE: number = -1; +const FRACTION: number = 1.5; + +const MESSAGES: readonly DaemonControlMessage[] = [ + { kind: 'shutdown', payload: {} }, + { kind: 'shutdownAck', payload: {} }, + { kind: 'pong', payload: { pid: PID, residentMemoryBytes: MEMORY_BYTES, uptimeMs: UPTIME_MS } } +]; + +it.each(MESSAGES)('round-trips lifecycle message $kind', (message: DaemonControlMessage) => { + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); +}); + +it.each([ZERO, NEGATIVE, FRACTION, '42'])('rejects an invalid daemon PID %s', (pid: unknown) => { + const json: string = JSON.stringify({ kind: 'pong', payload: { pid, uptimeMs: UPTIME_MS } }); + expect(() => decodeDaemonControlMessage(new TextEncoder().encode(json))).toThrow('pid'); +}); + +it.each([NEGATIVE, FRACTION, '1024'])('rejects invalid memory %s', (residentMemoryBytes: unknown) => { + const json: string = JSON.stringify({ + kind: 'pong', + payload: { residentMemoryBytes, uptimeMs: UPTIME_MS } + }); + expect(() => decodeDaemonControlMessage(new TextEncoder().encode(json))).toThrow('residentMemoryBytes'); +}); diff --git a/libraries/rush-daemon-protocol/src/test/RestartResult.test.ts b/libraries/rush-daemon-protocol/src/test/RestartResult.test.ts new file mode 100644 index 0000000000..cce2c3a6ca --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/RestartResult.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { IDaemonCommandResult } from '../DaemonCommandResult'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; + +const FAILURE_EXIT_CODE: number = 1; +const SUCCESS_EXIT_CODE: number = 0; +const RESULT: IDaemonCommandResult = { + aborted: false, exitCode: FAILURE_EXIT_CODE, outcome: 'failure', requestId: 'restart', + retryAfterRestart: true +}; + +it('round-trips a guaranteed pre-execution restart result', () => { + const message: DaemonControlMessage = { kind: 'requestResult', payload: RESULT }; + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); +}); + +it.each([ + { retryAfterRestart: false }, { retryAfterRestart: 'yes' }, { retryAfterRestart: null }, + { outcome: 'success' }, { exitCode: SUCCESS_EXIT_CODE }, { aborted: true }, + { admissionErrorCode: 'aborted' }, { scheduled: false, operationResults: [] } +])('rejects contradictory restart result %j', (override: object) => { + const message: unknown = { kind: 'requestResult', payload: { ...RESULT, ...override } }; + expect(() => decodeDaemonControlMessage(encodeDaemonControlMessage(message as DaemonControlMessage))).toThrow(); +}); diff --git a/libraries/rush-daemon-protocol/src/test/WarmSetStatus.test.ts b/libraries/rush-daemon-protocol/src/test/WarmSetStatus.test.ts new file mode 100644 index 0000000000..a37df7aead --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/WarmSetStatus.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; + +import { INVALID_NUMBER, WARM_STATUS, ZERO, statusFrame, workspaceStatus } from './WorkspaceStatusTestData'; + +it.each([ + null, + [], + {}, + { ...WARM_STATUS, retainedProjectNames: ['a', null] }, + { ...WARM_STATUS, protectedProjectNames: 'a' }, + { ...WARM_STATUS, watchedProjectNames: [ZERO] }, + { ...WARM_STATUS, daemonResidentMemoryBytes: INVALID_NUMBER }, + { ...WARM_STATUS, measuredRunnerMemoryBytes: 'unknown' }, + { ...WARM_STATUS, unmeasuredRunnerCount: INVALID_NUMBER }, + { ...WARM_STATUS, overMemoryBudget: 'false' }, + { ...WARM_STATUS, overProjectLimit: ZERO }, + { ...WARM_STATUS, maintenanceState: 'evicted' }, + { ...WARM_STATUS, deferredReason: 'success' }, + { ...WARM_STATUS, maintenanceFailure: {} }, + { ...WARM_STATUS, cleanupFailures: [false] }, + { ...WARM_STATUS, projectRanks: {} }, + { ...WARM_STATUS, projectRanks: [{ projectName: 'a', frequency: ZERO, lastUsed: ZERO, timeSavedMs: INVALID_NUMBER }] }, + { ...WARM_STATUS, projectRanks: [{ projectName: 'a', frequency: ZERO, lastUsed: ZERO, measuredRunnerMemoryBytes: ZERO }] } +])('rejects malformed warm accounting', (warmSet: unknown) => { + expect(() => decodeDaemonControlMessage(statusFrame(workspaceStatus(warmSet)))).toThrow(); +}); + +it.each([ + 'watch', + 'warmIdleTimeoutSeconds', + 'warmMemoryBudgetMB', + 'warmSetMaxProjects', + 'autoWarmByTelemetry' +])('rejects invalid effective configuration field %s', (field: string) => { + const configuration: Record = { ...WARM_STATUS.configuration, [field]: ZERO }; + const warmSet: unknown = { ...WARM_STATUS, configuration }; + expect(() => decodeDaemonControlMessage(statusFrame(workspaceStatus(warmSet)))).toThrow(); +}); + +it.each([false, true, undefined])( + 'accepts observation policy and legacy omission: %s', + (watch: boolean | undefined) => { + const warmSet: unknown = { ...WARM_STATUS, configuration: { ...WARM_STATUS.configuration, watch } }; + const frame: Uint8Array = statusFrame(workspaceStatus(warmSet)); + expect(encodeDaemonControlMessage(decodeDaemonControlMessage(frame))).toEqual(frame); + } +); + +it.each(['workspace-busy', 'native-busy', 'graph-busy', 'disposed'])( + 'round-trips explicit deferral %s', + (deferredReason: string) => { + const warmSet: unknown = { ...WARM_STATUS, maintenanceState: 'stopped', deferredReason }; + expect(decodeDaemonControlMessage(statusFrame(workspaceStatus(warmSet)))).toMatchObject({ + payload: { workspace: { warmSet } } + }); + } +); + +it('round-trips real ranking inputs while preserving unknown measurements', () => { + const projectRanks: ReadonlyArray = [{ projectName: 'a', frequency: ZERO, lastUsed: ZERO }]; + const frame: Uint8Array = statusFrame(workspaceStatus({ ...WARM_STATUS, projectRanks })); + expect(encodeDaemonControlMessage(decodeDaemonControlMessage(frame))).toEqual(frame); +}); diff --git a/libraries/rush-daemon-protocol/src/test/WorkspaceReloadTier.test.ts b/libraries/rush-daemon-protocol/src/test/WorkspaceReloadTier.test.ts new file mode 100644 index 0000000000..cbd5e85031 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/WorkspaceReloadTier.test.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; + +import { statusFrame, workspaceStatus } from './WorkspaceStatusTestData'; + +const REUSE: number = 0; +const RELOAD: number = 1; +const RESTART: number = 2; +const INVALID_TIER: number = 3; +const NEGATIVE_TIER: number = -1; +const FRACTION: number = 0.5; + +it.each([undefined, REUSE, RELOAD, RESTART])('round-trips lifecycle tier %s', (lastReloadTier: unknown) => { + const frame: Uint8Array = statusFrame({ ...workspaceStatus(), lastReloadTier }); + expect(encodeDaemonControlMessage(decodeDaemonControlMessage(frame))).toEqual(frame); +}); + +it.each([null, false, '0', INVALID_TIER, NEGATIVE_TIER, FRACTION, Number.MAX_SAFE_INTEGER])( + 'rejects malformed lifecycle tier %s', + (lastReloadTier: unknown) => { + const frame: Uint8Array = statusFrame({ ...workspaceStatus(), lastReloadTier }); + expect(() => decodeDaemonControlMessage(frame)).toThrow(); + } +); diff --git a/libraries/rush-daemon-protocol/src/test/WorkspaceStatus.test.ts b/libraries/rush-daemon-protocol/src/test/WorkspaceStatus.test.ts new file mode 100644 index 0000000000..e184892469 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/WorkspaceStatus.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; + +import { GENERATION, INVALID_NUMBER, ZERO, statusFrame, workspaceStatus } from './WorkspaceStatusTestData'; + +it.each([undefined, { generation: GENERATION, graphInitialized: false }, workspaceStatus()])( + 'round-trips optional cold and warm workspace status', + (workspace: unknown) => { + const frame: Uint8Array = statusFrame(workspace); + expect(encodeDaemonControlMessage(decodeDaemonControlMessage(frame))).toEqual(frame); + } +); + +it.each([ + null, + [], + {}, + { ...workspaceStatus(), generation: ZERO }, + { ...workspaceStatus(), generation: INVALID_NUMBER }, + { ...workspaceStatus(), generation: Number.POSITIVE_INFINITY }, + { ...workspaceStatus(), graphInitialized: 'true' }, + { ...workspaceStatus(), generationToken: undefined }, + { ...workspaceStatus(), generationToken: '' }, + { ...workspaceStatus(), graphInitialized: false } +])('rejects malformed or inconsistent workspace status', (workspace: unknown) => { + expect(() => decodeDaemonControlMessage(statusFrame(workspace))).toThrow(); +}); diff --git a/libraries/rush-daemon-protocol/src/test/WorkspaceStatusTestData.ts b/libraries/rush-daemon-protocol/src/test/WorkspaceStatusTestData.ts new file mode 100644 index 0000000000..92188da9da --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/WorkspaceStatusTestData.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WIRE_TEXT_ENCODER } from '../DaemonWireText'; +import type { IDaemonWarmSetStatus } from '../DaemonWorkspaceStatus'; + +export const GENERATION: number = 2; +export const ZERO: number = 0; +export const INVALID_NUMBER: number = -1; +const IDLE_SECONDS: number = 300; +const BUDGET_MB: number = 512; +const MAX_PROJECTS: number = 20; +const DAEMON_MEMORY: number = 1024; + +export const WARM_STATUS: IDaemonWarmSetStatus = { + configuration: { + warmIdleTimeoutSeconds: IDLE_SECONDS, + warmMemoryBudgetMB: BUDGET_MB, + warmSetMaxProjects: MAX_PROJECTS, + autoWarmByTelemetry: true + }, + maintenanceState: 'running', + retainedProjectNames: ['a'], + protectedProjectNames: [], + watchedProjectNames: ['a'], + daemonResidentMemoryBytes: DAEMON_MEMORY, + measuredRunnerMemoryBytes: ZERO, + unmeasuredRunnerCount: GENERATION, + overMemoryBudget: true, + overProjectLimit: false, + deferredReason: undefined, + cleanupFailures: ['An optional close failed.'] +}; + +export function workspaceStatus(warmSet: unknown = WARM_STATUS): Record { + return { generation: GENERATION, generationToken: 'generation-token', graphInitialized: true, warmSet }; +} + +export function statusFrame(workspace: unknown): Uint8Array { + return WIRE_TEXT_ENCODER.encode(JSON.stringify({ kind: 'pong', payload: { uptimeMs: ZERO, workspace } })); +} diff --git a/libraries/rush-daemon-transport/README.md b/libraries/rush-daemon-transport/README.md index ce43f12231..4abb9d30ef 100644 --- a/libraries/rush-daemon-transport/README.md +++ b/libraries/rush-daemon-transport/README.md @@ -14,6 +14,10 @@ The workspace-keyed socket/pipe **transport** for the Rush daemon (`rushd`): with backpressure-aware writes and serialized async frame handlers for inbound flow control. - **PID/lockfile handling** — stale sockets and dead PIDs are detected (two-factor: PID liveness plus a connect probe) and reclaimed without manual cleanup. +- **Two-phase shutdown** — `stopAcceptingAsync()` stops admission and waits for connections while + retaining ownership. Hosts release the endpoint with `closeAsync()` after their resources have + finished disposing. A live owner prevents rebinding even when its socket has already closed; + repeated closes cannot remove a successor's endpoint. Part of the Rush 6 / rushd re-architecture: [microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894). diff --git a/libraries/rush-daemon-transport/src/DaemonListener.ts b/libraries/rush-daemon-transport/src/DaemonListener.ts index 30e55b7d63..df37a5749b 100644 --- a/libraries/rush-daemon-transport/src/DaemonListener.ts +++ b/libraries/rush-daemon-transport/src/DaemonListener.ts @@ -6,15 +6,11 @@ import * as net from 'node:net'; import type { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; import { DaemonFrameConnection } from './DaemonFrameConnection'; -import { ADDRESS_IN_USE, listenOrErrorAsync, toListenTransportError } from './DaemonListenerNet'; -import type { INetError } from './DaemonListenerNet'; -import { ensureDaemonRuntimeDir, removeDaemonArtifacts, writeDaemonLockfile } from './DaemonLockfile'; +import { listenWithReclaimAsync } from './DaemonListenerBinding'; +import { DaemonListenerLifetime } from './DaemonListenerLifetime'; +import { ensureDaemonRuntimeDir, writeDaemonLockfile } from './DaemonLockfile'; +import { assertDaemonOwnershipAvailable } from './DaemonOwnership'; import type { IDaemonPaths } from './DaemonPaths'; -import { reclaimStaleDaemonAsync } from './DaemonReclaim'; - -const FIRST_ATTEMPT: number = 0; -const RECLAIM_ATTEMPT: number = 1; - /** Options for {@link DaemonFrameListener.listenAsync}. @beta */ export interface IDaemonListenerOptions { @@ -33,17 +29,16 @@ export interface IDaemonListenerOptions { * `daemonAlreadyRunning` transport error is thrown. * @beta */ export class DaemonFrameListener { - readonly #server: net.Server; - readonly #paths: IDaemonPaths; + readonly #lifetime: DaemonListenerLifetime; private constructor(server: net.Server, paths: IDaemonPaths) { - this.#server = server; - this.#paths = paths; + this.#lifetime = new DaemonListenerLifetime(server, paths); } /** Binds the socket/pipe path and writes the PID lockfile. */ public static async listenAsync( paths: IDaemonPaths, options: IDaemonListenerOptions ): Promise { + assertDaemonOwnershipAvailable(paths.lockfilePath); const server: net.Server = net.createServer((socket: net.Socket) => { options.onConnection(new DaemonFrameConnection(socket)); }); @@ -51,50 +46,30 @@ export class DaemonFrameListener { await listenWithReclaimAsync(server, paths); // Lockfile after bind: a pre-existing stale record must read as dead, not // as a live owner that would make reclaim refuse. - writeDaemonLockfile(paths.lockfilePath, { - pid: process.pid, - protocolVersion: options.protocolVersion, - startedAt: options.startedAt ?? new Date().toISOString(), - socketPath: paths.socketPath - }); + try { + writeListenerLockfile(paths, options); + } catch (error) { + await new DaemonListenerLifetime(server, paths).stopAcceptingAsync(); + throw error; + } return new DaemonFrameListener(server, paths); } /** Stops accepting connections and releases the socket/pipe and lockfile. */ - public async closeAsync(): Promise { - await new Promise((resolve: () => void) => this.#server.close(() => resolve())); - removeDaemonArtifacts(this.#paths.lockfilePath, this.#paths.socketPath); + public closeAsync(): Promise { + return this.#lifetime.closeAsync(); } -} - - -async function listenWithReclaimAsync(server: net.Server, paths: IDaemonPaths): Promise { - for (let attempt: number = FIRST_ATTEMPT; attempt <= RECLAIM_ATTEMPT; attempt++) { - const bound: boolean = await tryListenOnceAsync(server, paths, attempt); - if (bound) { - return; - } + /** Stops accepting clients and awaits existing connections while retaining daemon ownership. */ + public stopAcceptingAsync(): Promise { + return this.#lifetime.stopAcceptingAsync(); } } -async function tryListenOnceAsync( - server: net.Server, - paths: IDaemonPaths, - attempt: number -): Promise { - const error: INetError | undefined = await listenOrErrorAsync(server, paths.socketPath); - return error ? recoverFromListenErrorAsync(error, paths, attempt) : true; -} - -async function recoverFromListenErrorAsync( - error: INetError, - paths: IDaemonPaths, - attempt: number -): Promise { - const canReclaim: boolean = error.code === ADDRESS_IN_USE && attempt === FIRST_ATTEMPT; - if (!canReclaim) { - throw toListenTransportError(error, paths.socketPath); - } - await reclaimStaleDaemonAsync(paths); - return false; +function writeListenerLockfile(paths: IDaemonPaths, options: IDaemonListenerOptions): void { + writeDaemonLockfile(paths.lockfilePath, { + pid: process.pid, + protocolVersion: options.protocolVersion, + startedAt: options.startedAt ?? new Date().toISOString(), + socketPath: paths.socketPath + }); } diff --git a/libraries/rush-daemon-transport/src/DaemonListenerBinding.ts b/libraries/rush-daemon-transport/src/DaemonListenerBinding.ts new file mode 100644 index 0000000000..77885fa935 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonListenerBinding.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as net from 'node:net'; + +import { ADDRESS_IN_USE, listenOrErrorAsync, toListenTransportError } from './DaemonListenerNet'; +import type { INetError } from './DaemonListenerNet'; +import type { IDaemonPaths } from './DaemonPaths'; +import { reclaimStaleDaemonAsync } from './DaemonReclaim'; + +const FIRST_ATTEMPT: number = 0; +const RECLAIM_ATTEMPT: number = 1; + +export async function listenWithReclaimAsync(server: net.Server, paths: IDaemonPaths): Promise { + for (let attempt: number = FIRST_ATTEMPT; attempt <= RECLAIM_ATTEMPT; attempt++) { + const bound: boolean = await tryListenOnceAsync(server, paths, attempt); + if (bound) { + return; + } + } +} + +async function tryListenOnceAsync( + server: net.Server, + paths: IDaemonPaths, + attempt: number +): Promise { + const error: INetError | undefined = await listenOrErrorAsync(server, paths.socketPath); + return error ? recoverFromListenErrorAsync(error, paths, attempt) : true; +} + +async function recoverFromListenErrorAsync( + error: INetError, + paths: IDaemonPaths, + attempt: number +): Promise { + const canReclaim: boolean = error.code === ADDRESS_IN_USE && attempt === FIRST_ATTEMPT; + if (!canReclaim) { + throw toListenTransportError(error, paths.socketPath); + } + await reclaimStaleDaemonAsync(paths); + return false; +} diff --git a/libraries/rush-daemon-transport/src/DaemonListenerLifetime.ts b/libraries/rush-daemon-transport/src/DaemonListenerLifetime.ts new file mode 100644 index 0000000000..ee6f22141b --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonListenerLifetime.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as net from 'node:net'; + +import { removeDaemonArtifacts } from './DaemonLockfile'; +import type { IDaemonPaths } from './DaemonPaths'; + +export class DaemonListenerLifetime { + readonly #paths: IDaemonPaths; + readonly #server: net.Server; + #closePromise: Promise | undefined; + #stopPromise: Promise | undefined; + + public constructor(server: net.Server, paths: IDaemonPaths) { + this.#server = server; + this.#paths = paths; + } + + public stopAcceptingAsync(): Promise { + this.#stopPromise ??= new Promise((resolve) => this.#server.close(() => resolve())); + return this.#stopPromise; + } + + public closeAsync(): Promise { + this.#closePromise ??= this.#closeOnceAsync(); + return this.#closePromise; + } + + async #closeOnceAsync(): Promise { + await this.stopAcceptingAsync(); + removeDaemonArtifacts(this.#paths.lockfilePath, this.#paths.socketPath); + } +} diff --git a/libraries/rush-daemon-transport/src/DaemonOwnership.ts b/libraries/rush-daemon-transport/src/DaemonOwnership.ts new file mode 100644 index 0000000000..2f54cac96e --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonOwnership.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonProcessAlive, readDaemonLockfile } from './DaemonLockfile'; +import type { IDaemonLockfile } from './DaemonLockfile'; +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +export function assertDaemonOwnershipAvailable(lockfilePath: string): void { + const owner: IDaemonLockfile | undefined = readDaemonLockfile(lockfilePath); + if (owner && isDaemonProcessAlive(owner.pid)) { + throw new DaemonTransportError( + DaemonTransportErrorCode.daemonAlreadyRunning, + `Daemon process ${owner.pid} still owns ${lockfilePath}; wait for shutdown cleanup to finish.` + ); + } +} diff --git a/libraries/rush-daemon-transport/src/test/ListenerOwnership.test.ts b/libraries/rush-daemon-transport/src/test/ListenerOwnership.test.ts new file mode 100644 index 0000000000..567f515710 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/ListenerOwnership.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; + +import { DaemonFrameListener } from '../DaemonListener'; +import { readDaemonLockfile } from '../DaemonLockfile'; +import type { IDaemonPaths } from '../DaemonPaths'; +import { DaemonTransportErrorCode } from '../DaemonTransportError'; + +import { createTestDaemonPaths } from './TestDaemonFixture'; + +function listenAsync(paths: IDaemonPaths): Promise { + return DaemonFrameListener.listenAsync(paths, { + onConnection: () => undefined, + protocolVersion: DAEMON_PROTOCOL_VERSION + }); +} + +it('retains ownership after stopping until close explicitly releases it', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const listener: DaemonFrameListener = await listenAsync(paths); + try { + await listener.stopAcceptingAsync(); + expect(readDaemonLockfile(paths.lockfilePath)?.pid).toBe(process.pid); + await expect(listenAsync(paths)).rejects.toMatchObject({ + code: DaemonTransportErrorCode.daemonAlreadyRunning + }); + } finally { + await listener.closeAsync(); + } + expect(readDaemonLockfile(paths.lockfilePath)).toBeUndefined(); +}); + +it('does not remove a successor endpoint when the old listener closes twice', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const original: DaemonFrameListener = await listenAsync(paths); + await original.closeAsync(); + const successor: DaemonFrameListener = await listenAsync(paths); + try { + await original.closeAsync(); + expect(readDaemonLockfile(paths.lockfilePath)?.pid).toBe(process.pid); + await expect(listenAsync(paths)).rejects.toMatchObject({ + code: DaemonTransportErrorCode.daemonAlreadyRunning + }); + } finally { + await successor.closeAsync(); + } +}); diff --git a/libraries/rush-daemon-transport/src/test/ListenerStartupFailure.test.ts b/libraries/rush-daemon-transport/src/test/ListenerStartupFailure.test.ts new file mode 100644 index 0000000000..2255f8724d --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/ListenerStartupFailure.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; + +import { connectDaemonAsync } from '../DaemonConnector'; +import { DaemonFrameListener } from '../DaemonListener'; +import * as daemonLockfile from '../DaemonLockfile'; +import type { IDaemonPaths } from '../DaemonPaths'; +import { DaemonTransportErrorCode } from '../DaemonTransportError'; + +import { createTestDaemonPaths } from './TestDaemonFixture'; + +const FAILURE_MESSAGE: string = 'Unable to write daemon ownership'; + +it('closes its bound endpoint when publishing ownership fails', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const writeLockfile: jest.SpyInstance = jest.spyOn(daemonLockfile, 'writeDaemonLockfile'); + writeLockfile.mockImplementationOnce(() => { throw new Error(FAILURE_MESSAGE); }); + try { + await expect(DaemonFrameListener.listenAsync(paths, { + onConnection: () => undefined, + protocolVersion: DAEMON_PROTOCOL_VERSION + })).rejects.toThrow(FAILURE_MESSAGE); + await expect(connectDaemonAsync(paths.socketPath)).rejects.toMatchObject({ + code: DaemonTransportErrorCode.connectionRefused + }); + expect(daemonLockfile.readDaemonLockfile(paths.lockfilePath)).toBeUndefined(); + } finally { + writeLockfile.mockRestore(); + } +}); diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index a47a6f99d3..833c57cbdf 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -8,6 +8,31 @@ The package provides an opt-in `rushd` executable. Run it from a Rush workspace for the nearest `rush.json`; it does not change the default behavior of `rush`, `rushx`, or `rush-pnpm`. +Embedded hosts can opt into automatic shutdown with `idleTimeoutSeconds`. The timeout starts after +readiness and resets after the last pending request finishes, including resolution, queueing, execution, +output drain, and cleanup. An idle connection does not keep the daemon alive. Omitting the option keeps +the existing unlimited lifetime; invalid, nonpositive, or overflowing timeouts are rejected before startup. +The host's `closed` promise signals completion of shutdown, including idle shutdown, and `closeAsync()` +reports cleanup failures. `serveRushDaemonAsync()` returns after either idle shutdown or its shutdown signal. + +Protocol 0.6 management clients can stop the host through the workspace transport rather than signaling a PID +read from disk. The host requires a lifecycle-capable hello, drains `shutdownAck` before beginning shutdown, +then cancels outstanding requests and disposes the resolver, workspace, and endpoint through its normal close +path. A connection closing is not by itself proof that endpoint cleanup has finished: restarting clients must +wait for transport ownership to be released before starting a successor. Ownership is retained until resolver +and workspace disposal both succeed; cleanup failures retain the live owner's lock and are reported rather +than allowing a successor to overlap still-resident engine state. Ping responses include the live PID +and resident memory; they do not claim that the loaded projects have a warm operation graph. + +Request-owned child joins and registered resource disposers participate in the same ownership barrier. +A failed join is recorded as a sticky, typed workspace failure before its command result drains. Queued and +new workspace work, background maintenance, and generation reload cannot clear that failure. Shutdown still +attempts independent resource cleanup, but retains the listener and ownership record if any cleanup failed; +the closing listener refuses new sessions and keeps the failed standalone owner alive rather than letting it +exit naturally and become reclaimable over unjoined children. `closeAsync()` and an attempted +`restartCompleted` reject. A transport/output failure or a spawn failure whose child never started does not +by itself poison resource ownership after successful cleanup. + The host loads `RushConfiguration` once before signaling readiness and keeps a headless file watcher active for the daemon lifetime. Its invalidation tracker retains changes while no clients are connected so a later request can reconcile them. The tracker starts with a conservative unknown @@ -25,8 +50,370 @@ or integration-classified plugin graph inputs fail closed with `WorkspaceEngineR the input baseline advances or the invalidation is acknowledged. The startup watcher-registration boundary has no paths to classify and therefore remains a full invalidation. The routing layer must replace the complete workspace session rather than run a stale graph. -The default daemon executable does not construct or route this graph while the command-independent plugin shape and per-iteration runner -lifetime tracked by [rushstack#5895](https://github.com/microsoft/rushstack/issues/5895) remain incomplete. +The default daemon executable composes `ProductionDaemonRequestResolver` with native Rushx handling via +`RushDaemonRequestResolver`. Its first supported workspace build binds a real all-project graph lazily, +without replacing the session watcher or discarding retained invalidations. Rushx scripts do not construct +a workspace graph. Embedded hosts can install the composite explicitly; omitting a resolver from +`RushDaemonHost` retains the unsupported build behavior. + +### Bounded native engine integration + +`PhasedCommandEngine` in `rush-lib` parses native `build` and `rebuild` commands without invoking CLI execution, +initializing `.env`, changing the process working directory, or mutating `process.env`. Graph preparation reuses +`PhasedScriptAction`'s standard operation, sharding, shell-runner, validation, cache/legacy-skip, and situational +plugin pipeline. It does not launch a Rush CLI subprocess. The graph includes every project, and native +`SelectionParameterSet` results are applied at request time. In particular, `--only` and the impacted-project +selectors do not accidentally enable omitted dependencies; `--include-phase-deps` explicitly expands them. + +The host uses stable fingerprints to classify native requests: + +| Tier | Inputs | Action | +| --- | --- | --- | +| 0 | Unchanged definitions/parameters, or ordinary project source changes | Retain session, graph, plugins, and completed records; reconcile operation inputs | +| 1 | Rush/project configuration, effective rig/inherited settings, command shape, or unhealthy invalidation tracking | Drain the old generation, dispose it, and construct a new session and real graph in the same process | +| 2 | Environment, installed dependency state, implementation content, or selected Rush version | Drain request results (typed retry only for unstarted work), release old ownership, and launch a genuinely available matching successor; an eligible client may retry once | + +Configuration fingerprints use contents rather than timestamps. Runtime content hashes are cached only behind +file identity/size/mtime/ctime checks; touching unchanged content does not itself change a fingerprint. +Native dispatch first copies the envelope and normalizes only engine-owned `_RUSH_LIB_PATH` to this daemon's +real engine, preventing false restarts or wrong SDK selection from a foreign client path. All other environment +inputs remain unchanged and are checked normally. +Compatible selections reuse the same graph and records. An unchanged successful build schedules no work; rebuild +still invalidates the graph on each request. Every execution refreshes operation inputs under its native lease. + +A generation lease spans resolution through final output. Reload also takes exclusive workspace admission and +the native preparation lock, discards paused prepared work, and awaits old runner/plugin/watcher cleanup before +publishing the replacement. The initiating request atomically downgrades its admission so another reload cannot +dispose the newly selected graph before it runs. Watch requests are cancelled and drained before their generation +is replaced. Server-side re-resolution is limited to races detected before scheduling and before attempting a +terminal result. Protocol 0.10's separate client retry requires an explicit pre-execution +`retryAfterRestart: true` result and the safeguards described below; it never replays started work. + +This integration supports Git-backed workspaces with direct, inherited, or rig-based project configuration and ordinary native phases. +Engine configuration snapshots use private native configuration-file loaders and non-caching rig resolution, including +the normal native inheritance merge and schema validation. Git selectors likewise read request-owned ignore-glob +configuration. The engine does not clear, read, or populate the process-wide project/rig configuration caches. +Before each iteration, it reloads effective project configuration under the native execution lease and compares +the graph/cache settings with the construction snapshot. Changed inherited or rig-provided settings trigger a +generation reload before execution, even outside watcher roots or in ignored `node_modules` files. +The retained graph and its cache policy are never patched in place. + +External Rush plugins, `.env` initialization, watch/install/variant +and diagnostic-directory options, build event-hook scripts (unless explicitly ignored), and arbitrary global +commands are rejected by the phased path, not silently bypassed. Native Rushx is handled separately below. +For phased commands, a changed request environment requires a new process, including Rush/cache +policy variables. These restrictions remain until the corresponding initialization, +environment, and resource-lifetime contracts are request-scoped. + +The native Rush lock is held only during graph preparation and each coalesced iteration, not while the warm daemon +is idle. `acquireExecutionLeaseAsync` is an optional engine/session hook invoked once by the batch coordinator, +before input reconciliation. Compatible clients share that lease rather than contending independently. It remains +held through operation execution, runner cleanup, and every participant's output/input cleanup; the batch barrier +releases it before any final command result is published. Thus ordinary native actions and permanent `--no-daemon` +fallback can run immediately after a completed warm request without stopping the daemon. + +A real native command holding the lock causes preparation or execution to be refused; there is no lock bypass or +automatic retry. A later explicit request can retry after contention ends, including contention during the first +engine initialization. A dirty native lock left by another command invalidates retained successes so the native +incremental/cache pipeline can reconcile possibly changed ignored outputs. Installation validity is also checked on +every snapshot refresh. Disposal stops new leases, awaits an outstanding lease, then aborts the graph lifetime and awaits +runner/provider cleanup. The existing operation-completion cleanup is unchanged. + +### Process restart and isolated install/update + +`serveRushDaemonAsync` supplies a successor selector for bundled or cached compatible +daemon installations pinning the exact requested Rush engine. The client can prepare +an installation using native Rush package-install APIs; the host does not install +packages during restart. Selection probes foreign runtimes in isolation, and launch +rechecks the actual engine version and protocol before binding. An unavailable or +incompatible installation is never impersonated by the bundled engine. +Embedded `RushDaemonHost` users can provide `getSuccessorLaunchAsync`, returning the existing core +`IDaemonStartCommand` plus the expected daemon implementation version. Selection is validated before shutdown; +an unavailable selected Rush version fails explicitly and is never run by the current engine under a false version. +The default entrypoint supports the `rush.json` version, not a separate preview-version namespace. + +Successor startup reuses `connectOrStartDaemonAsync`: acknowledged old ownership must be released after all old +resources finish, startup is serialized with ordinary clients, and hello/ping readiness attests a different PID. +`restartCompleted` reports completion or failure. + +Protocol 0.10 (`DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR`) provides bounded, typed retry authorization. +Only a pre-execution command result may carry `retryAfterRestart: true`. During a planned restart, accepted +queued requests drain those typed results before disconnect rather than being reduced to ambiguous connection +loss. The client's `executeWithDaemonRestartAsync` waits for old ownership release and a validated successor, +then retries an eligible request **at most once**. Command input/output or cancellation prevents retry, +even with the typed flag. Error text, a changed PID, or connection loss never authorizes replay. +Ordinary shutdown and disconnect retain cancellation semantics. + +Positively identified built-in `install` and `update` requests execute in `NativeMutationWorker`, a single-shot +native Rush parser process owned by `GlobalCommandExecutionContext`. This is not the phased warm engine. +Native arguments, policies, hooks, stdin/EOF, output and numeric exit status are preserved. Even a failed mutation +may have changed files: its exact result is drained before old generation cleanup and successor startup. +Post-mutation state selects the successor. If the result cannot be drained or the selected version cannot be +launched, the host stops without silently starting an incorrect successor. A mutation that started is never +replayed, even after failure; an unstarted request can retry only through the typed pre-execution contract above. +An ordinary nonzero mutation result still permits restart once its resources have joined. A failed worker +join is different: its failure result drains, but the sticky workspace ownership barrier forbids both the +host's successor and a competing client's auto-start, even after the result connection closes. + +The opt-in CLI forwards positively identified built-in `install` and `update` only to peers supporting protocol +0.10. Other administrative commands remain native; Rushx script names are not reinterpreted as Rush built-ins. +Client-originated graph-reference fencing uses the protocol's generation token; operation names alone cannot +identify which snapshot a client previously observed. Graph controls do not migrate a prepared iteration across +a generation replacement. + +Resolver composition uses the optional `IDaemonRequestResolver.workspaceLifecycle` capability, not an +`instanceof` check. A composite delegates native inspection but must wrap every generation replacement too: + +```ts +this.workspaceLifecycle = wrapWorkspaceResolverLifecycle( + phasedResolver, + (replacement) => new RushDaemonRequestResolver(replacement) +); +``` + +The helper returns `undefined` for a delegate without lifecycle support. Explicit `invocationKind: "rushx"` +requests retain a generation lease but go directly to the composite resolver, without native build/mutation/graph +interception or phased environment matching. They use exclusive global admission. The host disposes each old +resolver before replacing its session, and disposes the current resolver at shutdown; the composite must forward +its normal disposer to its owned delegates. + +**Client integration boundary:** the resolver requires `commandOrigin: "built-in"` for native +`build`/`rebuild`. The standalone client identifies these workspace commands while leaving +`rushx build` and other script invocations custom. The resolver also validates the native parsed +action; identical script names alone never authorize a workspace build. + +### Explicit persistent Node operations + +Persistent tools are a separate, false-default execution opt-in, not a side effect of `daemon.watch` +or `autoWarmByTelemetry`. Set `daemon.usePersistentIpcRunners: true` in `rush.json` (or +`RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS=1`) and declare a Node launcher for each eligible operation: + +```json +{ + "operationSettings": [ + { + "operationName": "_phase:compile", + "daemonIpc": { + "entryPoint": "tools/ipc/build.cjs", + "args": ["--mode", "development"] + } + } + ] +} +``` + +The descriptor belongs in the project's `config/rush-project.json`; inherited and rig-provided descriptors +still resolve relative to the consuming **project root**, not the configuration file. Both opt-ins are required. +The actual selected Node executable (`process.execPath`) starts the entrypoint directly with `shell: false`, +including on Windows. Descriptor args and non-ignored native custom parameter tokens are passed as raw argv; +quotes, spaces and shell metacharacters are not parsed or expanded. Native cwd, environment, IPC stdio and +process ownership are preserved. No shell string is rewritten to obtain a launcher. + +Only unsharded incremental daemon builds use this path. Rebuild, ordinary/native fallback, empty/missing +canonical scripts, and preassigned runners (including shard/collator and architectural NoOp nodes) retain their +native behavior. Existing watch-only `:ipc` declarations and the graph's `isWatch` setting are unchanged. +The existing `--no-ipc` is honored when the native command registers it. IPC runners remain **non-cacheable**, +as in native watch mode; this is an explicit execution/cache-policy choice. Their hash still uses the native +canonical command and non-ignored custom parameters, not an invented command identity. + +The entrypoint must be a `.js`, `.cjs` or `.mjs` file in a dedicated implementation subdirectory. That directory's +complete contents, names and physical identity are fingerprinted, bounded to 256 entries, 16 nested directory +levels and 8 MiB. Links/special files inside the implementation tree and oversized trees fail explicitly. Keep build inputs and outputs +outside it. Changes to descriptors, entrypoint code or other files inside this implementation tree replace the +generation and join the old child before another run. Unchanged content, metadata touches, and ordinary inputs +outside the tree retain warm reuse. This is **not arbitrary module-closure tracking**: imports outside that tree, +other than Node built-ins, are unsupported. Bundle third-party implementation code into the dedicated tree. + +The tool must implement the existing Node IPC contract: announce `sync`, execute only when sent `run`, +finish stdout/stderr writes before `after-execute`, and join its work and exit when sent `exit`. An explicit tool +that exits without IPC readiness fails without native fallback or replay. `WatchLoop.runIPCAsync()` can supply +this contract when included in the tool's implementation bundle. Its completion RSS is an actual process sample, +not a descendant-memory estimate. Only requested executions establish cold/reused timing and frequency; the +daemon never runs additional work just to collect telemetry. Unchanged builds do not send another `run`. + +`warmSet.projectRanks` in ordinary CLI status exposes optional raw ranking inputs (request frequency, monotonic +recency, measured savings and child RSS), allowing the ordering to be independently inspected. Missing samples +remain absent. Proven native `NullOperationRunner` nodes are excluded from child-resource score inputs, not +from graph/results or daemon RSS; a custom runner returning NoOp is not assumed resource-free. + +### Warm-set generation attachment (WS3) + +`WorkspaceSession` automatically owns the warm controller for each real graph and +`WorkspaceSessionFileWatcher`, using the effective `rush.json`/environment settings. Both lazy native +initialization and eagerly supplied components attach after watcher startup and before the first iteration. +An integration-supplied controller is adopted, not duplicated. Custom watchers or graphs without native +result-eviction support remain explicitly unaccounted rather than reporting a fictitious warm set. + +Embedded integrations can still use `WorkspaceWarmSet.attach(options)` directly with a **real, already-created** +graph and an **already-started** watcher. Capture that generation's native execution lease callback and use +the same workspace scheduler that admits phased/global requests and graph mutations: + +```ts +const acquireExecutionLeaseAsync = engine.acquireExecutionLeaseAsync; +if (!acquireExecutionLeaseAsync) throw new Error('The native engine must provide execution ownership.'); +const warmSet = WorkspaceWarmSet.attach({ + operationGraph: engine.operationGraph, + configuration: resolvedDaemonConfiguration, + scheduler: getWorkspaceRequestScheduler(session), + acquireExecutionLeaseAsync, + watcher: generationWatcher, + onDiagnostic: reportWarmDiagnostic +}); +``` + +`getWorkspaceRequestScheduler` is the existing package-internal helper in `WorkspaceRequestAdmission.ts`. +The configuration is the existing resolved `rush.json`/environment configuration; `updateConfiguration()` also +validates and applies policy changes at runtime. Dispose the controller **before** its generation's engine and +watcher, outside outstanding request leases. Controller disposal stops its timer and awaits maintenance; +it does not dispose resources owned by the generation. The default session performs this ownership sequence +automatically, including for component-owned instances of the concrete file watcher. + +`quiesceWarmSetAsync()` is a one-way generation barrier: it stops the current controller, waits for pending +initialization, and disposes any controller returned late before completing. Quiescing a cold session prevents +later initialization from installing an active controller behind that barrier. Existing initialized graphs may +still finish admitted work; generation reload owns their disposal. Reload quiesces **before** taking workspace +and native preparation locks, so those locks cannot deadlock an in-flight maintenance lease. Late cleanup and +native lease-release failures remain sticky and block replacement; an optional project eviction failure still +preserves its records and diagnostics without failing an otherwise successful build. + +| Policy | Runtime behavior | +| --- | --- | +| `watch` | Retains host observation of requested warm projects between requests when true. False (the default) keeps root/config guards only. Never schedules builds. | +| `warmIdleTimeoutSeconds` | Expires unused project runners, watchers and retained results after requests finish. Unchanged requests refresh recency too. | +| `warmSetMaxProjects` | Retains the highest-ranked idle projects within the limit; executing/prepared and explicitly protected work is exempt. | +| `warmMemoryBudgetMB` | Attempts idle eviction under sampled daemon-plus-measured-child RSS pressure. Never treats cache files as memory or claims a hard RSS ceiling. | +| `autoWarmByTelemetry` | Promotes already-requested high-value work instead of pure LRU. Never schedules or executes speculative scripts. | + +One deterministic best-first comparator is shared by retention and reverse-order eviction. With complete +measurements it uses `(timeSavedMs * requestFrequency) / residentMemoryBytes`, then recency, then ordinal project +name. Measured entries precede the missing-data bucket; that bucket uses LRU and the same name tie-break. +Without telemetry mode the entire order is LRU. Savings compare actual cold and reused execution stopwatches +(or native non-cached duration versus cache-restoration duration); no startup cost or RSS is invented. +`operation-graph`'s existing `WatchLoop` now reports its own measured RSS in an optional IPC completion field. +The native IPC runner accepts that sample and exposes it only while resident. Old children and unsupported +runners remain explicitly unmeasured. These are last-completion process samples, not live measurements of +descendants. Shell-runner records/watchers live within daemon RSS and have no fabricated per-project allocation. + +Maintenance acquires **exclusive, no-wait workspace admission**, then native repository ownership. It defers on +contention or an executing/prepared graph without cancelling, discarding or mutating that work. Optional +`getProtectedOperations()` protects additional generation-owned resources; update that protection under the +same scheduler. Maintenance awaits `closeRunnersAsync`, confirms that runners no longer report active resources, +awaits project watcher closure, and only then calls guarded native `deleteResults()`. Its `beforeDeleteResults` +hook releases native cache/skip plugin scratch state; deletion also detaches old iteration contexts/record edges +while preserving survivors' hashes, timing, warnings and status. Graph +definitions, enabled selections and disk caches are unchanged. The native per-iteration `shouldRunnerPersist` +policy is deliberately left intact: optional footprint cleanup must not turn successful requested work into a +failed build merely because an optimization could not release resources. + +The default session starts with permanent root and Rush/subspace configuration observation and +`projectNames: []`, not recursive watchers for every cold project. With `watch: true`, requested projects are +observed during planning and between requests; idle eviction removes their observation. With `watch: false`, +host project observation is disabled, but retained runners and execution results are not discarded merely +because observation is off. **Every native request must still refresh its +input snapshot and revalidate effective direct/rig/inherited configuration**, including files outside watcher +roots. Cold source changes therefore rebuild correctly; changed graph configuration fails closed until the +generation owner supplies a freshly constructed engine. A same-PID soft reload replaces the controller, +watcher, graph and session together; controller history never migrates across generations. + +Changing observation policy uses the same idle maintenance leases. Enabling it restores observation of eligible +retained projects without running scripts; disabling it awaits project watcher closure without closing runners +or deleting results. Executing/prepared graphs and protected projects defer teardown, and failed/pending closes +remain visible in status and diagnostics. This flag controls only the host's project file observation, not +watchers inside retained runner processes, native Rush watch mode, or an autonomous build loop. +Previously project observation ran regardless of the inactive flag. Honoring its existing default `false` +intentionally lowers background observation; set `watch: true` to retain that observation between requests. + +`getStatus()` reports actual retained/protected projects, daemon RSS, measured child RSS, unmeasured runners, +remaining pressure, maintenance deferral and failed cleanup. Diagnostics go to `onDiagnostic` (or a process +warning). Failed cleanup keeps records and truthful resource accounting, and cannot falsify a command result. +Deferred project-cap cleanup remains visible in status without warning before idle maintenance can run. +Memory pressure and limits that remain after an idle cleanup attempt still produce diagnostics. +Releasing records does not force V8/allocator RSS to shrink. If remaining daemon memory, active/protected work, +or cleanup failures cannot fit the budget, pressure remains reported instead of claiming success. + +### Read-only generation and warm status + +Daemon `pong` replies (and the existing JSON `daemon status` output) include an optional `workspace` snapshot. +`RushDaemonHost.workspaceStatus` exposes the same synchronous view. It reads the provider's installed session +and opaque generation token without calling `getSessionAsync()`, preparing a graph, scheduling work, or waiting +for lifecycle/workspace/native locks. During old-generation cleanup it reports that installed generation; +while a replacement session is being constructed the token is absent. The token matches graph fencing tokens. +The shared pong/host snapshot reads `WorkspaceRequestLifecycle.lastReloadTier` live: `0` initially or after +reuse, `1` after a successful in-process reload, and `2` when a hard/mutation restart is requested. A host +without that lifecycle reports `0`. Status reads never update the tier or infer it from generation/PID changes; +the tier is not a command-success or successor-readiness signal. Older peers may omit the field. + +| Field | Meaning | +| --- | --- | +| `generation`, `generationToken` | Provider generation counter and current installed session identity; neither implies a graph or successful build. | +| `lastReloadTier` | Lifecycle-owned tier: `0` initial/reuse, `1` successful reload, `2` requested restart. | +| `graphInitialized` | Whether that session has a materialized operation graph. | +| `warmSet` | Absent when no controller is attached, not a claim of zero memory. | +| `warmSet.configuration` | The effective `watch` flag and four warm-resource knobs; older peers may omit `watch`. | +| `maintenanceState`, `maintenanceFailure` | Running, quiescing, stopped, or failed maintenance; stopping maintenance alone does not free graph/watcher resources. | +| `retainedProjectNames`, `protectedProjectNames`, `watchedProjectNames` | Actual retained projects, additional protection and still-resident project observation, including pending close. | +| RSS, unmeasured count and pressure fields | Sampled daemon/child memory and outstanding limits, with unknown child memory explicitly distinguished from zero. | +| `cleanupFailures`, `deferredReason` | Failed optional cleanup and why maintenance could not run. | + +All rows after `warmSet` describe fields inside that object. The extra pong field is additive and optional; +old pong messages still decode. The protocol validates nested shapes, finite counts/budgets and generation +identity. This optional status field is independent of protocol 0.10's typed restart-retry contract. +Graph snapshots also stop reporting historical success after a retained result is evicted: idle cold operations +report `READY` for request-time revalidation, without scheduling work or modifying the completed build outcome. + +### Native Rushx integration + +`RushXDaemonRequestResolver` handles only `invocationKind: "rushx"` with custom origin. +`RushDaemonRequestResolver(existingRushResolver)` composes it with an injected workspace +resolver; omitted or `"rush"` kinds go to that existing resolver without reinterpreting +custom workspace commands. The default executable installs this composite for both native +workspace builds and package-script execution. + +The resolver validates canonical request and governing package directories inside its +workspace before execution. Subfolder invocations run from the nearest package folder, +with native PATH, INIT_CWD, RUSH_INVOKED_FOLDER, npm environment filtering and shell escaping. +Per-request dotenv copies load repository then user values without changing daemon cwd, +environment, argv, console streams or cached user configuration. Ordinary script/environment +changes are read for each invocation; there is no cached script process or fabricated warm engine. + +Workspace identity and confinement use native physical paths. Windows Rushx execution separately +retains the client's invocation spelling (including 8.3 names and junctions) for cwd, package lookup, +the governing configuration namespace, lifecycle environment, and pnpm-sync diagnostics. Native +registration warnings use that configuration namespace rather than silently replacing an aliased +project with its physical registered identity. Queued aliases are rechecked against their original +physical directory, and explicit child cwd overrides are confined again immediately before spawning. +No output is rewritten to manufacture parity. Relative `RUSH_TEMP_FOLDER` initialization still +requires pre-execution in-process fallback instead of resolving against the daemon's cwd. + +`RushXCommand` shares the native implementation with the unchanged in-process entrypoint. +Its asynchronous lifecycle spawn seam uses `spawnChild()` for the actual script shell. +The context owns descendants, backpressures raw stdout/stderr, forwards stdin credits/EOF, +and awaits cleanup before the final result. Early child stdin closure preserves the script's +exit status. Native console ANSI bytes are preserved separately from color-aware diagnostic +output; pnpm synchronization keeps native quiet/debug behavior. Cancellation retains the +existing typed global-request abort result rather than inventing a second exit policy. + +The Rushx spawn seam applies the existing terminal policy's TTY color/width +overrides after lifecycle environment preparation. Non-TTY explicit environment +values remain intact, and daemon process globals are never merged into a request. +The standalone client keeps unknown interactive Rushx scripts (any TTY stdio) native +before connecting or consuming input. Embedded clients may forward known pipe-safe +scripts with terminal capabilities, but must declare controlling-terminal needs; +the daemon does not turn child pipes into terminal devices. + +On Linux, completion also waits for the captured detached process group/session to disappear +or contain only nonexecuting zombies. This uses a procps-compatible `ps --sid` with a bounded +cleanup wait; signal delivery and the leader's stream closure alone do not authorize completion. +Inspection failures or a group that remains live fail cleanup rather than reporting success. + +Active pre/post Rushx hooks still depend on process-global argv and synchronous inherited +I/O and are rejected before execution/input. `--ignore-hooks` and recursive calls reuse +native skipping behavior. Encrypted dotenv vaults, unsupported environment initialization, +and changed Rush/experiments configuration also reject before execution; queued configuration +changes fail closed on admission. No hook, dependency synchronization, warning or terminal +requirement is silently omitted. Controlling-terminal requests use the existing in-process +policy; no PTY is allocated. Protocol 0.8 prevents older peers from interpreting +`rushx build` as a workspace build. `PhasedRequestRouter` is the opt-in execution boundary once an integration has supplied that real warm graph. The integration parses the command and supplies its built-in/custom origin, an explicit phase/plugin shape, and operation enabled-state selection; @@ -49,10 +436,9 @@ to its own closure and derives its final result only from that subset. Requests a later batch. Cancelling or disconnecting one client removes its subscription without aborting work needed by other clients; the graph iteration is aborted only after every client in that batch has stopped needing it. -This layer deliberately does not reconstruct `PhasedScriptAction` command/plugin initialization. The typed phased -request contract begins after an integration has produced a validated selection for the exact warm engine shape; -full command parsing remains blocked by -[rushstack#5895](https://github.com/microsoft/rushstack/issues/5895). +The typed phased router remains separate from native initialization. `ProductionDaemonRequestResolver` supplies +validated exact selections from `PhasedCommandEngine`; other integrations retain the existing dependency-closure +selection mode by default. Native empty project selections are successful no-op requests. `GlobalCommandRequestRouter` is the corresponding opt-in boundary for caller-resolved global command logic. It canonicalizes and confines the request working directory to the workspace, snapshots its environment, creates a @@ -72,17 +458,52 @@ queue progress, raw-mode controls, binary output, structured events, and the ter wire queue. A connection runs at most one request at a time so binary operation output remains unambiguous; concurrent requests use separate connections. Each connection accepts at most 256 distinct request identifiers before the client must reconnect, allowing the lifecycle and stdin routers to retain every identifier for deterministic duplicate and -late-frame handling without unbounded growth. Disconnect and host shutdown abort every connection-owned active or -queued request before the resolver and warm workspace are disposed. Separate connections still share the workspace -scheduler and phased batch coordinator, so compatible selections can execute in one iteration. +late-frame handling without unbounded growth. Disconnect and ordinary host shutdown abort connection-owned +requests before the resolver and warm workspace are disposed. Planned process restart instead lets accepted +queued requests drain eligible typed restart results before their connections close. Separate connections still +share the workspace scheduler and phased batch coordinator, so compatible selections can execute in one iteration. The dispatcher accepts an integration-owned `IDaemonRequestResolver` that maps the validated envelope to the existing typed phased request or isolated global executor contracts. Resolvers receive the request abort signal and must settle -when cancellation, disconnect, or host shutdown aborts it. Without that resolver, the standalone executable continues -to start, answer ping, and reject request execution with the typed `unsupported` outcome; it never constructs an empty -graph or reports a false success. A retained invalidation that throws `WorkspaceEngineRecreationRequiredError` is -reported as `workspaceRecreationRequired` before scheduling. Replacing the warm session is intentionally deferred to -WS3. +when cancellation, disconnect, or host shutdown aborts it. An embedded host without that resolver continues to start, +answer ping, and reject ordinary command execution with the typed `unsupported` outcome; it never constructs an empty graph +or reports a false success. A retained invalidation that throws `WorkspaceEngineRecreationRequiredError` is +reported as `workspaceRecreationRequired` before scheduling for unmanaged integrations. The production lifecycle +instead replaces the generation and re-resolves a phased request only while execution is proven not to have begun. + +### Experimental graph requests + +The dispatcher reserves built-in `daemon graph` argv before invoking the production +command resolver. Requests require `environment.RUSH_DAEMON_EXPERIMENTAL === "1"` +and noninteractive input; unknown verbs, malformed selector pairs, and non-built-in +origins are rejected. There is no graph construction or command execution fallback. +`show`/`status` can report an uninitialized session; other verbs require its real graph. + +The route emits JSON-safe `rushd.graph-snapshot` extension events followed by the +existing request result. IDs, project/phase, enabled/status/dependencies, manual-mode +and scheduled flags, and a path-free invalidation summary are the entire snapshot. +It never sends environment variables, native runner objects, logs, or terminal output. +Scope selectors are exact operation IDs or project names and are fully validated +before applying native safe enablement or invalidation. Scope-out expands consumers +before native safe-disable prunes unneeded dependencies. + +Mutations acquire exclusive admission from the same workspace request scheduler. +Active iterations cannot be mutated; prepared iterations reject scope/invalidation +changes. Pause/resume set native manual mode; explicit builds may still run while +paused. Releasing prepared automatic work acquires the same native execution lease +as normal batches, discards its unstarted records, reconciles current inputs, and +reprepares the existing selection. It retains both leases until native idle, even +after request cancellation. A cold or unscheduled graph is never initialized or +given new work by resume. + +Watch is a lease-free observation subscription: one hook set per graph fans out +to live subscribers, each retaining a single dirty notification while its output +is backpressured. Status, invalidation and idle hooks wake the same bounded loop. +Workspace invalidation notifications also cover acknowledgements and watcher errors; +failed notification callbacks are warned without interrupting change tracking. +Cancellation, graph shutdown and disconnect unsubscribe promptly. A live watch +counts as an active request for daemon idle shutdown. No automatic build loop or +new wire version/capability handshake is introduced. The existing `RushCommandLineParser`, `BaseRushAction`, and some built-in/global action helpers still consult or mutate process-global state. This layer therefore does not pretend that arbitrary existing actions are daemon-safe: the @@ -95,6 +516,13 @@ child process. Both global and phased routes stop accepting input on abort/disco acknowledged cooked-mode restoration before publishing the exact-once command result. The daemon never reads or mutates its own stdin or raw-mode state. +Protocol 0.7 clients may negotiate stdin admission and EOF. Attaching an input sink grants one +`stdinReady` write credit; another follows each completed write. `stdinEnd` is queued behind preceding +data, and later data or duplicate EOF is rejected. Cancellation remains serviceable while a sink is +backpressured or not yet attached. Input sinks that accept EOF implement `endInputAsync()`; missing +EOF support fails the request explicitly. `spawnChild(..., { forwardInput: true })` forwards both +bytes and EOF to the owned child process. Older clients receive no new controls. + Terminal width remains the immutable request-start value established by WS2.5. The thin client owns resize and rendering, so this layer does not forward `SIGWINCH`. Commands declaring a real controlling-terminal requirement receive a typed `requiresInProcess` policy result and are not executed by rushd; no pseudo-terminal is allocated or diff --git a/libraries/rush-daemon/package.json b/libraries/rush-daemon/package.json index 6db36d57a9..1a2eba20b0 100644 --- a/libraries/rush-daemon/package.json +++ b/libraries/rush-daemon/package.json @@ -48,8 +48,10 @@ "dependencies": { "@microsoft/rush-lib": "workspace:*", "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-client-core": "workspace:*", "@rushstack/rush-daemon-protocol": "workspace:*", "@rushstack/rush-daemon-transport": "workspace:*", + "@rushstack/rush-terminal-renderer": "workspace:*", "@rushstack/terminal": "workspace:*" }, "devDependencies": { diff --git a/libraries/rush-daemon/src/DaemonControlSession.ts b/libraries/rush-daemon/src/DaemonControlSession.ts index 7e4a6ed8ac..47b0dd7932 100644 --- a/libraries/rush-daemon/src/DaemonControlSession.ts +++ b/libraries/rush-daemon/src/DaemonControlSession.ts @@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto'; import { DAEMON_INTERACTIVE_IO_PROTOCOL_MINOR, + DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR, + DAEMON_LIFECYCLE_PROTOCOL_MINOR, DAEMON_PROTOCOL_VERSION, DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR, DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR, @@ -20,7 +22,8 @@ import type { IDaemonErrorMessage, IDaemonFrame, IDaemonPongMessage, - IDaemonRequestEnvelope + IDaemonRequestEnvelope, + IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; import type { DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; @@ -44,6 +47,9 @@ export interface IDaemonControlSessionOptions { readonly onInteractiveConnection?: (connection: IDaemonInteractiveConnection) => void; readonly onClosed: (session: DaemonControlSession, error: Error | undefined) => void; readonly onError: (error: Error) => void; + readonly onRequestStarted?: () => () => void; + readonly onShutdownRequested: () => void; + readonly getWorkspaceStatus?: () => IDaemonWorkspaceStatus; } interface IRequestState { @@ -73,6 +79,8 @@ export class DaemonControlSession { #isClosing: boolean = false; #nextEventSequence: number = 1; #peerSupportsInteractiveProtocol: boolean = false; + #peerSupportsInputLifecycle: boolean = false; + #peerSupportsDaemonLifecycle: boolean = false; #peerSupportsRequestAdmission: boolean = false; #peerSupportsRequestLifecycle: boolean = false; #sendQueue: Promise = Promise.resolve(); @@ -95,8 +103,8 @@ export class DaemonControlSession { options.onInteractiveConnection?.(this.#interactiveConnection); } - public closeAsync(): Promise { - this.#closePromise ??= this.#closeOnceAsync(); + public closeAsync(drainRequests: boolean = false): Promise { + this.#closePromise ??= this.#closeOnceAsync(drainRequests); return this.#closePromise; } @@ -153,12 +161,20 @@ export class DaemonControlSession { case 'ping': this.#send(this.#createPong()); return; + case 'shutdown': + await this.#shutdownHostAsync(); + return; case 'requestStart': this.#startRequest(message.payload); return; case 'requestCancel': this.#cancelRequest(message.payload.requestId); return; + case 'stdinEnd': + void this.#completeInputAsync( + this.#interactiveConnection.routeStdinEndAsync(message.payload.requestId) + ); + return; default: throw new DaemonProtocolError( 'malformedControlMessage', @@ -190,6 +206,8 @@ export class DaemonControlSession { this.#sessionId = outcome.ack.payload.sessionId; const peerMinor: number = message.payload.protocolVersion.minor; this.#peerSupportsInteractiveProtocol = peerMinor >= DAEMON_INTERACTIVE_IO_PROTOCOL_MINOR; + this.#peerSupportsInputLifecycle = peerMinor >= DAEMON_INPUT_LIFECYCLE_PROTOCOL_MINOR; + this.#peerSupportsDaemonLifecycle = peerMinor >= DAEMON_LIFECYCLE_PROTOCOL_MINOR; this.#peerSupportsRequestAdmission = peerMinor >= DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR; this.#peerSupportsRequestLifecycle = peerMinor >= DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR; this.#send(outcome.ack); @@ -199,6 +217,7 @@ export class DaemonControlSession { if (this.#subscribed) { throw new DaemonProtocolError('malformedControlMessage', 'A daemon session may subscribe only once.'); } + this.#subscribed = true; this.#peerSupportsInteractiveProtocol = this.#peerSupportsInteractiveProtocol && payload.supportsInteractiveIO === true; @@ -206,7 +225,23 @@ export class DaemonControlSession { this.#peerSupportsRequestAdmission && payload.supportsRequestAdmission === true; this.#peerSupportsRequestLifecycle = this.#peerSupportsRequestLifecycle && payload.supportsRequestLifecycle === true; - this.#interactiveConnection.setEnabled(this.#peerSupportsInteractiveProtocol); + this.#peerSupportsInputLifecycle = + this.#peerSupportsInputLifecycle && payload.supportsInputLifecycle === true; + this.#interactiveConnection.setEnabled( + this.#peerSupportsInteractiveProtocol, + this.#peerSupportsInputLifecycle + ); + } + + async #shutdownHostAsync(): Promise { + if (!this.#peerSupportsDaemonLifecycle) { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'Daemon shutdown requires a lifecycle-capable protocol version.' + ); + } + await this.#enqueueControlAsync({ kind: 'shutdownAck', payload: {} }); + this.#options.onShutdownRequested(); } #startRequest(envelope: IDaemonRequestEnvelope): void { @@ -257,7 +292,14 @@ export class DaemonControlSession { }); const state: IRequestState = { abortController, client, completion: Promise.resolve() }; this.#requestById.set(requestId, state); - state.completion = Promise.resolve().then(() => this.#dispatchRequestAsync(envelope, state)); + const releaseActivity: (() => void) | undefined = this.#options.onRequestStarted?.(); + state.completion = Promise.resolve() + .then(() => this.#dispatchRequestAsync(envelope, state)) + .finally(() => { + this.#completeRequest(requestId, state); + releaseActivity?.(); + }); + void state.completion.catch((error: unknown) => this.#handleSendFailureAsync(error)); } #getNextEventSequence(): number { @@ -300,7 +342,6 @@ export class DaemonControlSession { const rejection: IClassifiedRejection = classifyRejection(dispatchError); await state.client.writeRejectionAsync(rejection.code, rejection.message); } - this.#completeRequest(envelope.requestId, state); } #completeRequest(requestId: string, state: IRequestState): void { @@ -333,6 +374,9 @@ export class DaemonControlSession { payload: { daemonVersion: this.#options.daemonVersion, protocolVersion: DAEMON_PROTOCOL_VERSION, + pid: process.pid, + residentMemoryBytes: process.memoryUsage().rss, + workspace: this.#options.getWorkspaceStatus?.(), uptimeMs: Date.now() - this.#options.startedAtMs } }; @@ -344,10 +388,7 @@ export class DaemonControlSession { ); } - #enqueueControlAsync( - message: DaemonControlMessage, - closeAfterSend: boolean = false - ): Promise { + #enqueueControlAsync(message: DaemonControlMessage, closeAfterSend: boolean = false): Promise { return this.#enqueueFrameAsync( { kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(message) }, closeAfterSend @@ -402,13 +443,21 @@ export class DaemonControlSession { } } - async #closeOnceAsync(): Promise { + async #closeOnceAsync(drainRequests: boolean = false): Promise { const closeReason: Error = new Error('The daemon control session is closing.'); + if (drainRequests) { + const pending: Promise[]> = Promise.allSettled( + Array.from(this.#requestById.values(), (state: IRequestState) => state.completion) + ); + // Lifecycle admission has stopped execution; let accepted requests receive their typed restart result. + if (!(await settlesWithinAsync(pending.then(() => undefined), CLOSE_DRAIN_TIMEOUT_MS))) { + this.#connection.abort(closeReason); + } + await pending; + } this.#markClosing(closeReason); const drainPromise: Promise = Promise.all([ - Promise.allSettled( - Array.from(this.#requestById.values(), (state: IRequestState) => state.completion) - ), + Promise.allSettled(Array.from(this.#requestById.values(), (state: IRequestState) => state.completion)), this.#sendQueue ]).then(() => undefined); if (!(await settlesWithinAsync(drainPromise, CLOSE_DRAIN_TIMEOUT_MS))) { @@ -434,7 +483,6 @@ export class DaemonControlSession { this.#options.onClosed(this, finalError); this.#resolveClosed(); } - } function createDeferred(): { promise: Promise; resolve: () => void } { @@ -471,7 +519,10 @@ function classifyRejection(error: unknown): IClassifiedRejection { return { code: 'routingFailed', message: normalizeError(error).message }; } -function combineCloseErrors(error: Error | undefined, cleanupErrors: ReadonlyArray): Error | undefined { +function combineCloseErrors( + error: Error | undefined, + cleanupErrors: ReadonlyArray +): Error | undefined { if (cleanupErrors.length === 0) return error; return new AggregateError( error ? [error, ...cleanupErrors] : cleanupErrors, @@ -485,7 +536,13 @@ async function settlesWithinAsync(promise: Promise, timeoutMs: number): Pr timeout = setTimeout(() => resolve(false), timeoutMs); timeout.unref(); }); - const settled: boolean = await Promise.race([promise.then(() => true, () => true), timeoutPromise]); + const settled: boolean = await Promise.race([ + promise.then( + () => true, + () => true + ), + timeoutPromise + ]); if (timeout) clearTimeout(timeout); return settled; } diff --git a/libraries/rush-daemon/src/DaemonGraphObserver.ts b/libraries/rush-daemon/src/DaemonGraphObserver.ts new file mode 100644 index 0000000000..529a178e2d --- /dev/null +++ b/libraries/rush-daemon/src/DaemonGraphObserver.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + OperationStatus, + type IOperationExecutionResult, + type IOperationGraph, + type Operation +} from '@microsoft/rush-lib'; +import type { + IDaemonGraphInvalidations, + IDaemonGraphOperation, + IDaemonGraphSnapshotPayload +} from '@rushstack/rush-daemon-protocol'; + +import type { IWorkspaceSession } from './WorkspaceSession'; +import type { IWorkspaceInvalidationSnapshot } from './WorkspaceInvalidationTracker'; +import { getWorkspaceGenerationToken } from './WorkspaceGeneration'; + +const OBSERVERS: WeakMap = new WeakMap(); +const TAP_NAME: string = 'RushDaemonGraphObserver'; +const RETAINED_RESULT_STATUSES: ReadonlySet = new Set([ + OperationStatus.Success, + OperationStatus.SuccessWithWarning, + OperationStatus.Failure, + OperationStatus.FromCache, + OperationStatus.NoOp +]); + +/** One hook set per graph, retaining only status metadata and live subscriber callbacks. */ +export class DaemonGraphObserver { + readonly #statuses: Map = new Map(); + readonly #subscribers: Set<() => void> = new Set(); + #idleSequence: number = 0; + + public constructor(graph: IOperationGraph) { + graph.hooks.onExecutionStatesUpdated.tap(TAP_NAME, (records) => { + for (const record of records) this.#statuses.set(record.operation, record.status); + this.#notify(); + }); + graph.hooks.onIterationScheduled.tap(TAP_NAME, (records) => { + for (const [operation, record] of records) this.#statuses.set(operation, record.status); + this.#notify(); + }); + graph.hooks.onInvalidateOperations.tap(TAP_NAME, (operations) => { + for (const operation of operations) this.#statuses.set(operation, OperationStatus.Ready); + this.#notify(); + }); + graph.hooks.onEnableStatesChanged.tap(TAP_NAME, () => this.#notify()); + graph.hooks.onGraphStateChanged.tap(TAP_NAME, () => this.#notify()); + graph.hooks.onIdle.tap(TAP_NAME, () => { + this.#idleSequence++; + this.#notify(); + }); + } + + public get idleSequence(): number { + return this.#idleSequence; + } + + public get subscriberCount(): number { + return this.#subscribers.size; + } + + public subscribe(notify: () => void): () => void { + this.#subscribers.add(notify); + return () => { + this.#subscribers.delete(notify); + }; + } + + public getOperations(graph: IOperationGraph): IDaemonGraphOperation[] { + return Array.from(graph.operations, (operation) => ({ + operationId: operation.name, + projectName: operation.associatedProject.packageName, + phaseName: operation.associatedPhase.name, + enabled: operation.enabled, + status: this.#getOperationStatus(graph, operation), + dependencyIds: Array.from(operation.dependencies, (dependency) => dependency.name).sort() + })).sort((a, b) => a.operationId.localeCompare(b.operationId)); + } + + #getOperationStatus(graph: IOperationGraph, operation: Operation): OperationStatus | null { + const retained: IOperationExecutionResult | undefined = graph.resultByOperation.get(operation); + const status: OperationStatus | undefined = this.#statuses.get(operation) ?? retained?.status; + if ( + status !== undefined && + RETAINED_RESULT_STATUSES.has(status) && + !retained && + graph.status !== OperationStatus.Executing && + !graph.hasScheduledIteration + ) { + // Idle eviction removed the actual result. Historical observer state must not certify it as current. + return OperationStatus.Ready; + } + return status ?? null; + } + + #notify(): void { + // Subscribers only wake their bounded observation loop; no I/O or user callbacks run in a native hook. + for (const notify of this.#subscribers) notify(); + } +} + +export function getDaemonGraphObserver(graph: IOperationGraph): DaemonGraphObserver { + let observer: DaemonGraphObserver | undefined = OBSERVERS.get(graph); + if (!observer) { + observer = new DaemonGraphObserver(graph); + OBSERVERS.set(graph, observer); + } + return observer; +} + +export function snapshotDaemonGraph(session: IWorkspaceSession): IDaemonGraphSnapshotPayload['snapshot'] { + const workspaceGeneration: string = getWorkspaceGenerationToken(session); + const changes: IWorkspaceInvalidationSnapshot = session.invalidations.getSnapshot(); + const invalidations: IDaemonGraphInvalidations = { + sequence: changes.sequence, + changedPathCount: changes.changedPaths.length, + hasUnknownChanges: changes.hasUnknownChanges, + isWatcherHealthy: changes.isWatcherHealthy + }; + const graph: IOperationGraph | undefined = session.operationGraph; + if (!graph) return { initialized: false, invalidations, workspaceGeneration }; + return { + initialized: true, + workspaceGeneration, + invalidations, + operations: getDaemonGraphObserver(graph).getOperations(graph), + status: graph.status, + pauseNextIteration: graph.pauseNextIteration, + hasScheduledIteration: graph.hasScheduledIteration + }; +} + +/** A single dirty bit coalesces arbitrarily many changes while the consumer is backpressured. */ +export class DaemonGraphChanges implements Disposable { + readonly #unsubscribe: (() => void)[]; + readonly #signals: ReadonlyArray; + #dirty: boolean = true; + #disposed: boolean = false; + #wake: (() => void) | undefined; + + public constructor(session: IWorkspaceSession, graph: IOperationGraph, signal: AbortSignal) { + this.#signals = [signal, graph.abortController.signal]; + this.#unsubscribe = [ + getDaemonGraphObserver(graph).subscribe(this.#notify), + session.invalidations.subscribe(this.#notify) + ]; + for (const abortSignal of this.#signals) { + abortSignal.addEventListener('abort', this.#abort, { once: true }); + } + if (this.#signals.some((abortSignal) => abortSignal.aborted)) this[Symbol.dispose](); + } + + public async nextAsync(): Promise { + if (!this.#dirty && !this.#disposed) { + await new Promise((resolve) => { + this.#wake = resolve; + }); + } + this.#dirty = false; + return !this.#disposed; + } + + public [Symbol.dispose](): void { + if (this.#disposed) return; + this.#disposed = true; + for (const unsubscribe of this.#unsubscribe) unsubscribe(); + for (const signal of this.#signals) signal.removeEventListener('abort', this.#abort); + this.#notify(); + } + + #abort = (): void => this[Symbol.dispose](); + + #notify = (): void => { + this.#dirty = true; + this.#wake?.(); + this.#wake = undefined; + }; +} diff --git a/libraries/rush-daemon/src/DaemonGraphRequest.ts b/libraries/rush-daemon/src/DaemonGraphRequest.ts new file mode 100644 index 0000000000..85d615ae1b --- /dev/null +++ b/libraries/rush-daemon/src/DaemonGraphRequest.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IOperationGraph, Operation } from '@microsoft/rush-lib'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import { DaemonRequestDispatchError } from './DaemonRequestDispatcher'; + +export type DaemonGraphVerb = + | 'show' | 'status' | 'scope-in' | 'scope-out' | 'invalidate' | 'watch' | 'pause' | 'resume'; + +export interface IDaemonGraphRequest { + readonly verb: DaemonGraphVerb; + readonly selectors: ReadonlyArray<{ readonly kind: '--operation' | '--project'; readonly value: string }>; +} + +export function parseDaemonGraphRequest(envelope: IDaemonRequestEnvelope): IDaemonGraphRequest { + if (envelope.environment.RUSH_DAEMON_EXPERIMENTAL !== '1') { + throw new DaemonRequestDispatchError('unsupported', 'Graph commands require RUSH_DAEMON_EXPERIMENTAL=1.'); + } + if ( + envelope.commandOrigin !== 'built-in' || envelope.commandName !== 'daemon' || + envelope.argv[0] !== 'daemon' || envelope.argv[1] !== 'graph' || envelope.terminal.acceptsStdin + ) { + throw new DaemonRequestDispatchError('invalidRequest', 'Expected a built-in, noninteractive daemon graph request.'); + } + const [, , verb, ...args] = envelope.argv; + switch (verb) { + case 'show': case 'status': case 'watch': case 'pause': case 'resume': + if (args.length) throw new DaemonRequestDispatchError('invalidRequest', `${verb} takes no selectors.`); + return { verb, selectors: [] }; + case 'scope-in': case 'scope-out': case 'invalidate': { + const selectors: IDaemonGraphRequest['selectors'][number][] = []; + for (let i: number = 0; i < args.length; i += 2) { + const kind: string = args[i]; + const value: string | undefined = args[i + 1]; + if ((kind !== '--operation' && kind !== '--project') || !value || value.startsWith('--')) { + throw new DaemonRequestDispatchError('invalidRequest', 'Selectors must be --operation ID or --project NAME pairs.'); + } + selectors.push({ kind, value }); + } + if (!selectors.length) { + throw new DaemonRequestDispatchError('invalidRequest', `${verb} requires at least one selector.`); + } + return { verb, selectors }; + } + default: + throw new DaemonRequestDispatchError( + 'invalidRequest', 'Usage: daemon graph show|status|scope-in|scope-out|invalidate|watch|pause|resume' + ); + } +} + +/** Resolve every selector before applying any change; project names and operation IDs are exact. */ +export function selectDaemonGraphOperations( + request: IDaemonGraphRequest, + graph: IOperationGraph +): ReadonlySet { + const selected: Set = new Set(); + for (const { kind, value } of request.selectors) { + let found: boolean = false; + for (const operation of graph.operations) { + const candidate: string = kind === '--operation' ? operation.name : operation.associatedProject.packageName; + if (candidate === value) { + selected.add(operation); + found = true; + } + } + if (!found) throw new DaemonRequestDispatchError('invalidRequest', `Unknown graph selector ${kind} ${value}.`); + } + if (request.verb === 'scope-out') { + // Native safe-disable prunes unused dependencies but retains dependencies of enabled consumers. + // Include the consumers being scoped out so they cannot keep the requested operation enabled. + for (const operation of selected) { + for (const consumer of operation.consumers) selected.add(consumer); + } + } + return selected; +} diff --git a/libraries/rush-daemon/src/DaemonGraphRequestRouter.ts b/libraries/rush-daemon/src/DaemonGraphRequestRouter.ts new file mode 100644 index 0000000000..cd2b0e9ae6 --- /dev/null +++ b/libraries/rush-daemon/src/DaemonGraphRequestRouter.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { OperationStatus, type IOperationGraph, type Operation } from '@microsoft/rush-lib'; +import { PackageJsonLookup } from '@rushstack/node-core-library'; +import { + DAEMON_PROTOCOL_VERSION, + RUSHD_GRAPH_SNAPSHOT, + type IDaemonCommandResult, + type IDaemonGraphSnapshotPayload, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { DaemonRequestDispatchError, type IDaemonRequestDispatchClient } from './DaemonRequestDispatcher'; +import { DaemonGraphChanges, getDaemonGraphObserver, snapshotDaemonGraph, type DaemonGraphObserver } from './DaemonGraphObserver'; +import { parseDaemonGraphRequest, selectDaemonGraphOperations, type IDaemonGraphRequest } from './DaemonGraphRequest'; +import { RequestExclusivityClass, RequestSchedulerError, RequestSchedulerErrorCode, type IRequestLease } from './RequestScheduler'; +import { + getRequestAdmissionErrorCode, + getWorkspaceRequestScheduler, + RequestAdmissionController +} from './WorkspaceRequestAdmission'; +import type { IWorkspaceSession } from './WorkspaceSession'; +import { setPauseNextIteration } from './PhasedRequestRouter'; +import { getWorkspaceGenerationToken } from './WorkspaceGeneration'; + +const DAEMON_PACKAGE_VERSION: string = PackageJsonLookup.loadOwnPackageJson(__dirname).version; + +export class DaemonGraphRequestRouter { + private readonly _session: IWorkspaceSession; + + public constructor(session: IWorkspaceSession) { + this._session = session; + } + + public async executeAsync(envelope: IDaemonRequestEnvelope, client: IDaemonRequestDispatchClient): Promise { + const request: IDaemonGraphRequest = parseDaemonGraphRequest(envelope); + let result: IDaemonCommandResult; + try { + if (request.verb === 'watch') { + const graph: IOperationGraph = this._requireGraph(); + const changes: DaemonGraphChanges = new DaemonGraphChanges(this._session, graph, client.abortSignal); + try { + while (await changes.nextAsync()) { + await this._writeSnapshotAsync(envelope.requestId, client, snapshotDaemonGraph(this._session)); + } + } finally { + changes[Symbol.dispose](); + } + result = { requestId: envelope.requestId, exitCode: 130, outcome: 'aborted', aborted: true }; + } else { + const snapshot: IDaemonGraphSnapshotPayload['snapshot'] = + request.verb === 'show' || request.verb === 'status' + ? snapshotDaemonGraph(this._session) + : await this._mutateAsync(request, envelope, client); + await this._writeSnapshotAsync(envelope.requestId, client, snapshot); + result = client.abortSignal.aborted + ? { requestId: envelope.requestId, exitCode: 130, outcome: 'aborted', aborted: true } + : { requestId: envelope.requestId, exitCode: 0, outcome: 'success', aborted: false }; + } + } catch (error) { + if (!(error instanceof RequestSchedulerError)) throw error; + const aborted: boolean = error.code === RequestSchedulerErrorCode.Aborted; + result = { + requestId: envelope.requestId, + exitCode: aborted ? 130 : 1, + outcome: aborted ? 'aborted' : 'failure', + aborted, + admissionErrorCode: getRequestAdmissionErrorCode(error) + }; + } + await client.writeResultAsync(result); + } + + private async _mutateAsync( + request: IDaemonGraphRequest, + envelope: IDaemonRequestEnvelope, + client: IDaemonRequestDispatchClient + ): Promise { + const admission: RequestAdmissionController = new RequestAdmissionController({ + admission: envelope.admission, client, requestId: envelope.requestId + }); + try { + const lease: IRequestLease = await admission.acquireAsync( + getWorkspaceRequestScheduler(this._session), RequestExclusivityClass.Exclusive + ); + try { + this._session.assertActive?.(); + if (envelope.expectedWorkspaceGeneration !== getWorkspaceGenerationToken(this._session)) { + throw new DaemonRequestDispatchError( + 'invalidRequest', 'The graph generation changed or was not supplied; read a fresh snapshot before mutating.' + ); + } + const graph: IOperationGraph = this._requireGraph(); + if (graph.status === OperationStatus.Executing) { + throw new DaemonRequestDispatchError('routingFailed', 'Cannot mutate an active graph iteration.'); + } + const operations: ReadonlySet = selectDaemonGraphOperations(request, graph); + if (request.verb === 'pause') { + setPauseNextIteration(graph, true); + } else if (request.verb === 'resume') { + await this._resumeAsync(graph); + } else { + if (graph.hasScheduledIteration) { + throw new DaemonRequestDispatchError('routingFailed', 'Cannot change a prepared graph iteration.'); + } + if (request.verb === 'invalidate') graph.invalidateOperations(operations, 'daemon graph invalidate'); + else graph.setEnabledStates(operations, request.verb === 'scope-in', 'safe'); + } + return snapshotDaemonGraph(this._session); + } finally { + lease.release(); + } + } finally { + admission.dispose(); + } + } + + private async _resumeAsync(graph: IOperationGraph): Promise { + if (!graph.hasScheduledIteration) { + setPauseNextIteration(graph, false); + return; + } + // Keep prepared work paused while obtaining exclusive native ownership and refreshing its inputs. + setPauseNextIteration(graph, true); + const nativeLease: AsyncDisposable | undefined = await this._session.acquireExecutionLeaseAsync?.(); + try { + graph.discardScheduledIteration(); + await this._session.reconcileInvalidationsAsync(); + const scheduled: boolean = await graph.scheduleIterationAsync({ + inputsSnapshot: this._session.inputsSnapshot + }); + if (!scheduled) { + setPauseNextIteration(graph, false); + return; + } + const observer: DaemonGraphObserver = getDaemonGraphObserver(graph); + const idleSequence: number = observer.idleSequence; + const changes: DaemonGraphChanges = + new DaemonGraphChanges(this._session, graph, graph.abortController.signal); + try { + setPauseNextIteration(graph, false); + while (observer.idleSequence === idleSequence) { + if (!(await changes.nextAsync())) { + throw new DaemonRequestDispatchError('routingFailed', 'The graph closed before reaching idle.'); + } + } + } finally { + changes[Symbol.dispose](); + } + } finally { + await nativeLease?.[Symbol.asyncDispose](); + } + } + + private _requireGraph(): IOperationGraph { + const graph: IOperationGraph | undefined = this._session.operationGraph; + if (!graph || graph.abortController.signal.aborted) { + throw new DaemonRequestDispatchError( + 'routingFailed', 'The graph is uninitialized or closed. Initialize it with an explicit supported build request.' + ); + } + return graph; + } + + private _writeSnapshotAsync( + requestId: string, + client: IDaemonRequestDispatchClient, + snapshot: IDaemonGraphSnapshotPayload['snapshot'] + ): Promise { + const sequence: number = client.getNextEventSequence(); + return client.writeEventAsync({ + protocolVersion: DAEMON_PROTOCOL_VERSION, + eventId: `${client.sessionId}:graph:${sequence}`, + sessionId: client.sessionId, + sequence, + timestamp: new Date().toISOString(), + source: { + packageName: '@rushstack/rush-daemon', + packageVersion: DAEMON_PACKAGE_VERSION, + component: 'daemon-graph' + }, + privacy: 'local-sensitive', + required: true, + type: 'extension', + scope: { commandName: 'daemon' }, + payload: { name: RUSHD_GRAPH_SNAPSHOT, data: { requestId, snapshot } } + }); + } +} diff --git a/libraries/rush-daemon/src/DaemonIdleTimer.ts b/libraries/rush-daemon/src/DaemonIdleTimer.ts new file mode 100644 index 0000000000..f64d4400b8 --- /dev/null +++ b/libraries/rush-daemon/src/DaemonIdleTimer.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const MILLISECONDS_PER_SECOND: number = 1000; +const MAX_TIMER_DELAY_MS: number = 0x7fffffff; + +/** Tracks request lifetimes, including resolution, queueing, output drain, and cleanup. */ +export class DaemonIdleTimer implements Disposable { + readonly #delayMs: number | undefined; + #activeRequests: number = 0; + #onIdle: (() => void) | undefined; + #timer: NodeJS.Timeout | undefined; + + public constructor(idleTimeoutSeconds: number | undefined) { + if ( + idleTimeoutSeconds !== undefined && + (!Number.isFinite(idleTimeoutSeconds) || + idleTimeoutSeconds <= 0 || + idleTimeoutSeconds > MAX_TIMER_DELAY_MS / MILLISECONDS_PER_SECOND) + ) { + throw new RangeError( + `idleTimeoutSeconds must be greater than zero and at most ${MAX_TIMER_DELAY_MS / MILLISECONDS_PER_SECOND}.` + ); + } + this.#delayMs = + idleTimeoutSeconds === undefined ? undefined : idleTimeoutSeconds * MILLISECONDS_PER_SECOND; + } + + public start(onIdle: () => void): void { + this.#onIdle = onIdle; + this.#schedule(); + } + + public acquire(): () => void { + this.#activeRequests++; + clearTimeout(this.#timer); + let released: boolean = false; + return () => { + if (released) return; + released = true; + this.#activeRequests--; + this.#schedule(); + }; + } + + public [Symbol.dispose](): void { + this.#onIdle = undefined; + clearTimeout(this.#timer); + } + + #schedule(): void { + clearTimeout(this.#timer); + if (this.#activeRequests !== 0 || !this.#onIdle || this.#delayMs === undefined) return; + this.#timer = setTimeout(this.#onIdle, this.#delayMs); + this.#timer.unref(); + } +} diff --git a/libraries/rush-daemon/src/DaemonInstallation.ts b/libraries/rush-daemon/src/DaemonInstallation.ts new file mode 100644 index 0000000000..c8d2d64374 --- /dev/null +++ b/libraries/rush-daemon/src/DaemonInstallation.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +import { JsonFile } from '@rushstack/node-core-library'; +import type { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; + +export interface IDaemonInstallationMetadata { + readonly daemonPackageJsonPath: string; + readonly daemonVersion: string; + readonly launcherPath: string; + readonly rushVersion: string; +} + +export interface IInstalledDaemonLauncher extends IDaemonInstallationMetadata { + readonly rushLibEntryPoint: string; + readonly protocolVersion: IDaemonProtocolVersion; + readonly canLaunchRequests: boolean; +} + +/** Resolves declared files without loading foreign engine code into the caller. */ +export function readDaemonInstallationMetadata(packageJsonPath: string): IDaemonInstallationMetadata { + const daemonPackageJsonPath: string = fs.realpathSync.native(packageJsonPath); + const root: string = path.dirname(daemonPackageJsonPath); + const metadata: { + name?: string; + version?: string; + bin?: Record; + } = JsonFile.load(daemonPackageJsonPath); + if ( + metadata?.name !== '@rushstack/rush-daemon' || + typeof metadata.version !== 'string' || + typeof metadata.bin?.rushd !== 'string' + ) { + throw new Error(`No declared @rushstack/rush-daemon launcher in ${daemonPackageJsonPath}.`); + } + const declaredPath: string = path.resolve(root, metadata.bin.rushd); + assertPackageFile(root, declaredPath); + const launcherPath: string = fs.realpathSync.native(declaredPath); + assertPackageFile(root, launcherPath); + if (!fs.statSync(launcherPath).isFile()) throw new Error(`Daemon launcher is not a file: ${launcherPath}`); + const selectedRequire: NodeRequire = createRequire(launcherPath); + const rushPackage: { name?: string; version?: string } = JsonFile.load( + selectedRequire.resolve('@microsoft/rush-lib/package.json') + ); + if (rushPackage?.name !== '@microsoft/rush-lib' || typeof rushPackage.version !== 'string') { + throw new Error(`Daemon launcher has no valid Rush engine dependency: ${launcherPath}`); + } + return { + daemonPackageJsonPath, + daemonVersion: metadata.version, + launcherPath, + rushVersion: rushPackage.version + }; +} + +function assertPackageFile(root: string, file: string): void { + const relative: string = path.relative(root, file); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Daemon launcher must be inside its declared package: ${file}`); + } +} diff --git a/libraries/rush-daemon/src/DaemonInteractiveConnection.ts b/libraries/rush-daemon/src/DaemonInteractiveConnection.ts index 72f7b625f6..67fa9fab0e 100644 --- a/libraries/rush-daemon/src/DaemonInteractiveConnection.ts +++ b/libraries/rush-daemon/src/DaemonInteractiveConnection.ts @@ -52,6 +52,7 @@ export class DaemonInteractiveConnection implements IDaemonInteractiveConnection readonly #pendingRawModeByRequestId: Map = new Map(); readonly #sendControlMessageAsync: SendDaemonControlMessageAsync; #enabled: boolean = false; + #inputLifecycleEnabled: boolean = false; #rawModeOwnerRequestId: string | undefined; #rawModeTail: Promise = Promise.resolve(); @@ -63,8 +64,9 @@ export class DaemonInteractiveConnection implements IDaemonInteractiveConnection return this.#abortController.signal; } - public setEnabled(enabled: boolean): void { + public setEnabled(enabled: boolean, inputLifecycleEnabled: boolean = false): void { this.#enabled = enabled; + this.#inputLifecycleEnabled = enabled && inputLifecycleEnabled; } public registerRequest(options: IDaemonInteractiveRequestOptions): IInteractiveRequestSession { @@ -75,7 +77,11 @@ export class DaemonInteractiveConnection implements IDaemonInteractiveConnection const client: IInteractiveRequestControlClient = { abortSignal: requestAbortSignal, writeRawModeControlAsync: (message: IDaemonSetRawModeMessage): Promise => - this.#queueRawModeControlAsync(message, requestAbortSignal) + this.#queueRawModeControlAsync(message, requestAbortSignal), + writeInputReadyAsync: this.#inputLifecycleEnabled + ? (requestId: string) => + this.#sendControlMessageAsync({ kind: 'stdinReady', payload: { requestId } }) + : undefined }; return this.#inputRouter.register({ ...options, client }); } @@ -89,6 +95,14 @@ export class DaemonInteractiveConnection implements IDaemonInteractiveConnection await this.#inputRouter.routeStdinFrameAsync(payload); } + public async routeStdinEndAsync(requestId: string): Promise { + this.#assertEnabled(); + if (!this.#inputLifecycleEnabled) { + throw new Error('The daemon client did not negotiate stdin admission and EOF.'); + } + await this.#inputRouter.routeStdinEndAsync(requestId); + } + public handleControlMessage(message: DaemonControlMessage): boolean { if (message.kind !== 'rawModeChanged') { return false; diff --git a/libraries/rush-daemon/src/DaemonRequestDispatcher.ts b/libraries/rush-daemon/src/DaemonRequestDispatcher.ts index 3f8085a85d..38f8d53e7f 100644 --- a/libraries/rush-daemon/src/DaemonRequestDispatcher.ts +++ b/libraries/rush-daemon/src/DaemonRequestDispatcher.ts @@ -19,6 +19,9 @@ import type { IPhasedRequestClient } from './PhasedRequestClient'; import { PhasedRequestRouter } from './PhasedRequestRouter'; import type { IGlobalCommandRequestClient } from './GlobalCommandRequestClient'; import type { IWorkspaceSession } from './WorkspaceSession'; +import { DaemonGraphRequestRouter } from './DaemonGraphRequestRouter'; +import { getDaemonGraphObserver } from './DaemonGraphObserver'; +import { isRushxInvocation, type IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; /** A request resolved by the integration that owns Rush command parsing. @beta */ export type ResolvedDaemonRequest = IResolvedDaemonPhasedRequest | IResolvedDaemonGlobalRequest; @@ -27,6 +30,8 @@ export type ResolvedDaemonRequest = IResolvedDaemonPhasedRequest | IResolvedDaem export interface IResolvedDaemonPhasedRequest { readonly kind: 'phased'; readonly request: IDaemonPhasedRequest; + /** Native selection has already resolved all required project/phase dependencies. */ + readonly exactSelection?: boolean; } /** A resolver outcome that uses the existing isolated global executor contract. @beta */ @@ -45,6 +50,7 @@ export interface IResolveDaemonRequestOptions { /** Resolves a validated wire envelope without coupling rushd to CLI parser internals. @beta */ export interface IDaemonRequestResolver { + readonly workspaceLifecycle?: IWorkspaceResolverLifecycle; readonly [Symbol.asyncDispose]?: () => Promise; resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise; } @@ -78,66 +84,136 @@ export interface IDaemonRequestDispatchClient { writeTerminalPolicyAsync(result: IDaemonTerminalPolicyResult): Promise; } +/** Immutable generation selected before command resolution. @beta */ +export interface IDispatchWorkspaceRequestOptions { + readonly envelope: IDaemonRequestEnvelope; + readonly client: IDaemonRequestDispatchClient; + readonly workspaceSession: IWorkspaceSession; + readonly resolver: IDaemonRequestResolver | undefined; + readonly onExecutionStarting?: () => void; +} + +/** Executes an already admitted workspace generation without resolving against another session. @beta */ +export type DispatchWorkspaceRequestAsync = ( + options: IDispatchWorkspaceRequestOptions +) => Promise; + +/** Host-owned lifecycle admission surrounding existing command routers. @beta */ +export interface IDaemonRequestLifecycle extends AsyncDisposable { + dispatchAsync( + envelope: IDaemonRequestEnvelope, + client: IDaemonRequestDispatchClient, + dispatchAsync: DispatchWorkspaceRequestAsync + ): Promise; +} + /** * Shared resolver-backed integration between wire requests and the accumulated typed WS2 routers. * * @beta */ export class DaemonRequestDispatcher implements AsyncDisposable { - readonly #globalRouter: GlobalCommandRequestRouter; - readonly #phasedRouter: PhasedRequestRouter; readonly #resolver: IDaemonRequestResolver | undefined; - readonly #workspaceSession: IWorkspaceSession; + readonly #workspaceSession: IWorkspaceSession | undefined; + readonly #lifecycle: IDaemonRequestLifecycle | undefined; #disposePromise: Promise | undefined; - public constructor(workspaceSession: IWorkspaceSession, resolver?: IDaemonRequestResolver) { - this.#workspaceSession = workspaceSession; - this.#resolver = resolver; - this.#globalRouter = new GlobalCommandRequestRouter(workspaceSession); - this.#phasedRouter = new PhasedRequestRouter(workspaceSession); + public constructor( + workspaceSession: IWorkspaceSession, + resolver?: IDaemonRequestResolver, + lifecycle?: IDaemonRequestLifecycle + ) { + this.#workspaceSession = lifecycle ? undefined : workspaceSession; + this.#resolver = lifecycle ? undefined : resolver; + this.#lifecycle = lifecycle; } public async dispatchAsync( envelope: IDaemonRequestEnvelope, client: IDaemonRequestDispatchClient ): Promise { - if (!this.#resolver) { - throw new DaemonRequestDispatchError( - 'unsupported', - 'This daemon host has no command request integration configured.' - ); + if (this.#lifecycle) { + await this.#lifecycle.dispatchAsync(envelope, client, dispatchWorkspaceRequestAsync); + } else { + await dispatchWorkspaceRequestAsync({ + envelope, + client, + workspaceSession: this.#workspaceSession!, + resolver: this.#resolver + }); } - const resolved: ResolvedDaemonRequest = await this.#resolver.resolveRequestAsync({ - abortSignal: client.abortSignal, - envelope, - workspaceSession: this.#workspaceSession - }); - if (resolved.kind === 'phased') { - validateResolvedPhasedRequest(envelope, resolved.request); - await this.#phasedRouter.executeAsync(resolved.request, createPhasedClient(client)); - return; - } - const request: IResolvedGlobalCommandRequest = this.#globalRouter.resolveRequest({ - admission: envelope.admission, - commandName: envelope.commandName, - commandOrigin: envelope.commandOrigin, - cwd: envelope.cwd, - environment: envelope.environment, - requestId: envelope.requestId, - terminal: { - ...envelope.terminal, - columns: envelope.terminal.columns - } - }); - await this.#globalRouter.executeAsync(request, resolved.executor, createGlobalClient(client)); } public [Symbol.asyncDispose](): Promise { - this.#disposePromise ??= this.#resolver?.[Symbol.asyncDispose]?.() ?? Promise.resolve(); + this.#disposePromise ??= this.#lifecycle + ? Promise.resolve(this.#lifecycle[Symbol.asyncDispose]()) + : (this.#resolver?.[Symbol.asyncDispose]?.() ?? Promise.resolve()); return this.#disposePromise; } } +async function dispatchWorkspaceRequestAsync( + options: IDispatchWorkspaceRequestOptions +): Promise { + const { envelope, client, workspaceSession, resolver, onExecutionStarting } = options; + workspaceSession.assertActive?.(); + if ( + !isRushxInvocation(envelope) && + envelope.commandOrigin === 'built-in' && + (envelope.commandName === 'daemon' || (envelope.argv[0] === 'daemon' && envelope.argv[1] === 'graph')) + ) { + onExecutionStarting?.(); + await new DaemonGraphRequestRouter(workspaceSession).executeAsync(envelope, client); + return undefined; + } + if (!resolver) { + throw new DaemonRequestDispatchError( + 'unsupported', + 'This daemon host has no command request integration configured.' + ); + } + const resolved: ResolvedDaemonRequest = await resolver.resolveRequestAsync({ + abortSignal: client.abortSignal, + envelope, + workspaceSession + }); + workspaceSession.assertActive?.(); + if (workspaceSession.operationGraph) { + getDaemonGraphObserver(workspaceSession.operationGraph); + } + if (resolved.kind === 'phased') { + validateResolvedPhasedRequest(envelope, resolved.request); + return await new PhasedRequestRouter(workspaceSession).executeAsync( + resolved.request, + createPhasedClient(client), + resolved.exactSelection, + onExecutionStarting + ); + } + const globalRouter: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(workspaceSession); + const request: IResolvedGlobalCommandRequest = globalRouter.resolveRequest({ + admission: envelope.admission, + commandName: envelope.commandName, + commandOrigin: isRushxInvocation(envelope) ? 'custom' : envelope.commandOrigin, + cwd: envelope.cwd, + environment: envelope.environment, + requestId: envelope.requestId, + terminal: { + ...envelope.terminal, + columns: envelope.terminal.columns + } + }); + return await globalRouter.executeAsync( + request, + async (context) => { + workspaceSession.assertActive?.(); + onExecutionStarting?.(); + return await resolved.executor(context); + }, + createGlobalClient(client) + ); +} + function validateResolvedPhasedRequest( envelope: IDaemonRequestEnvelope, request: IDaemonPhasedRequest diff --git a/libraries/rush-daemon/src/EngineTerminalProvider.ts b/libraries/rush-daemon/src/EngineTerminalProvider.ts new file mode 100644 index 0000000000..13c8b48d5e --- /dev/null +++ b/libraries/rush-daemon/src/EngineTerminalProvider.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IOperationGraph, _IOperationGraphEventSink } from '@microsoft/rush-lib'; +import { TerminalProviderSeverity, type ITerminalProvider } from '@rushstack/terminal'; + +export class EngineTerminalProvider implements ITerminalProvider { + public readonly supportsColor: boolean = false; + public readonly eolCharacter: string = '\n'; + readonly #messages: Array<{ text: string; severity: TerminalProviderSeverity }> = []; + #graph: (IOperationGraph & { eventSink?: _IOperationGraphEventSink }) | undefined; + #executing: boolean = false; + + public write(text: string, severity: TerminalProviderSeverity): void { + if (this.#executing) this.#emit(text, severity); + else this.#messages.push({ text, severity }); + } + + public describeError(error: unknown): string { + return [ + ...this.#messages.map(({ text }) => text), + error instanceof Error ? error.message : String(error) + ].join('\n'); + } + + public attach(graph: IOperationGraph): void { + if (!('eventSink' in graph)) + throw new Error('The native graph does not expose its operation event sink.'); + this.#graph = graph as IOperationGraph & { eventSink?: _IOperationGraphEventSink }; + // Snapshot diagnostics also belong to no-op requests, which never reach beforeExecuteIterationAsync. + graph.hooks.configureIteration.tap({ name: 'DaemonEngineTerminal', stage: Infinity }, () => { + for (const message of this.#messages.splice(0)) this.#emit(message.text, message.severity); + }); + graph.hooks.beforeExecuteIterationAsync.tap({ name: 'DaemonEngineTerminal', stage: -Infinity }, () => { + this.#executing = true; + for (const message of this.#messages.splice(0)) this.#emit(message.text, message.severity); + }); + graph.hooks.afterExecuteIterationAsync.tap( + { name: 'DaemonEngineTerminal', stage: Infinity }, + (status) => { + this.#executing = false; + return status; + } + ); + } + + #emit(text: string, severity: TerminalProviderSeverity): void { + if ( + !this.#graph?.debugMode && + (severity === TerminalProviderSeverity.verbose || severity === TerminalProviderSeverity.debug) + ) { + return; + } + this.#graph?.eventSink?.onActivity?.(text, { + stderr: severity === TerminalProviderSeverity.error || severity === TerminalProviderSeverity.warning + }); + } +} diff --git a/libraries/rush-daemon/src/GlobalCommandExecutionContext.ts b/libraries/rush-daemon/src/GlobalCommandExecutionContext.ts index c9ee9425d8..faa9ef0c13 100644 --- a/libraries/rush-daemon/src/GlobalCommandExecutionContext.ts +++ b/libraries/rush-daemon/src/GlobalCommandExecutionContext.ts @@ -3,6 +3,7 @@ import * as childProcess from 'node:child_process'; import { EOL } from 'node:os'; +import { finished } from 'node:stream/promises'; import { SubprocessTerminator } from '@rushstack/node-core-library'; import { Terminal, TerminalProviderSeverity } from '@rushstack/terminal'; @@ -10,6 +11,8 @@ import type { ITerminal, ITerminalProvider } from '@rushstack/terminal'; import { createGlobalCommandEnvironment, + resolveGlobalCommandEnvironment, + resolveGlobalCommandWorkingDirectory, type IGlobalCommandEnvironment, type IGlobalCommandTerminalProperties, type IResolvedGlobalCommandRequest @@ -20,6 +23,8 @@ import type { IInteractiveRequestSession } from './InteractiveRequestInputRouter'; import type { IWorkspaceSession } from './WorkspaceSession'; +import { waitForLinuxProcessGroupExitAsync } from './LinuxProcessGroupExit'; +import { recordWorkspaceRequestCleanupFailure } from './WorkspaceRequestResources'; const MAX_PENDING_TERMINAL_BYTES: number = 1024 * 1024; @@ -29,6 +34,10 @@ const MAX_PENDING_TERMINAL_BYTES: number = 1024 * 1024; * @beta */ export interface IGlobalCommandSpawnOptions { + /** An absolute directory, physically confined to the workspace. Explicit Windows path spelling is preserved. */ + readonly cwd?: string; + /** A complete child environment; cannot be combined with environmentOverlay. */ + readonly environment?: Readonly; readonly environmentOverlay?: Readonly; readonly forwardInput?: boolean; readonly forwardOutput?: boolean; @@ -51,11 +60,13 @@ export interface IGlobalCommandExecutionContext { readonly workspaceSession: IWorkspaceSession; registerDisposable(disposable: AsyncDisposable): void; + /** Queues raw request-owned output without terminal color or newline transformations. */ + writeOutput(stream: 'stdout' | 'stderr', chunk: Uint8Array): void; /** * Spawns a child process whose process tree is owned by this request. * * @remarks - * Descendants must not detach from the spawned child's process tree. On Windows, the spawned child must remain active + * Descendants must remain in the spawned child's POSIX process group. On Windows, the spawned child must remain active * until all of its descendants exit because a descendant cannot be recovered after its parent exits. */ spawnChild( @@ -161,6 +172,7 @@ export class GlobalCommandExecutionContext readonly #childCompletionErrors: unknown[] = []; readonly #childTerminationErrors: unknown[] = []; readonly #writer: OrderedTerminalWriter; + #disposePromise: Promise | undefined; #closed: boolean = false; #requestAborted: boolean = false; @@ -214,6 +226,11 @@ export class GlobalCommandExecutionContext this.#disposables.push(disposable); } + public writeOutput(stream: 'stdout' | 'stderr', chunk: Uint8Array): void { + this.#throwIfClosed(); + this.#writer.write(stream, chunk); + } + public spawnChild( command: string, args: ReadonlyArray, @@ -223,10 +240,21 @@ export class GlobalCommandExecutionContext if (this.abortSignal.aborted) { throw this.abortSignal.reason ?? new Error('The global command request was aborted.'); } + if (options.environment !== undefined && options.environmentOverlay !== undefined) { + throw new Error('A child environment and environmentOverlay cannot be combined.'); + } + if (options.forwardInput && !this.interactiveInput) { + throw new Error('The global command did not register an interactive input session.'); + } + const canonicalCwd: string = resolveGlobalCommandWorkingDirectory( + options.cwd ?? this.cwd, this.workspaceSession + ); const child: childProcess.ChildProcessWithoutNullStreams = childProcess.spawn(command, [...args], { - cwd: this.cwd, + cwd: process.platform === 'win32' ? options.cwd ?? canonicalCwd : canonicalCwd, detached: SubprocessTerminator.RECOMMENDED_OPTIONS.detached, - env: createGlobalCommandEnvironment(this.environment, options.environmentOverlay), + env: options.environment === undefined + ? createGlobalCommandEnvironment(this.environment, options.environmentOverlay) + : resolveGlobalCommandEnvironment(options.environment), shell: options.shell, stdio: 'pipe', windowsHide: options.windowsHide @@ -254,22 +282,32 @@ export class GlobalCommandExecutionContext throw new Error('The global command did not register an interactive input session.'); } const sink: IInteractiveRequestInputSink = { - writeInputAsync: (chunk: Uint8Array): Promise => writeChildInputAsync(child, chunk) + writeInputAsync: (chunk: Uint8Array): Promise => writeChildInputAsync(child, chunk), + endInputAsync: (): Promise => endChildInputAsync(child) }; + const onInputError = (error: Error): void => { + if (!isClosedChildInputError(error)) this.#abortRequest(error); + }; + child.stdin.on('error', onInputError); const attachment: Disposable = this.interactiveInput.attachInputSink(sink); this.registerDisposable({ [Symbol.asyncDispose]: (): Promise => { attachment[Symbol.dispose](); + child.stdin.removeListener('error', onInputError); return Promise.resolve(); } }); } - public async [Symbol.asyncDispose](): Promise { - if (this.#closed) { - return; + public [Symbol.asyncDispose](): Promise { + if (!this.#disposePromise) { + this.#closed = true; + this.#disposePromise = Promise.resolve().then(() => this.#disposeOnceAsync()); } - this.#closed = true; + return this.#disposePromise; + } + + async #disposeOnceAsync(): Promise { this.#abortController.abort(new Error('The global command execution context was disposed.')); const cleanupErrors: unknown[] = []; try { @@ -282,7 +320,11 @@ export class GlobalCommandExecutionContext cleanupErrors.push(...this.#childTerminationErrors); for (const disposable of this.#disposables.reverse()) { await collectCleanupErrorAsync( - Promise.resolve().then(() => disposable[Symbol.asyncDispose]()), + Promise.resolve() + .then(() => disposable[Symbol.asyncDispose]()) + .catch((error: unknown) => { + throw this.#recordResourceCleanupFailure(error); + }), cleanupErrors ); } @@ -303,26 +345,39 @@ export class GlobalCommandExecutionContext try { SubprocessTerminator.killProcessTree(child, SubprocessTerminator.RECOMMENDED_OPTIONS); } catch (error) { - this.#childTerminationErrors.push(error); - child.kill('SIGKILL'); + this.#childTerminationErrors.push(this.#recordResourceCleanupFailure(error)); + try { + child.kill('SIGKILL'); + } catch (fallbackError) { + this.#childTerminationErrors.push(this.#recordResourceCleanupFailure(fallbackError)); + } } }; this.abortSignal.addEventListener('abort', terminateChild, { once: true }); + let childError: Error | undefined; try { - await new Promise((resolve, reject) => { - child.once('error', reject); - child.once('close', () => { - try { - terminateExitedChildProcessGroup(child); - resolve(); - } catch (error) { - reject(error); - } + await new Promise((resolve) => { + child.once('error', (error: Error) => { + childError = error; }); + child.once('close', () => resolve()); }); } finally { this.abortSignal.removeEventListener('abort', terminateChild); } + try { + terminateExitedChildProcessGroup(child); + if (process.platform === 'linux' && child.pid !== undefined) { + await waitForLinuxProcessGroupExitAsync(child.pid); + } + } catch (error) { + throw this.#recordResourceCleanupFailure(error); + } + if (childError) throw childError; + } + + #recordResourceCleanupFailure(error: unknown): Error { + return recordWorkspaceRequestCleanupFailure(this.workspaceSession, this.#request.requestId, error); } #forwardChildOutput( @@ -382,13 +437,25 @@ function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +async function endChildInputAsync(child: childProcess.ChildProcessWithoutNullStreams): Promise { + if (child.stdin.destroyed) return; + const completion: Promise = finished(child.stdin, { cleanup: true }); + child.stdin.end(); + try { + await completion; + } catch (error) { + if (!isClosedChildInputError(error)) throw error; + } +} + function writeChildInputAsync( child: childProcess.ChildProcessWithoutNullStreams, chunk: Uint8Array ): Promise { + if (child.stdin.destroyed) return Promise.resolve(); return new Promise((resolve, reject) => { child.stdin.write(chunk, (error: Error | null | undefined) => { - if (error) { + if (error && !isClosedChildInputError(error)) { reject(error); } else { resolve(); @@ -396,3 +463,9 @@ function writeChildInputAsync( }); }); } + +function isClosedChildInputError(error: unknown): boolean { + // A script may close its pipe without reading all input. Preserve its exit status, as inherited stdin does. + const code: string | undefined = (error as NodeJS.ErrnoException).code; + return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED' || code === 'ERR_STREAM_PREMATURE_CLOSE'; +} diff --git a/libraries/rush-daemon/src/GlobalCommandRequest.ts b/libraries/rush-daemon/src/GlobalCommandRequest.ts index 539b0ddcb4..2b89c40d12 100644 --- a/libraries/rush-daemon/src/GlobalCommandRequest.ts +++ b/libraries/rush-daemon/src/GlobalCommandRequest.ts @@ -105,9 +105,7 @@ export function resolveGlobalCommandRequest( validateNonemptyName(options.commandName, 'command name'); validateCommandOrigin(options.commandOrigin); validateDaemonRequestAdmissionOptions(options.admission); - const repoRoot: string = getCanonicalDirectory(workspaceSession.metadata.repoRoot, 'workspace root'); - const cwd: string = getCanonicalDirectory(options.cwd, 'working directory'); - validatePathWithinWorkspace(cwd, repoRoot); + const cwd: string = resolveGlobalCommandWorkingDirectory(options.cwd, workspaceSession); const request: IResolvedGlobalCommandRequest = Object.freeze({ admission: options.admission ? Object.freeze({ ...options.admission }) : undefined, commandName: options.commandName, @@ -121,6 +119,23 @@ export function resolveGlobalCommandRequest( return request; } +export function resolveGlobalCommandWorkingDirectory( + folder: string, + workspaceSession: IWorkspaceSession +): string { + if (!path.isAbsolute(folder)) { + throw new Error('The global command working directory must be absolute.'); + } + const repoRoot: string = getCanonicalDirectory(workspaceSession.metadata.repoRoot, 'workspace root'); + const cwd: string = getCanonicalDirectory(folder, 'working directory'); + validatePathWithinWorkspace(cwd, repoRoot); + return cwd; +} + +export function resolveGlobalCommandEnvironment(environment: Readonly): NodeJS.ProcessEnv { + return createEnvironmentMap(environment).toObject(); +} + function validateCommandOrigin(value: DaemonRushCommandOrigin): void { if (value !== 'built-in' && value !== 'custom') { throw new Error('Global command origin is not recognized.'); diff --git a/libraries/rush-daemon/src/GlobalCommandRequestRouter.ts b/libraries/rush-daemon/src/GlobalCommandRequestRouter.ts index acb8d70626..385e7440e7 100644 --- a/libraries/rush-daemon/src/GlobalCommandRequestRouter.ts +++ b/libraries/rush-daemon/src/GlobalCommandRequestRouter.ts @@ -33,6 +33,7 @@ import { RequestSchedulerErrorCode } from './RequestScheduler'; import type { IWorkspaceSession } from './WorkspaceSession'; +import { assertWorkspaceRequestResourcesHealthy } from './WorkspaceRequestResources'; /** * Executes caller-resolved global command logic. @@ -143,6 +144,8 @@ async function executeAdmittedAsync( interactiveSession: IInteractiveRequestSession | undefined, workspaceSession: IWorkspaceSession ): Promise { + assertWorkspaceRequestResourcesHealthy(workspaceSession); + workspaceSession.assertActive?.(); const context: GlobalCommandExecutionContext = new GlobalCommandExecutionContext( request, client, diff --git a/libraries/rush-daemon/src/InteractiveRequestInputRouter.ts b/libraries/rush-daemon/src/InteractiveRequestInputRouter.ts index 16ee7ec3ab..141f0d353e 100644 --- a/libraries/rush-daemon/src/InteractiveRequestInputRouter.ts +++ b/libraries/rush-daemon/src/InteractiveRequestInputRouter.ts @@ -12,6 +12,7 @@ export type InteractiveInputRoutingErrorCode = | 'unknownRequest' | 'completedRequest' | 'nonInteractiveRequest' + | 'inputEnded' | 'requestLimitExceeded'; /** A request-scoped stdin routing failure. @beta */ @@ -28,6 +29,8 @@ export class InteractiveInputRoutingError extends Error { /** A backpressured destination for arbitrary stdin bytes. @beta */ export interface IInteractiveRequestInputSink { writeInputAsync(chunk: Uint8Array): Promise; + /** Delivers EOF after preceding writes. Required when accepting input that can end. */ + endInputAsync?(): Promise; } /** A client-side terminal control destination for one daemon request. @beta */ @@ -35,6 +38,8 @@ export interface IInteractiveRequestControlClient { readonly abortSignal: AbortSignal; /** Resolves after the thin client acknowledges that it applied the requested terminal state. */ writeRawModeControlAsync(message: IDaemonSetRawModeMessage): Promise; + /** Available only after stdin-lifecycle capability negotiation. */ + writeInputReadyAsync?(requestId: string): Promise; } /** Registration options for one request on a connection-scoped input router. @beta */ @@ -61,6 +66,8 @@ interface IRequestState { accepting: boolean; failure: Error | undefined; inputSink: IInteractiveRequestInputSink | undefined; + inputEnded: boolean; + inputReady: boolean; inputTail: Promise; pendingInputBytes: number; pendingInputFrameCount: number; @@ -106,6 +113,15 @@ class InteractiveRequestSession implements IInteractiveRequestSession { for (const waiter of state.sinkWaiters.splice(0)) { waiter.resolve(sink); } + const writeInputReadyAsync: IInteractiveRequestControlClient['writeInputReadyAsync'] = + state.client.writeInputReadyAsync; + if (!state.inputReady && writeInputReadyAsync) { + state.inputReady = true; + const readyPromise: Promise = state.inputTail.then(() => + writeInputReadyAsync.call(state.client, state.requestId) + ); + state.inputTail = readyPromise.catch((error: unknown) => failState(state, error)); + } return { [Symbol.dispose]: (): void => { if (state.inputSink === sink) { @@ -176,6 +192,23 @@ export class InteractiveRequestInputRouter { public async routeStdinFrameAsync(payload: Uint8Array): Promise { const { chunk, requestId } = decodeDaemonStdinChunk(payload); + await queueInputAsync(this.#getRequestState(requestId), chunk); + } + + /** Queues EOF behind already accepted input without blocking other requests or cancellation. */ + public routeStdinEndAsync(requestId: string): Promise { + const state: IRequestState = this.#getRequestState(requestId); + const endPromise: Promise = queueInputActionAsync(state, async (sink) => { + if (!sink.endInputAsync) { + throw new Error(`Input destination for request "${requestId}" does not support EOF.`); + } + await sink.endInputAsync(); + }, 0); + state.inputEnded = true; + return endPromise; + } + + #getRequestState(requestId: string): IRequestState { const state: IRequestState | undefined = this.#stateByRequestId.get(requestId); if (!state) { if (this.#completedRequestIds.has(requestId)) { @@ -183,7 +216,7 @@ export class InteractiveRequestInputRouter { } throw createRoutingError('unknownRequest', requestId); } - await queueInputAsync(state, chunk); + return state; } #completeRequest(requestId: string, state: IRequestState): void { @@ -208,6 +241,8 @@ function createRequestState(options: IInteractiveRequestRegistrationOptions): IR client: options.client, failure: undefined, inputSink: undefined, + inputEnded: false, + inputReady: false, inputTail: Promise.resolve(), onAbort: () => stopAcceptingInput(state), onFailure: options.onFailure, @@ -223,14 +258,26 @@ function createRequestState(options: IInteractiveRequestRegistrationOptions): IR } function queueInputAsync(state: IRequestState, chunk: Uint8Array): Promise { + return queueInputActionAsync(state, async (sink) => { + await sink.writeInputAsync(chunk); + await state.client.writeInputReadyAsync?.(state.requestId); + }, chunk.byteLength); +} + +function queueInputActionAsync( + state: IRequestState, + actionAsync: (sink: IInteractiveRequestInputSink) => Promise, + byteLength: number +): Promise { assertAcceptingInput(state); - reserveInputCapacity(state, chunk.byteLength); + if (state.inputEnded) throw createRoutingError('inputEnded', state.requestId); + reserveInputCapacity(state, byteLength); const writePromise: Promise = state.inputTail.then(async () => { assertAcceptingInput(state); const sink: IInteractiveRequestInputSink = await getInputSinkAsync(state); assertAcceptingInput(state); try { - await sink.writeInputAsync(chunk); + await actionAsync(sink); } catch (error) { const normalizedError: Error = createInputFailure(error); failState(state, normalizedError); @@ -238,7 +285,7 @@ function queueInputAsync(state: IRequestState, chunk: Uint8Array): Promise } }); const trackedPromise: Promise = writePromise.finally(() => - releaseInputCapacity(state, chunk.byteLength) + releaseInputCapacity(state, byteLength) ); state.inputTail = trackedPromise.catch((error: unknown) => handleQueuedInputError(state, error)); return trackedPromise; diff --git a/libraries/rush-daemon/src/LinuxProcessGroupExit.ts b/libraries/rush-daemon/src/LinuxProcessGroupExit.ts new file mode 100644 index 0000000000..5aa60f1c32 --- /dev/null +++ b/libraries/rush-daemon/src/LinuxProcessGroupExit.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { execFile } from 'node:child_process'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +const DEFAULT_EXIT_TIMEOUT_MS: number = 5_000; +const EXIT_POLL_INTERVAL_MS: number = 10; +const MAX_TIMEOUT_MS: number = 0x7fffffff; + +/** Waits for a captured detached Linux group/session to disappear or contain only zombies. */ +export async function waitForLinuxProcessGroupExitAsync( + groupId: number, + timeoutMs: number = DEFAULT_EXIT_TIMEOUT_MS +): Promise { + if (!Number.isSafeInteger(groupId) || groupId <= 0 || groupId === process.pid) { + throw new RangeError('Expected an owned child process group ID.'); + } + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) { + throw new RangeError('Expected a positive bounded process group exit timeout.'); + } + const deadline: number = Date.now() + timeoutMs; + while (processGroupExists(groupId)) { + const remaining: number = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Owned Linux process group ${groupId} did not exit within ${timeoutMs}ms.`); + } + const states: string[] = await readSessionStatesAsync(groupId, remaining); + if (states.length > 0 && states.every((state) => state.startsWith('Z'))) return; + await delayAsync(Math.min(EXIT_POLL_INTERVAL_MS, remaining)); + } +} + +function processGroupExists(groupId: number): boolean { + try { + // Probe only. Never signal a potentially reused group while waiting for cleanup. + process.kill(-groupId, 0); + return true; + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') { + return false; + } + throw error; + } +} + +function readSessionStatesAsync(groupId: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + // detached=true creates a new process group and session with the child's PID. + execFile( + 'ps', + ['--sid', String(groupId), '-o', 'stat='], + { + encoding: 'utf8', + timeout: timeoutMs + }, + (error, stdout, stderr) => { + if (error && !(error.code === 1 && !stdout.trim() && !stderr.trim())) { + reject(error); + } else if (stderr.trim()) { + reject(new Error(`Cannot inspect owned Linux process group ${groupId}: ${stderr.trim()}`)); + } else { + resolve(stdout.trim() ? stdout.trim().split(/\s+/) : []); + } + } + ); + }); +} diff --git a/libraries/rush-daemon/src/NativeMutationRequest.ts b/libraries/rush-daemon/src/NativeMutationRequest.ts new file mode 100644 index 0000000000..d22c58e74c --- /dev/null +++ b/libraries/rush-daemon/src/NativeMutationRequest.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRequestResolver } from './DaemonRequestDispatcher'; +import type { IGlobalCommandExecutionContext } from './GlobalCommandExecutionContext'; +import type { GlobalCommandExecutor } from './GlobalCommandRequestRouter'; + +export function createNativeMutationResolver( + envelope: IDaemonRequestEnvelope, + rushVersion: string, + onCompletedAsync: (context: IGlobalCommandExecutionContext) => Promise +): IDaemonRequestResolver { + if ( + envelope.commandOrigin !== 'built-in' || + !['install', 'update'].includes(envelope.commandName) || + envelope.argv[0] !== envelope.commandName + ) { + throw new Error('A native mutation must explicitly identify install/update and matching argv.'); + } + const executorAsync: GlobalCommandExecutor = async (context) => { + const forwardInput: boolean = envelope.terminal.acceptsStdin === true; + const child: ChildProcessWithoutNullStreams = context.spawnChild( + process.execPath, + [path.join(__dirname, 'NativeMutationWorker.js'), rushVersion, context.cwd, ...envelope.argv], + { forwardInput } + ); + if (!forwardInput) child.stdin.end(); + const exitCode: number = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => { + if (code === null) + reject(new Error(`Native mutation worker terminated by ${signal ?? 'an unknown signal'}.`)); + else resolve(code); + }); + }); + await onCompletedAsync(context); + return { exitCode }; + }; + return { + resolveRequestAsync: async () => ({ kind: 'global', executor: executorAsync }) + }; +} diff --git a/libraries/rush-daemon/src/NativeMutationWorker.ts b/libraries/rush-daemon/src/NativeMutationWorker.ts new file mode 100644 index 0000000000..dfa40d506e --- /dev/null +++ b/libraries/rush-daemon/src/NativeMutationWorker.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Rush } from '@microsoft/rush-lib'; +import { RushCommandLineParser } from '@microsoft/rush-lib/lib/cli/RushCommandLineParser'; + +/** + * This entrypoint is deliberately a separate, single-shot process. It is not used for phased builds. + * Native install/update retain their normal parsing, policy, hooks, cwd and environment behavior here. + */ +export async function runNativeMutationWorkerAsync(args: ReadonlyArray): Promise { + const [rushVersion, cwd, commandName, ...commandArgs] = args; + if (rushVersion !== Rush.version) { + throw new Error(`Mutation worker contains Rush ${Rush.version}, not requested Rush ${rushVersion}.`); + } + if (!cwd || (commandName !== 'install' && commandName !== 'update')) { + throw new Error('The native mutation worker accepts install or update only.'); + } + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd }); + if (!(await parser.executeAsync([commandName, ...commandArgs]))) process.exitCode ||= 1; +} + +if (require.main === module) { + void runNativeMutationWorkerAsync(process.argv.slice(2)).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/libraries/rush-daemon/src/PhasedRequestRouter.ts b/libraries/rush-daemon/src/PhasedRequestRouter.ts index bc21068abd..c7ba6217c4 100644 --- a/libraries/rush-daemon/src/PhasedRequestRouter.ts +++ b/libraries/rush-daemon/src/PhasedRequestRouter.ts @@ -53,6 +53,7 @@ interface IResolvedSelection { readonly activeOperations: ReadonlyArray; readonly enabledOperations: ReadonlyArray; readonly ignoreDependencyOperations: ReadonlyArray; + readonly exact: boolean; } interface IGraphRoutingState { @@ -61,6 +62,7 @@ interface IGraphRoutingState { } interface IPreparedPhasedRequest { + readonly onExecutionStarting: (() => void) | undefined; readonly client: IPhasedRequestClient; readonly exclusivityClass: RequestExclusivityClass; readonly interactiveSession: IInteractiveRequestSession | undefined; @@ -109,7 +111,9 @@ export class PhasedRequestRouter { /** Validates and executes one resolved phased request against the warm graph. */ public async executeAsync( request: IDaemonPhasedRequest, - client: IPhasedRequestClient + client: IPhasedRequestClient, + exactSelection: boolean = false, + onExecutionStarting?: () => void ): Promise { validateRequestIdentity(request); const interactiveSession: IInteractiveRequestSession | undefined = validateInteractiveSession( @@ -155,7 +159,11 @@ export class PhasedRequestRouter { try { validateEngineShape(request.engineShape, this.#workspaceSession.engineShape); const operationById: ReadonlyMap = indexOperations(graph.operations); - const selection: IResolvedSelection = resolveSelection(request.operationSelection, operationById); + const selection: IResolvedSelection = resolveSelection( + request.operationSelection, + operationById, + exactSelection + ); let warningsAllowedByEnvironment: boolean; try { warningsAllowedByEnvironment = parseWarningsAllowedByEnvironment(request.environment); @@ -184,7 +192,8 @@ export class PhasedRequestRouter { interactiveSession, request, selection, - warningsAllowedByEnvironment + warningsAllowedByEnvironment, + onExecutionStarting }, admissionController ); @@ -356,10 +365,19 @@ class PhasedRequestBatchCoordinator { }); this.#nextGraphLeasePromise = undefined; const graphLease: IRequestLease = await graphLeasePromise; + let executionLease: AsyncDisposable | undefined; + let releasePromise: Promise | undefined; + const releaseExecutionLeaseAsync: () => Promise = () => { + releasePromise ??= (async () => { + await executionLease?.[Symbol.asyncDispose](); + })(); + return releasePromise; + }; try { if (this.#graph.hasScheduledIteration || this.#graph.status === OperationStatus.Executing) { throw new Error('The warm workspace operation graph is not idle.'); } + executionLease = await this.#workspaceSession.acquireExecutionLeaseAsync?.(); await this.#workspaceSession.reconcileInvalidationsAsync(); if (batch[0].exclusivityClass === RequestExclusivityClass.SharedBuild) { @@ -370,8 +388,13 @@ class PhasedRequestBatchCoordinator { this.#isEntryLive(entry) ); if (participants.length === 0) { + const beforeResultAsync: (() => Promise) | undefined = executionLease + ? createBatchReleaseBarrier(batch, releaseExecutionLeaseAsync) + : undefined; await Promise.all( - batch.map((entry: IBatchEntry) => this.#finishEntryAsync(entry, false, undefined)) + batch.map((entry: IBatchEntry) => + this.#finishEntryAsync(entry, false, undefined, [], beforeResultAsync) + ) ); return; } @@ -401,6 +424,7 @@ class PhasedRequestBatchCoordinator { let executionError: unknown; const iterationCleanupErrors: unknown[] = []; try { + for (const entry of participants) entry.onExecutionStarting?.(); scheduled = await this.#graph.scheduleIterationAsync({ inputsSnapshot: this.#workspaceSession.inputsSnapshot }); @@ -447,13 +471,20 @@ class PhasedRequestBatchCoordinator { await this.#abortTail; iterationCleanupErrors.push(...this.#abortErrors.splice(0)); + const beforeResultAsync: (() => Promise) | undefined = executionLease + ? createBatchReleaseBarrier(batch, releaseExecutionLeaseAsync) + : undefined; await Promise.all( batch.map((entry: IBatchEntry) => - this.#finishEntryAsync(entry, scheduled, executionError, iterationCleanupErrors) + this.#finishEntryAsync(entry, scheduled, executionError, iterationCleanupErrors, beforeResultAsync) ) ); } finally { - graphLease.release(); + try { + await releaseExecutionLeaseAsync(); + } finally { + graphLease.release(); + } } } @@ -508,7 +539,8 @@ class PhasedRequestBatchCoordinator { entry: IBatchEntry, batchScheduled: boolean, executionError: unknown, - batchCleanupErrors: ReadonlyArray = [] + batchCleanupErrors: ReadonlyArray = [], + beforeResultAsync?: () => Promise ): Promise { if (entry.completed) { return; @@ -522,6 +554,11 @@ class PhasedRequestBatchCoordinator { } } await collectInteractiveCleanupErrorAsync(entry.interactiveSession, cleanupErrors); + try { + await beforeResultAsync?.(); + } catch (error) { + cleanupErrors.push(error); + } const aborted: boolean = entry.abortRequested || entry.client.abortSignal.aborted; const operationOutcomes: ReadonlyArray = entry.requestSink ? collectOperationOutcomes( @@ -577,6 +614,23 @@ class PhasedRequestBatchCoordinator { } } +function createBatchReleaseBarrier( + batch: ReadonlyArray, + releaseAsync: () => Promise +): () => Promise { + let remaining: number = batch.filter((entry) => !entry.completed).length; + if (remaining === 0) return releaseAsync; + let arrive: () => void = () => undefined; + const allDrained: Promise = new Promise((resolve) => { + arrive = resolve; + }); + const released: Promise = allDrained.then(releaseAsync); + return async () => { + if (--remaining === 0) arrive(); + await released; + }; +} + function getDualEmitGraph(workspaceSession: IWorkspaceSession): IDualEmitOperationGraph { const graph: IOperationGraph | undefined = workspaceSession.operationGraph; if (!graph) { @@ -599,7 +653,8 @@ function setGraphEventSink( graph.eventSink = eventSink; } -function setPauseNextIteration(graph: IOperationGraph, pauseNextIteration: boolean): void { +/** Changes native manual mode while the caller owns graph admission. @internal */ +export function setPauseNextIteration(graph: IOperationGraph, pauseNextIteration: boolean): void { graph.pauseNextIteration = pauseNextIteration; } @@ -705,9 +760,10 @@ function indexOperations(operations: ReadonlySet): ReadonlyMap, - operationById: ReadonlyMap + operationById: ReadonlyMap, + exact: boolean ): IResolvedSelection { - if (requestedSelection.length === 0) { + if (!exact && requestedSelection.length === 0) { throw new Error('A phased request must select at least one operation.'); } const selectedIds: Set = new Set(); @@ -726,9 +782,12 @@ function resolveSelection( addSelectedOperation(selection.enabledState, operation, enabledOperations, ignoreDependencyOperations); } return { - activeOperations: collectSelectionClosure(enabledOperations, ignoreDependencyOperations), + activeOperations: exact + ? [...enabledOperations, ...ignoreDependencyOperations] + : collectSelectionClosure(enabledOperations, ignoreDependencyOperations), enabledOperations, - ignoreDependencyOperations + ignoreDependencyOperations, + exact }; } @@ -767,15 +826,13 @@ function applySelections( graph: IOperationGraph, selections: ReadonlyArray ): void { - const enabledOperations: ReadonlyArray = selections.flatMap( - (selection: IResolvedSelection) => selection.enabledOperations - ); - const ignoreDependencyOperations: ReadonlyArray = selections.flatMap( - (selection: IResolvedSelection) => selection.ignoreDependencyOperations - ); const enabledClosureBySelection: ReadonlyArray> = selections.map( (selection: IResolvedSelection) => - new Set(collectSelectionClosure(selection.enabledOperations, [])) + new Set( + selection.exact + ? selection.enabledOperations + : collectSelectionClosure(selection.enabledOperations, []) + ) ); const effectiveIgnoreDependencyOperations: Operation[] = []; selections.forEach((selection: IResolvedSelection, selectionIndex: number) => { @@ -790,8 +847,16 @@ function applySelections( } }); graph.setEnabledStates(graph.operations, false, 'unsafe'); - graph.setEnabledStates(ignoreDependencyOperations, 'ignore-dependency-changes', 'safe'); - graph.setEnabledStates(enabledOperations, true, 'safe'); + for (const selection of selections) { + graph.setEnabledStates( + selection.ignoreDependencyOperations, + 'ignore-dependency-changes', + selection.exact ? 'unsafe' : 'safe' + ); + } + for (const selection of selections) { + graph.setEnabledStates(selection.enabledOperations, true, selection.exact ? 'unsafe' : 'safe'); + } graph.setEnabledStates( effectiveIgnoreDependencyOperations, 'ignore-dependency-changes', diff --git a/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts new file mode 100644 index 0000000000..ce9310746a --- /dev/null +++ b/libraries/rush-daemon/src/ProductionDaemonRequestResolver.ts @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import type { LockFile } from '@rushstack/node-core-library'; +import { + PhasedCommandEngine, + PhasedCommandEngineBusyError, + PhasedCommandEngineConfigurationChangedError, + type IPhasedCommandEngine, + type IInputsSnapshot, + type IOperationGraph, + type Operation, + type OperationEnabledState +} from '@microsoft/rush-lib'; +import type { IDaemonPhasedOperationSelection } from '@rushstack/rush-daemon-protocol'; + +import { + DaemonRequestDispatchError, + type IDaemonRequestResolver, + type IResolveDaemonRequestOptions, + type ResolvedDaemonRequest +} from './DaemonRequestDispatcher'; +import { + WorkspaceEngineComponentFactory, + WorkspaceEngineRecreationRequiredError, + type IMapWorkspaceInvalidationsOptions, + type IWorkspaceEngineShape, + type IWorkspaceInvalidationReconciliation +} from './WorkspaceEngineComponentFactory'; +import type { IWorkspaceSession, IWorkspaceSessionComponents } from './WorkspaceSession'; +import { EngineTerminalProvider } from './EngineTerminalProvider'; +import type { IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; + +/** + * Binds the standalone host to a real native build/rebuild graph on its first request. + * + * @remarks + * A host is pinned to its first command and non-selection parameters. Incompatible parameters, + * environments, or graph inputs are rejected before scheduling; no request is retried automatically. + * The initial supported surface excludes external plugins, .env initialization, install/watch, + * event-hook scripts, and rushx/global commands. Use the unchanged native CLI for those surfaces. + * @beta + */ +export class ProductionDaemonRequestResolver implements IDaemonRequestResolver { + #binding: Promise | undefined; + #parameterIdentity: string | undefined; + #workspaceSession: IWorkspaceSession | undefined; + readonly #environmentIdentity: string = environmentIdentity(process.env); + readonly #preparationLock: LockFile | undefined; + readonly #validateGraphInputsAsync: (() => Promise) | undefined; + + public constructor(options?: { + readonly preparationLock?: LockFile; + readonly validateGraphInputsAsync?: () => Promise; + }) { + this.#preparationLock = options?.preparationLock; + this.#validateGraphInputsAsync = options?.validateGraphInputsAsync; + } + + public get workspaceLifecycle(): IWorkspaceResolverLifecycle { + return this; + } + + /** Creates an unbound resolver for a replacement session without carrying old runner definitions. */ + public createForSession( + preparationLock?: LockFile, + validateGraphInputsAsync?: () => Promise + ): ProductionDaemonRequestResolver { + return new ProductionDaemonRequestResolver({ preparationLock, validateGraphInputsAsync }); + } + + /** Inspects the native command shape without constructing or executing an operation graph. */ + public async getCommandParameterIdentityAsync(options: IResolveDaemonRequestOptions): Promise { + return (await this.#parseCommandAsync(options, new EngineTerminalProvider())).parameterIdentity; + } + + public async resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise { + const { envelope, workspaceSession } = options; + const terminal: EngineTerminalProvider = new EngineTerminalProvider(); + const command: PhasedCommandEngine = await this.#parseCommandAsync(options, terminal); + if (this.#binding) { + if ( + this.#parameterIdentity !== command.parameterIdentity || + this.#workspaceSession !== workspaceSession + ) { + throw new WorkspaceEngineRecreationRequiredError(); + } + } else { + this.#parameterIdentity = command.parameterIdentity; + this.#workspaceSession = workspaceSession; + const binding: Promise = this.#bindAsync(command, terminal, workspaceSession); + this.#binding = binding; + void binding.catch((error: unknown) => { + if (error instanceof PhasedCommandEngineBusyError && this.#binding === binding) { + this.#binding = undefined; + this.#parameterIdentity = undefined; + this.#workspaceSession = undefined; + } + }); + } + await this.#binding; + const graph: IOperationGraph | undefined = workspaceSession.operationGraph; + const shape: IWorkspaceEngineShape | undefined = workspaceSession.engineShape; + if (!graph || !shape) throw new Error('Native engine initialization did not bind a workspace graph.'); + let selection: ReadonlyMap; + try { + selection = await command.selectOperationsAsync(graph); + } catch (error) { + throw new DaemonRequestDispatchError('invalidRequest', terminal.describeError(error), { cause: error }); + } + const operationSelection: IDaemonPhasedOperationSelection[] = []; + for (const [operation, enabledState] of selection) { + if (enabledState !== false) operationSelection.push({ operationId: operation.name, enabledState }); + } + return { + kind: 'phased', + exactSelection: true, + request: { + admission: envelope.admission, + commandName: envelope.commandName, + commandOrigin: envelope.commandOrigin, + engineShape: shape, + environment: envelope.environment, + operationSelection, + requestId: envelope.requestId, + terminalRequirement: envelope.terminal.terminalRequirement + } + }; + } + + async #parseCommandAsync( + options: IResolveDaemonRequestOptions, + terminal: EngineTerminalProvider + ): Promise { + const { envelope, workspaceSession, abortSignal } = options; + if (!['build', 'rebuild'].includes(envelope.commandName) || envelope.commandOrigin !== 'built-in') { + throw new DaemonRequestDispatchError( + 'unsupported', + 'The production daemon requires an explicitly identified native build/rebuild request. Ambiguous custom/rushx requests require --no-daemon.' + ); + } + if ( + environmentIdentity(envelope.environment) !== this.#environmentIdentity || + environmentIdentity(process.env) !== this.#environmentIdentity + ) { + throw new DaemonRequestDispatchError( + 'unsupported', + 'The request environment differs from the daemon startup environment. Restart the daemon from this environment or use --no-daemon.' + ); + } + let command: PhasedCommandEngine; + try { + command = await PhasedCommandEngine.parseAsync({ + argv: envelope.argv, + cwd: envelope.cwd, + rushConfiguration: workspaceSession.rushConfiguration, + terminalProvider: terminal + }); + } catch (error) { + throw new DaemonRequestDispatchError('unsupported', terminal.describeError(error), { cause: error }); + } + if (command.commandName !== envelope.commandName) { + throw new DaemonRequestDispatchError( + 'invalidRequest', + 'The command name does not match the native parsed argv.' + ); + } + if (abortSignal.aborted) + throw new DaemonRequestDispatchError( + 'routingFailed', + 'The request was cancelled before engine initialization.' + ); + return command; + } + + async #bindAsync( + command: PhasedCommandEngine, + terminal: EngineTerminalProvider, + session: IWorkspaceSession + ): Promise { + if (!session.initializeEngineAsync) throw new Error('This session cannot bind a native engine.'); + await session.initializeEngineAsync(async (options) => { + let engine: IPhasedCommandEngine; + try { + engine = await command.createEngineAsync(this.#preparationLock); + } catch (error) { + if (error instanceof PhasedCommandEngineBusyError) throw error; + throw new Error(terminal.describeError(error), { cause: error }); + } + try { + terminal.attach(engine.operationGraph); + const factory: WorkspaceEngineComponentFactory = new WorkspaceEngineComponentFactory({ + createEngineComponentsAsync: async () => ({ + ...engine, + getInputsSnapshotAsync: async () => { + let snapshot: IInputsSnapshot | undefined; + try { + snapshot = await engine.getInputsSnapshotAsync(); + } catch (error) { + if (error instanceof PhasedCommandEngineConfigurationChangedError) { + throw new WorkspaceEngineRecreationRequiredError(); + } + throw error; + } + if (snapshot && !this.#validateGraphInputsAsync) + assertCompatibleInputs(engine.inputsSnapshot, snapshot); + return snapshot; + } + }), + shape: engine, + refreshInputsOnEveryRequest: true, + validateGraphInputsAsync: this.#validateGraphInputsAsync, + mapInvalidationsToOperationsAsync: async (invalidationOptions) => + getChangedOperations(invalidationOptions) + }); + const components: IWorkspaceSessionComponents = await factory.createAsync(options); + return { + ...components, + reconcileInvalidationsAsync: async () => { + const result: IWorkspaceInvalidationReconciliation = + await components.reconcileInvalidationsAsync!(); + if (!engine.isIncremental) engine.operationGraph.invalidateOperations(undefined, 'rebuild'); + return result; + } + }; + } catch (error) { + try { + await engine[Symbol.asyncDispose](); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Failed to initialize and dispose the native engine.' + ); + } + throw error; + } + }); + } +} + +function environmentIdentity(environment: Readonly>): string { + return JSON.stringify( + Object.entries(environment) + .filter(([, value]) => value !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + ); +} + +function getChangedOperations(options: IMapWorkspaceInvalidationsOptions): Iterable { + const { currentInputsSnapshot: current, nextInputsSnapshot: next, operationGraph } = options; + return Array.from(operationGraph.operations).filter( + (operation) => + current.getOperationOwnStateHash(operation.associatedProject, operation.associatedPhase.name) !== + next.getOperationOwnStateHash(operation.associatedProject, operation.associatedPhase.name) + ); +} + +function assertCompatibleInputs(current: IInputsSnapshot, next: IInputsSnapshot): void { + const paths: Set = new Set([...current.hashes.keys(), ...next.hashes.keys()]); + for (const filePath of paths) { + if (isGraphDefinitionPath(filePath) && current.hashes.get(filePath) !== next.hashes.get(filePath)) { + throw new WorkspaceEngineRecreationRequiredError(); + } + } +} + +function isGraphDefinitionPath(filePath: string): boolean { + const normalized: string = filePath.replace(/\\/g, '/'); + return ( + /(^|\/)config\//.test(normalized) || + ['rush.json', 'package.json', '.gitignore', '.npmrc', '.env', 'pnpm-lock.yaml'].includes( + path.basename(filePath) + ) + ); +} diff --git a/libraries/rush-daemon/src/RequestScheduler.ts b/libraries/rush-daemon/src/RequestScheduler.ts index dc5804f28c..b5fdab3020 100644 --- a/libraries/rush-daemon/src/RequestScheduler.ts +++ b/libraries/rush-daemon/src/RequestScheduler.ts @@ -91,6 +91,11 @@ interface IQueuedRequest { abortListener: (() => void) | undefined; } +interface ILeaseState { + exclusivityClass: RequestExclusivityClass; + released: boolean; +} + /** * Provides fair, queue-and-wait admission for daemon requests. * @@ -104,6 +109,24 @@ export class RequestScheduler { readonly #queue: IQueuedRequest[] = []; #activeClass: RequestExclusivityClass | undefined; #activeRequestCount: number = 0; + readonly #leaseStates: WeakMap = new WeakMap(); + + /** + * Atomically reduces an exclusive owner's rights without opening a gap in admission. + * Used to publish a reloaded generation and retain it while the initiating request executes. + */ + public downgradeExclusiveLease( + lease: IRequestLease, + target: RequestExclusivityClass.SharedBuild | RequestExclusivityClass.SharedRead + ): void { + const state: ILeaseState | undefined = this.#leaseStates.get(lease); + if (!state || state.released || state.exclusivityClass !== RequestExclusivityClass.Exclusive) { + throw new Error('Only an active exclusive lease from this scheduler can be downgraded.'); + } + state.exclusivityClass = target; + this.#activeClass = target; + this.#drainQueue(); + } /** * The number of requests currently waiting for admission. @@ -131,7 +154,10 @@ export class RequestScheduler { if (options.abortSignal?.aborted) { return Promise.reject( - new RequestSchedulerError(RequestSchedulerErrorCode.Aborted, 'The request was aborted before admission.') + new RequestSchedulerError( + RequestSchedulerErrorCode.Aborted, + 'The request was aborted before admission.' + ) ); } @@ -173,7 +199,10 @@ export class RequestScheduler { request.abortListener = () => { this.#rejectQueuedRequest( request, - new RequestSchedulerError(RequestSchedulerErrorCode.Aborted, 'The request was aborted while waiting.') + new RequestSchedulerError( + RequestSchedulerErrorCode.Aborted, + 'The request was aborted while waiting.' + ) ); }; options.abortSignal.addEventListener('abort', request.abortListener, { once: true }); @@ -200,24 +229,24 @@ export class RequestScheduler { return true; } - return ( - exclusivityClass !== RequestExclusivityClass.Exclusive && exclusivityClass === this.#activeClass - ); + return exclusivityClass !== RequestExclusivityClass.Exclusive && exclusivityClass === this.#activeClass; } #createLease(exclusivityClass: RequestExclusivityClass): IRequestLease { this.#activeClass = exclusivityClass; this.#activeRequestCount++; - let released: boolean = false; - return { - exclusivityClass, + const state: ILeaseState = { exclusivityClass, released: false }; + const lease: IRequestLease = { + get exclusivityClass(): RequestExclusivityClass { + return state.exclusivityClass; + }, release: (): void => { - if (released) { + if (state.released) { return; } - released = true; + state.released = true; this.#activeRequestCount--; if (this.#activeRequestCount === 0) { this.#activeClass = undefined; @@ -225,6 +254,8 @@ export class RequestScheduler { this.#drainQueue(); } }; + this.#leaseStates.set(lease, state); + return lease; } #drainQueue(): void { diff --git a/libraries/rush-daemon/src/RushDaemonCommandLine.ts b/libraries/rush-daemon/src/RushDaemonCommandLine.ts index 097f933027..3d4a93892e 100644 --- a/libraries/rush-daemon/src/RushDaemonCommandLine.ts +++ b/libraries/rush-daemon/src/RushDaemonCommandLine.ts @@ -5,8 +5,12 @@ import * as path from 'node:path'; import { FileSystem, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; import type { IPackageJson } from '@rushstack/node-core-library'; +import { resolveDaemonConfiguration, type IDaemonConfigurationJson } from '@microsoft/rush-lib'; import { serveRushDaemonAsync } from './serveRushDaemon'; +import type { IRushDaemonServeOptions } from './serveRushDaemon'; +import { ProductionDaemonRequestResolver } from './ProductionDaemonRequestResolver'; +import { RushDaemonRequestResolver } from './RushDaemonRequestResolver'; const RUSH_JSON_FILENAME: string = 'rush.json'; @@ -29,20 +33,28 @@ export function resolveRushDaemonWorkspace(startingFolder: string): IRushDaemonW export async function launchRushDaemonAsync(startingFolder: string = process.cwd()): Promise { const workspace: IRushDaemonWorkspace = resolveRushDaemonWorkspace(startingFolder); - const packageJson: IPackageJson | undefined = - PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); + const rushJson: { daemon?: IDaemonConfigurationJson } = JsonFile.load( + path.join(workspace.repoRoot, RUSH_JSON_FILENAME) + ); + const configuration: Readonly> = resolveDaemonConfiguration( + rushJson.daemon + ); + const packageJson: IPackageJson | undefined = PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); if (!packageJson) { throw new Error('Unable to determine the @rushstack/rush-daemon package version.'); } - await serveRushDaemonAsync({ + const serveOptions: IRushDaemonServeOptions = { daemonVersion: packageJson.version, repoRoot: workspace.repoRoot, rushVersion: workspace.rushVersion, + requestResolver: new RushDaemonRequestResolver(new ProductionDaemonRequestResolver()), + idleTimeoutSeconds: configuration.idleTimeoutSeconds, onError: (error: Error) => process.stderr.write(`${error.stack ?? error.message}\n`), onReady: (host) => { process.stdout.write(`rushd ready at ${host.paths.socketPath}\n`); } - }); + }; + await serveRushDaemonAsync(serveOptions); } function findRushJsonPath(startingFolder: string): string { diff --git a/libraries/rush-daemon/src/RushDaemonHost.ts b/libraries/rush-daemon/src/RushDaemonHost.ts index 39d5df6aca..6aa4989ac3 100644 --- a/libraries/rush-daemon/src/RushDaemonHost.ts +++ b/libraries/rush-daemon/src/RushDaemonHost.ts @@ -3,24 +3,31 @@ import { realpath } from 'node:fs/promises'; +import { connectOrStartDaemonAsync, type DaemonClient } from '@rushstack/rush-client-core'; import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; import { computeDaemonWorkspaceKey, DaemonFrameListener, resolveDaemonPathsFromProcess } from '@rushstack/rush-daemon-transport'; -import type { - DaemonFrameConnection, - IDaemonPaths -} from '@rushstack/rush-daemon-transport'; +import type { DaemonFrameConnection, IDaemonPaths } from '@rushstack/rush-daemon-transport'; import { DaemonControlSession } from './DaemonControlSession'; +import { DaemonIdleTimer } from './DaemonIdleTimer'; import type { IDaemonInteractiveConnection } from './DaemonInteractiveConnection'; import { DaemonRequestDispatcher } from './DaemonRequestDispatcher'; import type { IDaemonRequestResolver } from './DaemonRequestDispatcher'; import { WorkspaceSession } from './WorkspaceSession'; import type { IWorkspaceSession, WorkspaceSessionFactory } from './WorkspaceSession'; import { WorkspaceSessionProvider } from './WorkspaceSessionProvider'; +import { getWorkspaceStatus } from './WorkspaceStatus'; +import { WorkspaceRequestLifecycle } from './WorkspaceRequestLifecycle'; +import type { + GetWorkspaceSuccessorLaunchAsync, + IWorkspaceProcessRestartPlan, + IWorkspaceProcessRestartResult +} from './WorkspaceProcessRestart'; /** * Options for starting one workspace daemon host. @@ -28,10 +35,14 @@ import { WorkspaceSessionProvider } from './WorkspaceSessionProvider'; * @beta */ export interface IRushDaemonHostOptions { + /** Selects an available successor before a hard transition; startup still waits for complete old ownership release. */ + readonly getSuccessorLaunchAsync?: GetWorkspaceSuccessorLaunchAsync; /** Overrides workspace session construction for engine integration or testing. */ readonly createWorkspaceSessionAsync?: WorkspaceSessionFactory; /** The daemon implementation version reported by `pong`. */ readonly daemonVersion: string; + /** Shuts down after this many seconds without pending requests. Disabled when omitted. */ + readonly idleTimeoutSeconds?: number; /** Reports connection-level failures. */ readonly onError?: (error: Error) => void; /** Resolves validated wire envelopes into existing typed phased or global requests. */ @@ -53,12 +64,25 @@ export interface IRushDaemonHostOptions { */ export class RushDaemonHost { readonly #listener: DaemonFrameListener; + readonly #idleTimer: DaemonIdleTimer; readonly #sessions: Set; readonly #workspaceSessionProvider: WorkspaceSessionProvider; + readonly #readWorkspaceStatus: () => IDaemonWorkspaceStatus; readonly #lifecycle: { closing: boolean }; readonly #requestDispatcher: DaemonRequestDispatcher; public readonly paths: IDaemonPaths; #closePromise: Promise | undefined; + #notifyClosed: (() => void) | undefined; + readonly #options: IRushDaemonHostOptions; + readonly #startedAt: string; + #restartPromise: Promise | undefined; + #resolveRestart: ((result: IWorkspaceProcessRestartResult | undefined) => void) | undefined; + #rejectRestart: ((error: Error) => void) | undefined; + /** Settles after an accepted restart reaches a new ready process, or normal shutdown finishes without restarting. */ + public readonly restartCompleted: Promise; + + /** Resolves after shutdown cleanup finishes. Use closeAsync() to observe cleanup failures. */ + public readonly closed: Promise; private constructor( listener: DaemonFrameListener, @@ -66,18 +90,35 @@ export class RushDaemonHost { sessions: Set, lifecycle: { closing: boolean }, requestDispatcher: DaemonRequestDispatcher, - workspaceSessionProvider: WorkspaceSessionProvider + workspaceSessionProvider: WorkspaceSessionProvider, + idleTimer: DaemonIdleTimer, + options: IRushDaemonHostOptions, + startedAt: string, + readWorkspaceStatus: () => IDaemonWorkspaceStatus ) { + this.closed = new Promise((resolve) => { + this.#notifyClosed = resolve; + }); this.#listener = listener; this.paths = paths; this.#sessions = sessions; this.#lifecycle = lifecycle; this.#requestDispatcher = requestDispatcher; this.#workspaceSessionProvider = workspaceSessionProvider; + this.#readWorkspaceStatus = readWorkspaceStatus; + this.#idleTimer = idleTimer; + this.#options = options; + this.#startedAt = startedAt; + this.restartCompleted = new Promise((resolve, reject) => { + this.#resolveRestart = resolve; + this.#rejectRestart = reject; + }); + void this.restartCompleted.catch(() => undefined); } /** Resolves only after the transport is bound and its lockfile has been written. */ public static async startAsync(options: IRushDaemonHostOptions): Promise { + const idleTimer: DaemonIdleTimer = new DaemonIdleTimer(options.idleTimeoutSeconds); const canonicalRepoRoot: string = await realpath(options.repoRoot); const workspaceKey: string = computeDaemonWorkspaceKey({ canonicalRepoRoot, @@ -97,9 +138,27 @@ export class RushDaemonHost { ); const startedAtMs: number = Date.now(); const workspaceSession: IWorkspaceSession = await workspaceSessionProvider.getSessionAsync(); + let requestLifecycle: WorkspaceRequestLifecycle | undefined; + try { + if (options.requestResolver?.workspaceLifecycle) { + requestLifecycle = await WorkspaceRequestLifecycle.createAsync({ + provider: workspaceSessionProvider, + resolver: options.requestResolver, + rushVersion: options.rushVersion, + getSuccessorLaunchAsync: options.getSuccessorLaunchAsync, + onRestartRequested: requestRestart + }); + } + } catch (error) { + await workspaceSessionProvider[Symbol.asyncDispose](); + throw error; + } + const readWorkspaceStatus = (): IDaemonWorkspaceStatus => + getWorkspaceStatus(workspaceSessionProvider, requestLifecycle?.lastReloadTier ?? 0); const requestDispatcher: DaemonRequestDispatcher = new DaemonRequestDispatcher( workspaceSession, - options.requestResolver + options.requestResolver, + requestLifecycle ); let listener: DaemonFrameListener; try { @@ -111,6 +170,7 @@ export class RushDaemonHost { daemonVersion: options.daemonVersion, dispatcher: requestDispatcher, startedAtMs, + getWorkspaceStatus: readWorkspaceStatus, onInteractiveConnection: options.onInteractiveConnection, onClosed: (closedSession: DaemonControlSession, error: Error | undefined) => { sessions.delete(closedSession); @@ -118,7 +178,9 @@ export class RushDaemonHost { options.onError?.(error); } }, - onError: (error: Error) => options.onError?.(error) + onError: (error: Error) => options.onError?.(error), + onRequestStarted: () => idleTimer.acquire(), + onShutdownRequested: requestShutdown }); sessions.add(session); if (lifecycle.closing) { @@ -146,14 +208,29 @@ export class RushDaemonHost { } throw error; } - return new RushDaemonHost( + const host: RushDaemonHost = new RushDaemonHost( listener, paths, sessions, lifecycle, requestDispatcher, - workspaceSessionProvider + workspaceSessionProvider, + idleTimer, + options, + new Date(startedAtMs).toISOString(), + readWorkspaceStatus ); + function requestRestart(plan: IWorkspaceProcessRestartPlan): void { + host.#requestRestart(plan); + } + function requestShutdown(): void { + void host.closeAsync().catch((error: Error) => { + if (options.onError) options.onError(error); + else process.emitWarning(error); + }); + } + idleTimer.start(requestShutdown); + return host; } /** Returns the single warm workspace session owned by this host. */ @@ -161,29 +238,86 @@ export class RushDaemonHost { return this.#workspaceSessionProvider.getSessionAsync(); } + /** Host-local generation, useful for rejecting retained server-side references. */ + public get workspaceGeneration(): number { + return this.#workspaceSessionProvider.generation; + } + + /** Samples the installed generation without constructing a session or graph or taking request leases. */ + public get workspaceStatus(): IDaemonWorkspaceStatus { + return this.#readWorkspaceStatus(); + } + /** Closes active connections, stops listening, and removes transport artifacts. */ public closeAsync(): Promise { - this.#closePromise ??= this.#closeOnceAsync(); + this.#closePromise ??= this.#closeOnceAsync().finally(() => { + this.#notifyClosed?.(); + if (!this.#restartPromise) this.#resolveRestart?.(undefined); + }); return this.#closePromise; } + #requestRestart(plan: IWorkspaceProcessRestartPlan): void { + if (this.#restartPromise || this.#closePromise) return; + this.#restartPromise = Promise.resolve().then(() => this.#restartOnceAsync(plan)); + void this.#restartPromise.then( + (result) => this.#resolveRestart?.(result), + (error: unknown) => { + const failure: Error = error instanceof Error ? error : new Error(String(error)); + this.#rejectRestart?.(failure); + if (this.#options.onError) this.#options.onError(failure); + else process.emitWarning(failure); + } + ); + } + + async #restartOnceAsync(plan: IWorkspaceProcessRestartPlan): Promise { + await this.closeAsync(); + if (plan.failure) throw plan.failure; + if (!plan.launch) throw new Error('A successor was not selected.'); + const paths: IDaemonPaths = resolveDaemonPathsFromProcess( + computeDaemonWorkspaceKey({ + canonicalRepoRoot: plan.repoRoot, + rushVersion: plan.rushVersion, + startupOptions: this.#options.startupOptions + }) + ); + const client: DaemonClient = await connectOrStartDaemonAsync({ + paths, + expectedDaemonVersion: plan.launch.daemonVersion, + startCommand: plan.launch.startCommand, + previousDaemon: { pid: process.pid, startedAt: this.#startedAt } + }); + try { + const { pid } = await client.status; + if (!pid || pid === process.pid) throw new Error('A process restart must attest a new daemon PID.'); + return { pid, rushVersion: plan.rushVersion }; + } finally { + await client.closeAsync(); + } + } + async #closeOnceAsync(): Promise { + this.#idleTimer[Symbol.dispose](); this.#lifecycle.closing = true; const errors: unknown[] = []; - const listenerClosePromise: Promise = this.#listener - .closeAsync() - .then(() => undefined, (error: unknown) => error); + // Refuse new sessions but keep the listener's live ownership until every resource join succeeds. + // A failed standalone host must not exit naturally and become reclaimable over unjoined children. const sessionSettlements: PromiseSettledResult[] = await Promise.allSettled( - Array.from(this.#sessions, (session: DaemonControlSession) => session.closeAsync()) + Array.from(this.#sessions, (session: DaemonControlSession) => + session.closeAsync(!!this.#restartPromise) + ) ); for (const settlement of sessionSettlements) { if (settlement.status === 'rejected') { errors.push(settlement.reason); } } - const listenerError: unknown | undefined = await listenerClosePromise; - if (listenerError !== undefined) { - errors.push(listenerError); + try { + const workspace: IWorkspaceSession = await this.#workspaceSessionProvider.getSessionAsync(); + await workspace.quiesceWarmSetAsync?.(); + } catch (error) { + throw new AggregateError([...errors, error], 'Could not quiesce workspace maintenance for shutdown.'); } try { await this.#requestDispatcher[Symbol.asyncDispose](); @@ -196,6 +330,13 @@ export class RushDaemonHost { errors.push(error); } + if (errors.length === 0) { + try { + await this.#listener.closeAsync(); + } catch (error) { + errors.push(error); + } + } if (errors.length === 1) { throw errors[0]; } else if (errors.length > 1) { diff --git a/libraries/rush-daemon/src/RushDaemonRequestResolver.ts b/libraries/rush-daemon/src/RushDaemonRequestResolver.ts new file mode 100644 index 0000000000..9422fc783e --- /dev/null +++ b/libraries/rush-daemon/src/RushDaemonRequestResolver.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { + IDaemonRequestResolver, + IResolveDaemonRequestOptions, + ResolvedDaemonRequest +} from './DaemonRequestDispatcher'; +import { RushXDaemonRequestResolver } from './RushXDaemonRequestResolver'; +import { + wrapWorkspaceResolverLifecycle, + type IWorkspaceResolverLifecycle +} from './WorkspaceResolverLifecycle'; + +/** Composes native Rushx execution with an integration-owned workspace command resolver. @beta */ +export class RushDaemonRequestResolver implements IDaemonRequestResolver { + readonly #rushResolver: IDaemonRequestResolver; + readonly #rushxResolver: RushXDaemonRequestResolver = new RushXDaemonRequestResolver(); + public readonly workspaceLifecycle: IWorkspaceResolverLifecycle | undefined; + + public constructor(rushResolver: IDaemonRequestResolver) { + this.#rushResolver = rushResolver; + this.workspaceLifecycle = wrapWorkspaceResolverLifecycle( + rushResolver, (replacement) => new RushDaemonRequestResolver(replacement) + ); + } + + public resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise { + return (options.envelope.invocationKind === 'rushx' ? this.#rushxResolver : this.#rushResolver) + .resolveRequestAsync(options); + } + + public async [Symbol.asyncDispose](): Promise { + await this.#rushResolver[Symbol.asyncDispose]?.(); + } +} diff --git a/libraries/rush-daemon/src/RushXDaemonRequestResolver.ts b/libraries/rush-daemon/src/RushXDaemonRequestResolver.ts new file mode 100644 index 0000000000..0d08c2f830 --- /dev/null +++ b/libraries/rush-daemon/src/RushXDaemonRequestResolver.ts @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { EOL } from 'node:os'; +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; + +import { + EnvironmentConfiguration, + daemonEnvironmentVariables, + RushConfiguration, + RushXCommand, + type IRushXCommandLineArguments +} from '@microsoft/rush-lib'; +import { EnvironmentMap, FileSystem, JsonFile } from '@rushstack/node-core-library'; +import { getDaemonChildEnvironmentOverrides } from '@rushstack/rush-terminal-renderer/lib/ChildEnvironment'; +import { Terminal, TerminalProviderSeverity, type ITerminal } from '@rushstack/terminal'; + +import { + DaemonRequestDispatchError, + type IDaemonRequestResolver, + type IResolveDaemonRequestOptions, + type ResolvedDaemonRequest +} from './DaemonRequestDispatcher'; +import { resolveGlobalCommandRequest, resolveGlobalCommandWorkingDirectory } from './GlobalCommandRequest'; +import type { GlobalCommandExecutor } from './GlobalCommandRequestRouter'; +import type { IGlobalCommandExecutionContext } from './GlobalCommandExecutionContext'; + +/** + * Runs explicitly tagged Rushx scripts through native parsing and lifecycle preparation. + * + * @remarks + * No workspace graph or Rush CLI subprocess is constructed. The global execution context owns the actual + * package script's shell, streams and process tree. Process-global hook/configuration cases reject before input admission. + * @beta + */ +export class RushXDaemonRequestResolver implements IDaemonRequestResolver { + readonly #startupEnvironment: NodeJS.ProcessEnv = { ...process.env }; + readonly #requestLocalRushVariables: ReadonlySet = new Set([ + ...Object.values(daemonEnvironmentVariables), + 'RUSH_DAEMON_EXPERIMENTAL', + 'RUSH_INVOKED_FOLDER', + 'RUSH_QUIET_MODE' + ]); + + public async resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise { + const { envelope, workspaceSession, abortSignal } = options; + if (envelope.invocationKind !== 'rushx') { + throw new DaemonRequestDispatchError( + 'unsupported', + 'This resolver requires an explicit Rushx invocation.' + ); + } + if (envelope.commandOrigin !== 'custom') { + throw new DaemonRequestDispatchError( + 'invalidRequest', + 'A Rushx script cannot claim built-in Rush origin.' + ); + } + let cwd: string; + let canonicalCwd: string; + try { + canonicalCwd = resolveGlobalCommandRequest( + { + ...envelope, + terminal: { ...envelope.terminal, columns: envelope.terminal.columns } + }, + workspaceSession + ).cwd; + cwd = process.platform === 'win32' ? path.resolve(envelope.cwd) : canonicalCwd; + resolveGlobalCommandWorkingDirectory(RushXCommand.getPackageFolder(cwd), workspaceSession); + } catch (error) { + throw new DaemonRequestDispatchError('invalidRequest', (error as Error).message, { cause: error }); + } + const args: IRushXCommandLineArguments = RushXCommand.parseArguments(envelope.argv, envelope.environment); + if (args.commandName !== envelope.commandName) { + throw new DaemonRequestDispatchError( + 'invalidRequest', + 'The command name does not match native Rushx argv.' + ); + } + const configuration: RushConfiguration = workspaceSession.rushConfiguration; + let environment: NodeJS.ProcessEnv; + let rushJsonFilePath: string; + try { + const rushJsonPath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ + startingFolder: cwd, + showVerbose: false + }); + if ( + !rushJsonPath || + fs.realpathSync.native(rushJsonPath) !== fs.realpathSync.native(configuration.rushJsonFile) + ) { + throw new Error('The governing Rush configuration differs from this daemon workspace.'); + } + rushJsonFilePath = rushJsonPath; + assertCurrentConfiguration(configuration); + environment = RushXCommand.prepareEnvironment(cwd, envelope.environment, rushJsonPath); + this.#validateConfigurationEnvironment(environment); + const reason: string | undefined = RushXCommand.getInProcessReason(args, environment, configuration); + if (reason) throw new Error(reason); + } catch (error) { + throw new DaemonRequestDispatchError('unsupported', (error as Error).message, { cause: error }); + } + abortSignal.throwIfAborted(); + const executeAsync: GlobalCommandExecutor = async (context) => { + try { + if (resolveGlobalCommandWorkingDirectory(cwd, workspaceSession) !== canonicalCwd) { + throw new Error('The Rushx invocation directory changed while waiting for admission.'); + } + assertCurrentConfiguration(configuration); + } catch (error) { + context.terminal.writeErrorLine((error as Error).message); + throw error; + } + return { + exitCode: await RushXCommand.executeAsync({ + arguments: args, + cwd, + environment, + rushConfiguration: configuration, + rushJsonFilePath, + terminal: createRushXTerminal(context, args.isDebug), + consoleTerminal: new Terminal({ + supportsColor: true, + eolCharacter: '\n', + write: (data, severity) => + context.writeOutput( + severity === TerminalProviderSeverity.error || severity === TerminalProviderSeverity.warning + ? 'stderr' + : 'stdout', + Buffer.from(data) + ) + }), + launchOptions: { isManaged: true }, + abortSignal: context.abortSignal, + spawn: (command, childArgs, spawnOptions) => { + if (typeof spawnOptions.cwd !== 'string' || !spawnOptions.env) { + throw new Error('Native Rushx did not supply a complete child cwd/environment.'); + } + const child: ChildProcessWithoutNullStreams = context.spawnChild(command, childArgs, { + cwd: spawnOptions.cwd, + environment: { + ...spawnOptions.env, + ...getDaemonChildEnvironmentOverrides({ + isTTY: envelope.terminal.isTTY, + colorLevel: envelope.terminal.supportsColor ? 1 : 0, + columns: envelope.terminal.columns + }) + }, + shell: spawnOptions.shell, + forwardInput: envelope.terminal.acceptsStdin === true + }); + if (!envelope.terminal.acceptsStdin) child.stdin.end(); + return child; + } + }) + }; + }; + return { kind: 'global', executor: executeAsync }; + } + + #validateConfigurationEnvironment(environment: NodeJS.ProcessEnv): void { + const tempOverride: string | undefined = new EnvironmentMap(environment).get('RUSH_TEMP_FOLDER'); + if (tempOverride && !path.isAbsolute(tempOverride)) { + throw new Error('Relative RUSH_TEMP_FOLDER requires in-process Rushx environment initialization.'); + } + const quiet: string | undefined = environment.RUSH_QUIET_MODE; + if (quiet !== 'true' && quiet !== 'false') { + EnvironmentConfiguration.parseBooleanEnvironmentVariable('RUSH_QUIET_MODE', quiet); + } + const names: Set = new Set([ + ...Object.keys(this.#startupEnvironment), + ...Object.keys(environment) + ]); + for (const name of names) { + if ( + name.startsWith('RUSH_') && + !this.#requestLocalRushVariables.has(name) && + environment[name] !== this.#startupEnvironment[name] + ) { + throw new Error( + `Changed ${name} requires native Rush environment initialization before Rushx execution.` + ); + } + } + } +} + +function assertCurrentConfiguration(configuration: RushConfiguration): void { + const current: RushConfiguration['rushConfigurationJson'] = JsonFile.load(configuration.rushJsonFile); + let experiments: unknown; + try { + experiments = JsonFile.load(path.join(configuration.commonRushConfigFolder, 'experiments.json')); + } catch (error) { + if (!FileSystem.isNotExistError(error)) throw error; + experiments = {}; + } + if ( + !isDeepStrictEqual( + { ...current, repository: current.repository || {} }, + configuration.rushConfigurationJson + ) || + !isDeepStrictEqual(experiments, configuration.experimentsConfiguration.configuration) + ) { + throw new Error('Rush configuration changed; a refreshed workspace is required before Rushx execution.'); + } +} + +function createRushXTerminal(context: IGlobalCommandExecutionContext, debugEnabled: boolean): ITerminal { + return new Terminal({ + supportsColor: context.terminalProperties.supportsColor, + eolCharacter: EOL, + write: (data, severity) => { + switch (severity) { + case TerminalProviderSeverity.debug: + case TerminalProviderSeverity.verbose: + if (debugEnabled) context.terminal.write(data); + break; + case TerminalProviderSeverity.warning: + case TerminalProviderSeverity.error: + context.terminal.writeError(data, { doNotOverrideSgrCodes: true }); + break; + default: + context.terminal.write(data); + break; + } + } + }); +} diff --git a/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts new file mode 100644 index 0000000000..7b8a6b1d2c --- /dev/null +++ b/libraries/rush-daemon/src/SelectedDaemonBootstrap.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createRequire } from 'node:module'; +import * as path from 'node:path'; + +import { JsonFile } from '@rushstack/node-core-library'; +import type { IDaemonConfigurationJson } from '@microsoft/rush-lib'; +import { DAEMON_PROTOCOL_VERSION, type IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; + +import { + readDaemonInstallationMetadata, + type IDaemonInstallationMetadata, + type IInstalledDaemonLauncher +} from './DaemonInstallation'; + +async function mainAsync(): Promise { + const [mode, packageJsonPath, expectedVersion, repoRoot] = process.argv.slice(2); + if ((mode !== '--probe' && mode !== '--launch') || !packageJsonPath) { + throw new Error( + 'Expected --probe or --launch .' + ); + } + const metadata: IDaemonInstallationMetadata = readDaemonInstallationMetadata(packageJsonPath); + const selectedRequire: NodeRequire = createRequire(metadata.launcherPath); + const rushLibEntryPoint: string = selectedRequire.resolve('@microsoft/rush-lib'); + const rushLib: Partial = selectedRequire('@microsoft/rush-lib'); + if (typeof rushLib?.Rush?.version !== 'string' || rushLib.Rush.version !== metadata.rushVersion) { + throw new Error(`Rush runtime and installed metadata disagree for ${metadata.launcherPath}.`); + } + // This is the native Rush SDK handoff, pointing at the actual selected engine, not the caller's engine. + process.env._RUSH_LIB_PATH = rushLibEntryPoint; + const protocol: { DAEMON_PROTOCOL_VERSION?: IDaemonProtocolVersion } = selectedRequire( + '@rushstack/rush-daemon-protocol' + ); + const protocolVersion: IDaemonProtocolVersion | undefined = protocol?.DAEMON_PROTOCOL_VERSION; + if ( + !protocolVersion || + !Number.isSafeInteger(protocolVersion.major) || + protocolVersion.major < 0 || + !Number.isSafeInteger(protocolVersion.minor) || + protocolVersion.minor < 0 + ) { + throw new Error(`Daemon installation does not attest a valid protocol: ${metadata.launcherPath}`); + } + const daemon: Partial = selectedRequire('@rushstack/rush-daemon'); + const { serveRushDaemonAsync, ProductionDaemonRequestResolver, RushDaemonRequestResolver } = daemon; + const { resolveDaemonConfiguration } = rushLib; + const canLaunchRequests: boolean = + typeof serveRushDaemonAsync === 'function' && + typeof ProductionDaemonRequestResolver === 'function' && + typeof RushDaemonRequestResolver === 'function' && + typeof resolveDaemonConfiguration === 'function'; + const installation: IInstalledDaemonLauncher = { + ...metadata, + rushVersion: rushLib.Rush.version, + rushLibEntryPoint, + protocolVersion, + canLaunchRequests + }; + if (mode === '--probe') { + process.stdout.write(JSON.stringify(installation)); + return; + } + if (!expectedVersion || !repoRoot || expectedVersion !== installation.rushVersion) { + throw new Error( + `Cannot launch selected Rush ${expectedVersion}: actual engine is ${installation.rushVersion}.` + ); + } + if ( + protocolVersion.major !== DAEMON_PROTOCOL_VERSION.major || + protocolVersion.minor < DAEMON_PROTOCOL_VERSION.minor + ) { + throw new Error( + `Selected daemon protocol ${protocolVersion.major}.${protocolVersion.minor} cannot support this launcher.` + ); + } + const configured: { rushVersion?: unknown; daemon?: IDaemonConfigurationJson } = JsonFile.load( + path.join(repoRoot, 'rush.json') + ); + if ((process.env.RUSH_PREVIEW_VERSION || configured.rushVersion) !== expectedVersion) { + throw new Error( + `Selected Rush ${expectedVersion} does not match rush.json or its RUSH_PREVIEW_VERSION override; select again before starting.` + ); + } + if ( + !serveRushDaemonAsync || + !ProductionDaemonRequestResolver || + !RushDaemonRequestResolver || + !resolveDaemonConfiguration + ) { + throw new Error(`Selected daemon ${installation.daemonVersion} lacks the default request-launch APIs.`); + } + const configuration: Readonly> = resolveDaemonConfiguration( + configured.daemon, + process.env + ); + await serveRushDaemonAsync({ + repoRoot, + rushVersion: installation.rushVersion, + daemonVersion: installation.daemonVersion, + requestResolver: new RushDaemonRequestResolver(new ProductionDaemonRequestResolver()), + idleTimeoutSeconds: configuration.idleTimeoutSeconds, + onError: (error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + }, + onReady: (host) => { + process.stdout.write(`rushd ready at ${host.paths.socketPath} (Rush ${installation.rushVersion})\n`); + } + }); +} + +if (require.main === module) { + mainAsync().catch((error: Error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/libraries/rush-daemon/src/SelectedDaemonInstaller.ts b/libraries/rush-daemon/src/SelectedDaemonInstaller.ts new file mode 100644 index 0000000000..d479d46c74 --- /dev/null +++ b/libraries/rush-daemon/src/SelectedDaemonInstaller.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { _FlagFile } from '@microsoft/rush-lib'; +import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; +import { LockFile } from '@rushstack/node-core-library'; + +import { assertExactDaemonVersion } from './VersionSelectedDaemonLauncher'; + +async function mainAsync(): Promise { + const [cacheFolder, daemonVersion, repoRoot] = process.argv.slice(2); + if (!cacheFolder || !path.isAbsolute(cacheFolder) || !repoRoot || !path.isAbsolute(repoRoot)) { + throw new Error('Daemon installation requires absolute cache and repository folders.'); + } + assertExactDaemonVersion(daemonVersion); + const folder: string = path.join(cacheFolder, `daemon-${daemonVersion}`); + // The native installer empties its installation directory; its mutex must live outside that directory. + const lock: LockFile = await LockFile.acquireAsync(cacheFolder, 'rush-daemon-install', 120000); + try { + const marker: _FlagFile = new _FlagFile(folder, 'last-install', { + node: process.versions.node, + daemonVersion + }); + if (await marker.isValidAsync()) return; + await Utilities.installPackageInDirectoryAsync({ + directory: folder, + packageName: '@rushstack/rush-daemon', + version: daemonVersion, + tempPackageTitle: 'rush-daemon-local-install', + maxInstallAttempts: 3, + commonRushConfigFolder: path.join(repoRoot, 'common', 'config', 'rush'), + suppressOutput: true, + filterNpmIncompatibleProperties: true + }); + await marker.createAsync(); + } finally { + lock.release(); + } +} + +if (require.main === module) { + mainAsync().catch((error: Error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts b/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts new file mode 100644 index 0000000000..f931b84277 --- /dev/null +++ b/libraries/rush-daemon/src/VersionSelectedDaemonLauncher.ts @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { _FlagFile } from '@microsoft/rush-lib'; +import { EnvironmentConfiguration } from '@microsoft/rush-lib/lib/api/EnvironmentConfiguration'; +import { + DependencySpecifier, + DependencySpecifierType +} from '@microsoft/rush-lib/lib/logic/DependencySpecifier'; +import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; +import { FileSystem, JsonFile, PackageJsonLookup, User } from '@rushstack/node-core-library'; +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonStartCommand } from '@rushstack/rush-client-core'; + +import { + readDaemonInstallationMetadata, + type IDaemonInstallationMetadata, + type IInstalledDaemonLauncher +} from './DaemonInstallation'; + +const DAEMON_PACKAGE: string = '@rushstack/rush-daemon'; + +export interface IDaemonLauncherContext { + readonly repoRoot: string; + readonly rushVersion: string; + readonly environment: Readonly; +} + +export interface IVersionSelectedDaemonLaunch extends IInstalledDaemonLauncher { + readonly startCommand: IDaemonStartCommand; +} + +export class DaemonLauncherUnavailableError extends Error { + public readonly installation: IInstalledDaemonLauncher | undefined; + + public constructor(rushVersion: string, reason: string, installation?: IInstalledDaemonLauncher) { + super(`Cannot launch selected Rush ${rushVersion}: ${reason} Use native Rush instead.`); + this.name = 'DaemonLauncherUnavailableError'; + this.installation = installation; + } +} + +/** Native-style, node-specific cache, resolved against the captured environment without mutating process.env. */ +export function getDaemonVersionCacheFolder(environment: Readonly): string { + const globalFolder: string = + EnvironmentConfiguration._getRushGlobalFolderOverride(environment) ?? + path.join(User.getHomeFolder(), '.rush'); + const nodeVersion: string = /^[a-z0-9.-]+$/i.test(process.version) ? process.version : 'unknown-version'; + return path.join(globalFolder, `node-${nodeVersion}`, 'rush-daemon-versions'); +} + +export function assertExactDaemonVersion(version: string): void { + if ( + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version) || + new DependencySpecifier(DAEMON_PACKAGE, version).specifierType !== DependencySpecifierType.Version + ) { + throw new Error(`Daemon selection requires an exact version, not "${version}".`); + } +} + +export function getSelectedDaemonStartCommand( + daemonPackageJsonPath: string, + context: IDaemonLauncherContext +): IDaemonStartCommand { + return { + command: process.execPath, + args: [ + require.resolve('./SelectedDaemonBootstrap'), + '--launch', + daemonPackageJsonPath, + context.rushVersion, + context.repoRoot + ], + cwd: context.repoRoot, + environment: Object.freeze( + Object.fromEntries( + Object.entries(context.environment).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ) + ) + }; +} + +/** Probes in a fresh process so loading a foreign Rush engine cannot alter the caller's SDK/global state. */ +export async function inspectDaemonLauncherAsync( + daemonPackageJsonPath: string, + context: IDaemonLauncherContext +): Promise { + const { stdout, stderr, exitCode, signal } = await Utilities.executeCommandAndCaptureOutputAsync({ + command: process.execPath, + args: [require.resolve('./SelectedDaemonBootstrap'), '--probe', daemonPackageJsonPath], + workingDirectory: context.repoRoot, + environment: { ...context.environment }, + keepEnvironment: false, + captureExitCodeAndSignal: true + }); + if (exitCode !== 0 || signal) throw new Error(`Daemon version attestation failed: ${stderr}`); + const installation: IInstalledDaemonLauncher = JSON.parse(stdout); + if ( + typeof installation.rushVersion !== 'string' || + typeof installation.daemonVersion !== 'string' || + typeof installation.launcherPath !== 'string' || + typeof installation.rushLibEntryPoint !== 'string' || + !installation.protocolVersion || + !Number.isSafeInteger(installation.protocolVersion.major) || + !Number.isSafeInteger(installation.protocolVersion.minor) || + typeof installation.daemonPackageJsonPath !== 'string' || + typeof installation.canLaunchRequests !== 'boolean' + ) { + throw new Error('Daemon version attestation returned an invalid result.'); + } + return installation; +} + +/** + * Reuses a verified available installation or installs a daemon release that pins the exact requested engine. + * Unlike RushVersionSelector.ensureRushVersionInstalledAsync(), this never executes a Rush command. + * Registry selection uses the newest release declaring that exact engine dependency, without overrides. + * Installation runs the native Rush installer in an isolated process and keeps its mutex outside the + * directory that the installer replaces. Cached-only selection never queries the registry. + */ +export async function selectDaemonLauncherAsync( + input: IDaemonLauncherContext, + options: { readonly allowInstall?: boolean } = {} +): Promise { + const override: string | undefined = EnvironmentConfiguration._getRushGlobalFolderOverride( + input.environment + ); + const context: IDaemonLauncherContext = { + repoRoot: await fs.realpath(input.repoRoot), + rushVersion: input.rushVersion, + environment: Object.freeze({ + ...input.environment, + ...(override ? { RUSH_GLOBAL_FOLDER: override } : {}) + }) + }; + assertExactDaemonVersion(context.rushVersion); + const cacheFolder: string = getDaemonVersionCacheFolder(context.environment); + const configured: { rushVersion?: unknown } = await JsonFile.loadAsync( + path.join(context.repoRoot, 'rush.json') + ); + if ((context.environment.RUSH_PREVIEW_VERSION || configured.rushVersion) !== context.rushVersion) { + throw new DaemonLauncherUnavailableError( + context.rushVersion, + 'The requested version does not match rush.json or its RUSH_PREVIEW_VERSION override.' + ); + } + const bundledPackage: string | undefined = + PackageJsonLookup.instance.tryGetPackageJsonFilePathFor(__dirname); + if (!bundledPackage) throw new Error('Cannot locate the bundled daemon package.'); + if (readDaemonInstallationMetadata(bundledPackage).rushVersion === context.rushVersion) { + return createLaunch(await inspectDaemonLauncherAsync(bundledPackage, context), context); + } + let incompatible: IInstalledDaemonLauncher | undefined; + if (await FileSystem.existsAsync(cacheFolder)) { + for (const entry of await fs.readdir(cacheFolder, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('daemon-')) continue; + const version: string = entry.name.slice('daemon-'.length); + assertExactDaemonVersion(version); + const folder: string = path.join(cacheFolder, entry.name); + const marker: _FlagFile = new _FlagFile(folder, 'last-install', { + node: process.versions.node, + daemonVersion: version + }); + if (!(await marker.isValidAsync())) continue; + const packagePath: string = path.join( + folder, + 'node_modules', + '@rushstack', + 'rush-daemon', + 'package.json' + ); + const metadata: IDaemonInstallationMetadata = readDaemonInstallationMetadata(packagePath); + if (metadata.daemonVersion !== version) + throw new Error(`Daemon cache version does not match ${folder}.`); + if (metadata.rushVersion !== context.rushVersion) continue; + const installed: IInstalledDaemonLauncher = await inspectDaemonLauncherAsync(packagePath, context); + if (supportsClient(installed)) return createLaunch(installed, context); + incompatible = installed; + } + } + if (options.allowInstall === false) { + if (incompatible) return createLaunch(incompatible, context); + throw new DaemonLauncherUnavailableError( + context.rushVersion, + 'No matching installed daemon launcher is available.' + ); + } + const versions: unknown = await npmViewAsync(DAEMON_PACKAGE, 'versions', context); + const candidates: unknown[] = Array.isArray(versions) ? versions : [versions]; + // npm view returns its versions field in semver order; no second version parser is needed here. + for (const candidate of candidates.reverse()) { + if (typeof candidate !== 'string') throw new Error('npm returned an invalid daemon version list.'); + assertExactDaemonVersion(candidate); + const metadata: unknown = await npmViewAsync(`${DAEMON_PACKAGE}@${candidate}`, undefined, context); + if (!isRecord(metadata) || metadata.name !== DAEMON_PACKAGE || metadata.version !== candidate) { + throw new Error(`npm returned invalid metadata for ${DAEMON_PACKAGE}@${candidate}.`); + } + if ( + !isRecord(metadata.dependencies) || + metadata.dependencies['@microsoft/rush-lib'] !== context.rushVersion || + !isRecord(metadata.bin) || + typeof metadata.bin.rushd !== 'string' + ) + continue; + const child: ChildProcess = spawn( + process.execPath, + [require.resolve('./SelectedDaemonInstaller'), cacheFolder, candidate, context.repoRoot], + { + cwd: context.repoRoot, + env: { ...context.environment }, + stdio: ['ignore', 2, 2] + } + ); + const [exitCode, signal] = await once(child, 'close'); + if (exitCode !== 0 || signal) { + throw new Error(`Installing ${DAEMON_PACKAGE}@${candidate} failed (${signal ?? exitCode}).`); + } + const packagePath: string = path.join( + cacheFolder, + `daemon-${candidate}`, + 'node_modules', + '@rushstack', + 'rush-daemon', + 'package.json' + ); + const installed: IInstalledDaemonLauncher = await inspectDaemonLauncherAsync(packagePath, context); + if (installed.daemonVersion !== candidate) + throw new Error(`Installed daemon does not match requested ${candidate}.`); + return createLaunch(installed, context); + } + throw new DaemonLauncherUnavailableError( + context.rushVersion, + 'No published daemon release pins that exact Rush engine.' + ); +} + +function supportsClient(installation: IInstalledDaemonLauncher): boolean { + return ( + installation.protocolVersion.major === DAEMON_PROTOCOL_VERSION.major && + installation.protocolVersion.minor >= DAEMON_PROTOCOL_VERSION.minor && + installation.canLaunchRequests + ); +} + +function createLaunch( + installation: IInstalledDaemonLauncher, + context: IDaemonLauncherContext +): IVersionSelectedDaemonLaunch { + if (installation.rushVersion !== context.rushVersion) { + throw new Error( + `Daemon installation resolved Rush ${installation.rushVersion}, not requested ${context.rushVersion}.` + ); + } + if (!supportsClient(installation)) { + throw new DaemonLauncherUnavailableError( + context.rushVersion, + `Daemon ${installation.daemonVersion} attests protocol ${installation.protocolVersion.major}.${installation.protocolVersion.minor}; this client requires ${DAEMON_PROTOCOL_VERSION.major}.${DAEMON_PROTOCOL_VERSION.minor} and default request-launch APIs.`, + installation + ); + } + return { + ...installation, + startCommand: getSelectedDaemonStartCommand(installation.daemonPackageJsonPath, context) + }; +} + +async function npmViewAsync( + specifier: string, + field: string | undefined, + context: IDaemonLauncherContext +): Promise { + const configFolder: string = path.join(context.repoRoot, 'common', 'config', 'rush'); + const { stdout, stderr, exitCode, signal } = await Utilities.executeCommandAndCaptureOutputAsync({ + command: 'npm', + args: [ + 'view', + specifier, + ...(field ? [field] : []), + '--json', + '--fetch-retries=0', + '--fetch-timeout=30000', + '--no-update-notifier' + ], + workingDirectory: (await FileSystem.existsAsync(configFolder)) ? configFolder : context.repoRoot, + environment: { ...context.environment }, + keepEnvironment: false, + captureExitCodeAndSignal: true + }); + if (exitCode !== 0 || signal) { + throw new DaemonLauncherUnavailableError( + context.rushVersion, + `Registry lookup for ${specifier} failed: ${stderr}` + ); + } + return JSON.parse(stdout); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/libraries/rush-daemon/src/WarmSetRanking.ts b/libraries/rush-daemon/src/WarmSetRanking.ts new file mode 100644 index 0000000000..cc50659d62 --- /dev/null +++ b/libraries/rush-daemon/src/WarmSetRanking.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** Inputs to the single retention/eviction order. Missing measurements are not estimates. */ +export interface IWarmSetRank { + readonly key: string; + readonly lastUsed: number; + readonly frequency: number; + readonly timeSavedMs: number | undefined; + readonly residentMemoryBytes: number | undefined; +} + +export function getWarmSetScore(entry: IWarmSetRank): number | undefined { + const { timeSavedMs, frequency, residentMemoryBytes } = entry; + if ( + timeSavedMs === undefined || + !Number.isFinite(timeSavedMs) || + timeSavedMs < 0 || + !Number.isFinite(frequency) || + frequency <= 0 || + residentMemoryBytes === undefined || + !Number.isFinite(residentMemoryBytes) || + residentMemoryBytes <= 0 + ) + return undefined; + const score: number = (timeSavedMs * frequency) / residentMemoryBytes; + return Number.isFinite(score) ? score : undefined; +} + +/** Best to retain first; eviction consumes this same order from the end. */ +export function compareWarmSetRanks(a: IWarmSetRank, b: IWarmSetRank, telemetry: boolean): number { + if (telemetry) { + const aScore: number | undefined = getWarmSetScore(a); + const bScore: number | undefined = getWarmSetScore(b); + // A fixed missing-data bucket is essential: pairwise LRU fallback would not be transitive. + if (aScore !== undefined && bScore === undefined) return -1; + if (aScore === undefined && bScore !== undefined) return 1; + if (aScore !== undefined && bScore !== undefined && aScore !== bScore) return bScore - aScore; + } + if (a.lastUsed !== b.lastUsed) return b.lastUsed - a.lastUsed; + return a.key === b.key ? 0 : a.key < b.key ? -1 : 1; +} diff --git a/libraries/rush-daemon/src/WorkspaceEngineComponentFactory.ts b/libraries/rush-daemon/src/WorkspaceEngineComponentFactory.ts index 61d97935cd..1f2686cacd 100644 --- a/libraries/rush-daemon/src/WorkspaceEngineComponentFactory.ts +++ b/libraries/rush-daemon/src/WorkspaceEngineComponentFactory.ts @@ -64,6 +64,8 @@ export interface IWorkspaceEngineComponents extends AsyncDisposable { * currently expose one deterministic shutdown operation. */ [Symbol.asyncDispose](): Promise; + /** Optional native execution gate, acquired once for a coalesced iteration before reconciliation. */ + readonly acquireExecutionLeaseAsync?: () => Promise; readonly getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; readonly inputsSnapshot: IInputsSnapshot; readonly operationGraph: IOperationGraph; @@ -164,6 +166,10 @@ export interface IWorkspaceEngineComponentFactoryOptions { readonly isEngineRecreationRequiredAsync?: IsWorkspaceEngineRecreationRequiredAsync; readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync; readonly shape: IWorkspaceEngineShape; + /** Refresh inputs even without a watcher notification (for request-time freshness). */ + readonly refreshInputsOnEveryRequest?: boolean; + /** Authoritative graph-definition validation, awaited on every reconciliation before trusting path hints. */ + readonly validateGraphInputsAsync?: () => Promise; } interface IWorkspaceEngineLifecycleOptions { @@ -173,6 +179,8 @@ interface IWorkspaceEngineLifecycleOptions { readonly isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined; readonly mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync; readonly rushConfiguration: RushConfiguration; + readonly refreshInputsOnEveryRequest: boolean; + readonly validateGraphInputsAsync: (() => Promise) | undefined; } interface IGraphDefiningPaths { @@ -187,6 +195,8 @@ class WorkspaceEngineLifecycle { readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined; readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync; readonly #rushConfiguration: RushConfiguration; + readonly #refreshInputsOnEveryRequest: boolean; + readonly #validateGraphInputsAsync: (() => Promise) | undefined; #currentInputsSnapshot: IInputsSnapshot; #disposePromise: Promise | undefined; #isDisposing: boolean = false; @@ -201,6 +211,8 @@ class WorkspaceEngineLifecycle { this.#isEngineRecreationRequiredAsync = options.isEngineRecreationRequiredAsync; this.#mapInvalidationsToOperationsAsync = options.mapInvalidationsToOperationsAsync; this.#rushConfiguration = options.rushConfiguration; + this.#refreshInputsOnEveryRequest = options.refreshInputsOnEveryRequest; + this.#validateGraphInputsAsync = options.validateGraphInputsAsync; } public get inputsSnapshot(): IInputsSnapshot { @@ -228,6 +240,7 @@ class WorkspaceEngineLifecycle { } async #reconcileOnceAsync(): Promise { + await this.#validateGraphInputsAsync?.(); const invalidationSnapshot: IWorkspaceInvalidationSnapshot = this.#invalidations.getSnapshot(); if (await this.#requiresEngineRecreationAsync(invalidationSnapshot)) { throw new WorkspaceEngineRecreationRequiredError(); @@ -236,7 +249,11 @@ class WorkspaceEngineLifecycle { this.#requiresFullInvalidation || invalidationSnapshot.hasUnknownChanges || !invalidationSnapshot.isWatcherHealthy; - if (!isFullInvalidation && invalidationSnapshot.changedPaths.length === 0) { + if ( + !this.#refreshInputsOnEveryRequest && + !isFullInvalidation && + invalidationSnapshot.changedPaths.length === 0 + ) { return { inputsSnapshot: this.#currentInputsSnapshot, invalidatedOperationCount: 0, @@ -245,8 +262,7 @@ class WorkspaceEngineLifecycle { }; } - const nextInputsSnapshot: IInputsSnapshot | undefined = - await this.#components.getInputsSnapshotAsync(); + const nextInputsSnapshot: IInputsSnapshot | undefined = await this.#components.getInputsSnapshotAsync(); if (!nextInputsSnapshot) { throw new Error('Rush could not capture the next workspace inputs snapshot.'); } @@ -257,13 +273,12 @@ class WorkspaceEngineLifecycle { operationGraph.invalidateOperations(undefined, INVALIDATION_REASON); invalidatedOperationCount = operationGraph.operations.size; } else { - const mappedOperations: Iterable = - await this.#mapInvalidationsToOperationsAsync({ - changedPaths: invalidationSnapshot.changedPaths, - currentInputsSnapshot: this.#currentInputsSnapshot, - nextInputsSnapshot, - operationGraph - }); + const mappedOperations: Iterable = await this.#mapInvalidationsToOperationsAsync({ + changedPaths: invalidationSnapshot.changedPaths, + currentInputsSnapshot: this.#currentInputsSnapshot, + nextInputsSnapshot, + operationGraph + }); const invalidatedOperations: ReadonlySet = validateMappedOperations( mappedOperations, operationGraph @@ -275,6 +290,7 @@ class WorkspaceEngineLifecycle { this.#currentInputsSnapshot = nextInputsSnapshot; this.#invalidations.acknowledgeThrough(invalidationSnapshot.sequence); this.#requiresFullInvalidation = + !this.#refreshInputsOnEveryRequest && this.#invalidations.getSnapshot().sequence > invalidationSnapshot.sequence; return { inputsSnapshot: nextInputsSnapshot, @@ -299,14 +315,12 @@ class WorkspaceEngineLifecycle { ) { return false; } - if ( - this.#invalidations.hasUnattributedUnknownChanges || - !invalidationSnapshot.isWatcherHealthy - ) { + if (this.#invalidations.hasUnattributedUnknownChanges || !invalidationSnapshot.isWatcherHealthy) { return true; } if ( + !this.#validateGraphInputsAsync && invalidationSnapshot.changedPaths.some((changedPath: string) => isBuiltInGraphDefiningPath( changedPath, @@ -340,6 +354,8 @@ export class WorkspaceEngineComponentFactory { readonly #createEngineComponentsAsync: CreateWorkspaceEngineComponentsAsync; readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined; readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync; + readonly #refreshInputsOnEveryRequest: boolean; + readonly #validateGraphInputsAsync: (() => Promise) | undefined; public readonly createAsync: CreateWorkspaceSessionComponentsAsync; public readonly shape: IWorkspaceEngineShape; @@ -348,6 +364,8 @@ export class WorkspaceEngineComponentFactory { this.#createEngineComponentsAsync = options.createEngineComponentsAsync; this.#isEngineRecreationRequiredAsync = options.isEngineRecreationRequiredAsync; this.#mapInvalidationsToOperationsAsync = options.mapInvalidationsToOperationsAsync; + this.#refreshInputsOnEveryRequest = options.refreshInputsOnEveryRequest ?? false; + this.#validateGraphInputsAsync = options.validateGraphInputsAsync; this.shape = normalizeShape(options.shape); this.createAsync = (createOptions: ICreateWorkspaceSessionComponentsOptions) => this.#createAsync(createOptions); @@ -357,9 +375,7 @@ export class WorkspaceEngineComponentFactory { options: ICreateWorkspaceSessionComponentsOptions ): Promise { const projects: ReadonlySet = new Set(options.rushConfiguration.projects); - const graphDefiningPaths: IGraphDefiningPaths = createGraphDefiningPaths( - options.rushConfiguration - ); + const graphDefiningPaths: IGraphDefiningPaths = createGraphDefiningPaths(options.rushConfiguration); const components: IWorkspaceEngineComponents = await this.#createEngineComponentsAsync({ phaseNames: this.shape.phaseNames, pluginNames: this.shape.pluginNames, @@ -379,10 +395,15 @@ export class WorkspaceEngineComponentFactory { invalidations: options.invalidations, isEngineRecreationRequiredAsync: this.#isEngineRecreationRequiredAsync, mapInvalidationsToOperationsAsync: this.#mapInvalidationsToOperationsAsync, - rushConfiguration: options.rushConfiguration + rushConfiguration: options.rushConfiguration, + refreshInputsOnEveryRequest: this.#refreshInputsOnEveryRequest, + validateGraphInputsAsync: this.#validateGraphInputsAsync }); return { [Symbol.asyncDispose]: () => lifecycle[Symbol.asyncDispose](), + acquireExecutionLeaseAsync: components.acquireExecutionLeaseAsync + ? () => components.acquireExecutionLeaseAsync!() + : undefined, engineShape: this.shape, get inputsSnapshot(): IInputsSnapshot { return lifecycle.inputsSnapshot; @@ -432,9 +453,7 @@ function isPathInside(candidatePath: string, folderPath: string): boolean { const relativePath: string = path.relative(path.resolve(folderPath), candidatePath); return ( relativePath === '' || - (!path.isAbsolute(relativePath) && - relativePath !== '..' && - !relativePath.startsWith(`..${path.sep}`)) + (!path.isAbsolute(relativePath) && relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`)) ); } @@ -477,9 +496,7 @@ function validateComponents( throw new Error('The reusable workspace operation graph must not be empty.'); } - const configuredProjects: ReadonlySet = new Set( - rushConfiguration.projects - ); + const configuredProjects: ReadonlySet = new Set(rushConfiguration.projects); const representedProjects: Set = new Set(); const phaseNames: ReadonlySet = new Set(shape.phaseNames); for (const operation of operations) { diff --git a/libraries/rush-daemon/src/WorkspaceGeneration.ts b/libraries/rush-daemon/src/WorkspaceGeneration.ts new file mode 100644 index 0000000000..fdd836563b --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceGeneration.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; + +import type { IWorkspaceSession } from './WorkspaceSession'; + +const TOKENS: WeakMap = new WeakMap(); + +/** An opaque token that changes with both in-process generations and process restarts. @beta */ +export function getWorkspaceGenerationToken(session: IWorkspaceSession): string { + let token: string | undefined = TOKENS.get(session); + if (!token) { + token = randomUUID(); + TOKENS.set(session, token); + } + return token; +} diff --git a/libraries/rush-daemon/src/WorkspaceInvalidationTracker.ts b/libraries/rush-daemon/src/WorkspaceInvalidationTracker.ts index d12aedc38f..f8d357a102 100644 --- a/libraries/rush-daemon/src/WorkspaceInvalidationTracker.ts +++ b/libraries/rush-daemon/src/WorkspaceInvalidationTracker.ts @@ -26,34 +26,39 @@ const MAX_TRACKED_CHANGED_PATHS: number = 10_000; */ export class WorkspaceInvalidationTracker { readonly #sequenceByPath: Map = new Map(); + readonly #subscribers: Set<() => void> = new Set(); #initializationSequence: number | undefined; #latestSequence: number = 0; #unknownChangeSequence: number | undefined; #watcherHealthy: boolean = true; + /** Observes changes and acknowledgements. Callback failures are reported as process warnings. */ + public subscribe(notify: () => void): () => void { + this.#subscribers.add(notify); + return () => { this.#subscribers.delete(notify); }; + } + /** Records a path-specific or unknown workspace change. */ public invalidate(changedPath?: string): void { const sequence: number = ++this.#latestSequence; if (changedPath === undefined || this.#unknownChangeSequence !== undefined) { this.#unknownChangeSequence = sequence; - return; - } - - if ( + } else if ( !this.#sequenceByPath.has(changedPath) && this.#sequenceByPath.size >= MAX_TRACKED_CHANGED_PATHS ) { this.#sequenceByPath.clear(); this.#unknownChangeSequence = sequence; - return; + } else { + this.#sequenceByPath.set(changedPath, sequence); } - - this.#sequenceByPath.set(changedPath, sequence); + this.#notify(); } /** @internal */ public invalidateForInitialization(): void { this.#initializationSequence = ++this.#latestSequence; + this.#notify(); } /** @internal */ @@ -109,5 +114,18 @@ export class WorkspaceInvalidationTracker { if (this.#initializationSequence !== undefined && this.#initializationSequence <= sequence) { this.#initializationSequence = undefined; } + this.#notify(); + } + + #notify(): void { + for (const notify of this.#subscribers) { + try { + notify(); + } catch (error) { + process.emitWarning(error instanceof Error ? error : String(error), { + code: 'RUSH_DAEMON_INVALIDATION_CALLBACK_ERROR' + }); + } + } } } diff --git a/libraries/rush-daemon/src/WorkspaceProcessRestart.ts b/libraries/rush-daemon/src/WorkspaceProcessRestart.ts new file mode 100644 index 0000000000..26897d8602 --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceProcessRestart.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonStartCommand } from '@rushstack/rush-client-core'; + +import { selectDaemonLauncherAsync } from './VersionSelectedDaemonLauncher'; + +/** Inputs for selecting a successor, before the old host gives up ownership. @beta */ +export interface IWorkspaceProcessRestartContext { + readonly repoRoot: string; + readonly rushVersion: string; + readonly environment: Readonly>; + readonly reason: 'hard-input-change' | 'native-mutation'; +} + +/** An explicitly selected launch command consumed by the existing core startup contract. @beta */ +export interface IWorkspaceSuccessorLaunch { + readonly startCommand: IDaemonStartCommand; + readonly daemonVersion: string; +} + +/** Selects and validates an available successor without starting it. @beta */ +export type GetWorkspaceSuccessorLaunchAsync = ( + context: IWorkspaceProcessRestartContext +) => Promise; + +/** The successor attested by the existing hello/ping and ownership startup checks. @beta */ +export interface IWorkspaceProcessRestartResult { + readonly pid: number; + readonly rushVersion: string; +} + +/** A mutation may require shutdown even when its resulting version cannot be launched. */ +export interface IWorkspaceProcessRestartPlan extends IWorkspaceProcessRestartContext { + readonly launch: IWorkspaceSuccessorLaunch | undefined; + readonly failure: Error | undefined; +} + +/** Selects a verified bundled or cached daemon; installation belongs to client startup preparation. */ +export async function getInstalledWorkspaceSuccessorLaunchAsync( + context: IWorkspaceProcessRestartContext +): Promise { + return await selectDaemonLauncherAsync(context, { allowInstall: false }); +} diff --git a/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts b/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts index 8f70167109..70ff8514d2 100644 --- a/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts +++ b/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { - validateDaemonRequestAdmissionOptions -} from '@rushstack/rush-daemon-protocol'; +import { validateDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; import type { DaemonRequestAdmissionErrorCode, IDaemonRequestAdmissionOptions, @@ -12,12 +10,14 @@ import type { import { type IRequestLease, + type IRequestSchedulerAcquireOptions, type RequestExclusivityClass, RequestScheduler, RequestSchedulerError, RequestSchedulerErrorCode } from './RequestScheduler'; import type { IWorkspaceSession } from './WorkspaceSession'; +import { assertWorkspaceRequestResourcesHealthy } from './WorkspaceRequestResources'; export interface IRequestAdmissionClient { readonly abortSignal: AbortSignal; @@ -33,20 +33,35 @@ export interface IRequestAdmissionControllerOptions { const REQUEST_SCHEDULER_BY_SESSION: WeakMap = new WeakMap(); +class WorkspaceRequestScheduler extends RequestScheduler { + readonly #session: IWorkspaceSession; + + public constructor(session: IWorkspaceSession) { + super(); + this.#session = session; + } + + public override async acquireAsync(options: IRequestSchedulerAcquireOptions): Promise { + assertWorkspaceRequestResourcesHealthy(this.#session); + const lease: IRequestLease = await super.acquireAsync(options); + try { + assertWorkspaceRequestResourcesHealthy(this.#session); + return lease; + } catch (error) { + lease.release(); + throw error; + } + } +} + class QueuePositionWriter { readonly #abortController: AbortController; readonly #requestId: string; - readonly #writeQueuePositionAsync: ( - message: IDaemonRequestQueuePositionMessage - ) => Promise; + readonly #writeQueuePositionAsync: (message: IDaemonRequestQueuePositionMessage) => Promise; #failure: unknown; #tail: Promise = Promise.resolve(); - public constructor( - client: IRequestAdmissionClient, - requestId: string, - abortController: AbortController - ) { + public constructor(client: IRequestAdmissionClient, requestId: string, abortController: AbortController) { const writeQueuePositionAsync: IRequestAdmissionClient['writeQueuePositionAsync'] = client.writeQueuePositionAsync; if (!writeQueuePositionAsync) { @@ -141,6 +156,13 @@ export class RequestAdmissionController { this.#client.abortSignal.removeEventListener('abort', this.#abortFromClient); } + /** Passes the remaining admission budget to another existing routing boundary. */ + public get remainingAdmission(): IDaemonRequestAdmissionOptions | undefined { + return this.#admission + ? { ...this.#admission, waitTimeoutMs: this.#getRemainingWaitTimeoutMs() } + : undefined; + } + #getRemainingWaitTimeoutMs(): number | undefined { return this.#deadlineMs === undefined ? undefined : Math.max(0, this.#deadlineMs - Date.now()); } @@ -161,9 +183,7 @@ export class RequestAdmissionController { } } -export function getRequestAdmissionErrorCode( - error: RequestSchedulerError -): DaemonRequestAdmissionErrorCode { +export function getRequestAdmissionErrorCode(error: RequestSchedulerError): DaemonRequestAdmissionErrorCode { switch (error.code) { case RequestSchedulerErrorCode.Aborted: return 'aborted'; @@ -177,7 +197,7 @@ export function getRequestAdmissionErrorCode( export function getWorkspaceRequestScheduler(workspaceSession: IWorkspaceSession): RequestScheduler { let scheduler: RequestScheduler | undefined = REQUEST_SCHEDULER_BY_SESSION.get(workspaceSession); if (!scheduler) { - scheduler = new RequestScheduler(); + scheduler = new WorkspaceRequestScheduler(workspaceSession); REQUEST_SCHEDULER_BY_SESSION.set(workspaceSession, scheduler); } return scheduler; diff --git a/libraries/rush-daemon/src/WorkspaceRequestLifecycle.ts b/libraries/rush-daemon/src/WorkspaceRequestLifecycle.ts new file mode 100644 index 0000000000..da5ca8230a --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceRequestLifecycle.ts @@ -0,0 +1,840 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { + captureProjectConfigurationFingerprintAsync, + captureWorkspaceInputFingerprintAsync, + classifyWorkspaceInputChange, + EnvironmentVariableNames, + PhasedCommandEngineBusyError, + Rush, + WorkspaceInputChangeTier, + WorkspaceRuntimeFingerprintCache, + type IWorkspaceInputFingerprint +} from '@microsoft/rush-lib'; +import { LockFile } from '@rushstack/node-core-library'; +import { NoOpTerminalProvider, Terminal } from '@rushstack/terminal'; +import type { IDaemonCommandResult, IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import { + DaemonRequestDispatchError, + type DispatchWorkspaceRequestAsync, + type IDaemonRequestDispatchClient, + type IDaemonRequestLifecycle, + type IDaemonRequestResolver +} from './DaemonRequestDispatcher'; +import { createNativeMutationResolver } from './NativeMutationRequest'; +import { parseDaemonGraphRequest, type IDaemonGraphRequest } from './DaemonGraphRequest'; +import { isRushxInvocation, type IWorkspaceResolverLifecycle } from './WorkspaceResolverLifecycle'; +import { + RequestExclusivityClass, + RequestScheduler, + RequestSchedulerError, + type IRequestLease +} from './RequestScheduler'; +import { + RequestAdmissionController, + getRequestAdmissionErrorCode, + getWorkspaceRequestScheduler +} from './WorkspaceRequestAdmission'; +import { WorkspaceEngineRecreationRequiredError } from './WorkspaceEngineComponentFactory'; +import type { IWorkspaceSession } from './WorkspaceSession'; +import type { WorkspaceSessionProvider } from './WorkspaceSessionProvider'; +import { assertWorkspaceRequestResourcesHealthy } from './WorkspaceRequestResources'; +import type { + GetWorkspaceSuccessorLaunchAsync, + IWorkspaceProcessRestartContext, + IWorkspaceProcessRestartPlan, + IWorkspaceSuccessorLaunch +} from './WorkspaceProcessRestart'; + +interface IExecutionState { + began: boolean; + terminalAttempted: boolean; + resultDrained: boolean; +} + +interface IPreparedGeneration { + readonly session: IWorkspaceSession; + readonly resolver: IDaemonRequestResolver; + readonly generation: number; + readonly lease: IRequestLease; + readonly fingerprint: IWorkspaceInputFingerprint; +} + +export interface IWorkspaceRequestLifecycleOptions { + readonly provider: WorkspaceSessionProvider; + readonly resolver: IDaemonRequestResolver; + readonly rushVersion: string; + readonly getSuccessorLaunchAsync: GetWorkspaceSuccessorLaunchAsync | undefined; + readonly onRestartRequested: (plan: IWorkspaceProcessRestartPlan) => void; +} + +class RestartBeforeExecution extends Error { + public readonly plan: IWorkspaceProcessRestartPlan; + public readonly session: IWorkspaceSession; + public readonly lease: IRequestLease; + public readonly workspaceLease: IRequestLease; + public constructor( + plan: IWorkspaceProcessRestartPlan, + session: IWorkspaceSession, + lease: IRequestLease, + workspaceLease: IRequestLease + ) { + super( + 'Workspace process inputs changed. No operation was scheduled or executed. Reconnect and submit a new request after restart.' + ); + this.plan = plan; + this.session = session; + this.lease = lease; + this.workspaceLease = workspaceLease; + } +} + +class RestartPendingBeforeExecution extends Error { + public constructor() { + super('The workspace is restarting. No operation was scheduled or executed.'); + } +} + +/** + * Generation admission composes the existing request schedulers, native locks and session provider. + * It never releases a resolved request onto a different session, and never replays scheduled work. + */ +export class WorkspaceRequestLifecycle implements IDaemonRequestLifecycle { + readonly #options: IWorkspaceRequestLifecycleOptions; + readonly #gate: RequestScheduler = new RequestScheduler(); + readonly #abortController: AbortController = new AbortController(); + readonly #observers: Set = new Set(); + readonly #terminal: Terminal = new Terminal(new NoOpTerminalProvider()); + readonly #runtimePaths: ReadonlyArray = [__dirname, path.resolve(__dirname, '../package.json')]; + readonly #startupFingerprint: IWorkspaceInputFingerprint; + readonly #runtimeCache: WorkspaceRuntimeFingerprintCache; + #fingerprint: IWorkspaceInputFingerprint; + #projectFingerprint: string | undefined; + #commandIdentity: string | undefined; + #resolver: IDaemonRequestResolver; + readonly #ownedResolvers: Set = new Set(); + #boundSession: IWorkspaceSession | undefined; + #forceReload: boolean = false; + #closing: boolean = false; + #restartPending: boolean = false; + #lastReloadTier: WorkspaceInputChangeTier = WorkspaceInputChangeTier.Reuse; + #transitioning: boolean = false; + #cleanupFailure: unknown; + #disposePromise: Promise | undefined; + + private constructor( + options: IWorkspaceRequestLifecycleOptions, + fingerprint: IWorkspaceInputFingerprint, + runtimeCache: WorkspaceRuntimeFingerprintCache + ) { + this.#options = options; + this.#startupFingerprint = this.#fingerprint = fingerprint; + this.#resolver = options.resolver; + this.#ownedResolvers.add(options.resolver); + this.#runtimeCache = runtimeCache; + } + + public static async createAsync( + options: IWorkspaceRequestLifecycleOptions + ): Promise { + const session: IWorkspaceSession = await options.provider.getSessionAsync(); + const runtimeCache: WorkspaceRuntimeFingerprintCache = new WorkspaceRuntimeFingerprintCache(); + const fingerprint: IWorkspaceInputFingerprint = await captureWorkspaceInputFingerprintAsync({ + rushConfiguration: session.rushConfiguration, + environment: process.env, + runtimePaths: [__dirname, path.resolve(__dirname, '../package.json')], + runtimeCache + }); + return new WorkspaceRequestLifecycle(options, fingerprint, runtimeCache); + } + + /** The last applied input decision; reading status never changes or reloads the workspace. */ + public get lastReloadTier(): WorkspaceInputChangeTier { + return this.#lastReloadTier; + } + + public async dispatchAsync( + request: IDaemonRequestEnvelope, + destination: IDaemonRequestDispatchClient, + dispatchAsync: DispatchWorkspaceRequestAsync + ): Promise { + // Native Rush owns its SDK handoff; a foreign client's bundled engine must not override this one. + const envelope: IDaemonRequestEnvelope = { + ...request, + environment: { + ...request.environment, + [EnvironmentVariableNames._RUSH_LIB_PATH]: require.resolve('@microsoft/rush-lib') + } + }; + if (this.#restartPending) { + await destination.interactiveSession.finishAsync(); + await destination.writeResultAsync({ + ...preExecutionFailure(envelope.requestId, new RestartPendingBeforeExecution()), + retryAfterRestart: true + }); + return; + } + if (this.#closing) + throw new Error('The workspace lifecycle is closing. No operation was scheduled or executed.'); + if (this.#cleanupFailure !== undefined) throw this.#cleanupFailure; + const state: IExecutionState = { began: false, terminalAttempted: false, resultDrained: false }; + const observer: AbortController | undefined = isGraphWatch(envelope) ? new AbortController() : undefined; + if (observer) this.#observers.add(observer); + const signal: AbortSignal = AbortSignal.any([ + destination.abortSignal, + this.#abortController.signal, + ...(observer ? [observer.signal] : []) + ]); + const client: IDaemonRequestDispatchClient = createLifecycleClient(destination, signal, state); + const admission: RequestAdmissionController = new RequestAdmissionController({ + admission: envelope.admission, + client, + requestId: envelope.requestId + }); + let generation: IPreparedGeneration | undefined; + try { + for (let attempt: number = 0; ; attempt++) { + try { + generation = await this.#prepareAsync(envelope, client, admission); + const requestEnvelope: IDaemonRequestEnvelope = { + ...envelope, + admission: admission.remainingAdmission + }; + if (isMutation(envelope)) { + await this.#executeMutationAsync(generation, requestEnvelope, client, state, dispatchAsync); + } else { + await dispatchAsync({ + envelope: requestEnvelope, + client, + workspaceSession: generation.session, + resolver: generation.resolver, + onExecutionStarting: () => { + this.#assertGeneration(generation!); + state.began = true; + } + }); + } + return; + } catch (error) { + if ( + error instanceof WorkspaceEngineRecreationRequiredError && + !state.began && + !state.terminalAttempted && + !isGraphRequest(envelope) && + attempt < 1 + ) { + this.#forceReload = true; + continue; + } + if (error instanceof RestartBeforeExecution && !state.began && !state.terminalAttempted) { + try { + await client.interactiveSession.finishAsync(); + await client.writeResultAsync({ + ...preExecutionFailure(envelope.requestId, error), + retryAfterRestart: true + }); + error.session.retire?.(); + this.#lastReloadTier = WorkspaceInputChangeTier.Restart; + this.#restartPending = true; + this.#closing = true; + this.#options.onRestartRequested(error.plan); + } finally { + error.workspaceLease.release(); + error.lease.release(); + } + return; + } + if (error instanceof RestartPendingBeforeExecution && !state.began && !state.terminalAttempted) { + await client.interactiveSession.finishAsync(); + await client.writeResultAsync({ + ...preExecutionFailure(envelope.requestId, error), + retryAfterRestart: true + }); + return; + } + if (error instanceof RequestSchedulerError && !state.began && !state.terminalAttempted) { + await client.interactiveSession.finishAsync(); + await client.writeResultAsync({ + ...preExecutionFailure(envelope.requestId, error), + aborted: client.abortSignal.aborted, + admissionErrorCode: getRequestAdmissionErrorCode(error) + }); + return; + } + throw error; + } finally { + generation?.lease.release(); + generation = undefined; + } + } + } finally { + admission.dispose(); + if (observer) this.#observers.delete(observer); + } + } + + async #prepareAsync( + envelope: IDaemonRequestEnvelope, + client: IDaemonRequestDispatchClient, + admission: RequestAdmissionController, + admittedLease?: IRequestLease + ): Promise { + let lease: IRequestLease = + admittedLease ?? (await admission.acquireAsync(this.#gate, RequestExclusivityClass.SharedBuild)); + let ownsTransition: boolean = false; + try { + if (this.#restartPending) throw new RestartPendingBeforeExecution(); + if (this.#closing) + throw new Error('The workspace is restarting. No operation was scheduled or executed.'); + if (this.#cleanupFailure !== undefined) throw this.#cleanupFailure; + let session: IWorkspaceSession = await this.#options.provider.getSessionAsync(); + assertWorkspaceRequestResourcesHealthy(session); + if (isRushxInvocation(envelope)) { + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint: this.#fingerprint + }; + } + if (isGraphRequest(envelope)) { + const graphRequest: IDaemonGraphRequest = parseDaemonGraphRequest(envelope); + if (session.operationGraph && !['show', 'status', 'watch'].includes(graphRequest.verb)) { + // Graph control environment flags are not a request to change the retained engine environment. + const controlEnvelope: IDaemonRequestEnvelope = { + ...envelope, + environment: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + }; + const current: IWorkspaceInputFingerprint = await this.#captureAsync(session, controlEnvelope); + const currentTier: WorkspaceInputChangeTier = this.#classify(current, false); + if (currentTier === WorkspaceInputChangeTier.Restart) { + lease.release(); + this.#cancelObservers(); + lease = await admission.acquireAsync(this.#gate, RequestExclusivityClass.Exclusive); + session = await this.#options.provider.getSessionAsync(); + await this.#quiesceWarmSetAsync(session); + const workspaceLease: IRequestLease = await admission.acquireAsync( + getWorkspaceRequestScheduler(session), + RequestExclusivityClass.Exclusive + ); + try { + const plan: IWorkspaceProcessRestartPlan = await this.#restartPlanAsync( + session, + controlEnvelope, + 'hard-input-change' + ); + throw new RestartBeforeExecution(plan, session, lease, workspaceLease); + } catch (error) { + if (!(error instanceof RestartBeforeExecution)) workspaceLease.release(); + throw error; + } + } + if ( + currentTier !== WorkspaceInputChangeTier.Reuse || + (this.#boundSession && + this.#projectFingerprint !== + (await captureProjectConfigurationFingerprintAsync( + session.rushConfiguration, + this.#terminal + ))) + ) { + throw new Error( + 'Graph inputs changed. Load the new generation with a supported build request; no operation was scheduled or executed.' + ); + } + } + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint: this.#fingerprint + }; + } + if ( + envelope.commandOrigin !== 'built-in' || + !['build', 'rebuild', 'install', 'update'].includes(envelope.commandName) + ) { + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint: this.#fingerprint + }; + } + let fingerprint: IWorkspaceInputFingerprint = await this.#captureAsync(session, envelope); + let tier: WorkspaceInputChangeTier = this.#classify(fingerprint, isMutation(envelope)); + let commandIdentity: string | undefined; + let projectFingerprint: string | undefined; + if (tier !== WorkspaceInputChangeTier.Restart && !isMutation(envelope)) { + commandIdentity = await getResolverLifecycle(this.#resolver).getCommandParameterIdentityAsync({ + envelope, + workspaceSession: session, + abortSignal: client.abortSignal + }); + if (tier === WorkspaceInputChangeTier.Reuse) { + projectFingerprint = await captureProjectConfigurationFingerprintAsync( + session.rushConfiguration, + this.#terminal + ); + if ( + this.#boundSession !== session || + this.#commandIdentity !== commandIdentity || + this.#projectFingerprint !== projectFingerprint || + this.#forceReload || + !session.invalidations.getSnapshot().isWatcherHealthy || + session.invalidations.hasUnattributedUnknownChanges + ) + tier = WorkspaceInputChangeTier.Reload; + } + } + if (tier === WorkspaceInputChangeTier.Reuse && !isMutation(envelope)) { + this.#lastReloadTier = WorkspaceInputChangeTier.Reuse; + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint + }; + } + + lease.release(); + if (this.#transitioning) { + const shared: IRequestLease = await admission.acquireAsync( + this.#gate, + RequestExclusivityClass.SharedBuild + ); + return await this.#prepareAsync(envelope, client, admission, shared); + } + this.#transitioning = ownsTransition = true; + this.#cancelObservers(); + lease = await admission.acquireAsync(this.#gate, RequestExclusivityClass.Exclusive); + if (this.#restartPending) throw new RestartPendingBeforeExecution(); + if (this.#closing) + throw new Error('The workspace is restarting. No operation was scheduled or executed.'); + session = await this.#options.provider.getSessionAsync(); + fingerprint = await this.#captureAsync(session, envelope); + tier = this.#classify(fingerprint, isMutation(envelope)); + if (tier === WorkspaceInputChangeTier.Restart) { + await this.#quiesceWarmSetAsync(session); + const workspaceLease: IRequestLease = await admission.acquireAsync( + getWorkspaceRequestScheduler(session), + RequestExclusivityClass.Exclusive + ); + try { + const plan: IWorkspaceProcessRestartPlan = await this.#restartPlanAsync( + session, + envelope, + 'hard-input-change' + ); + throw new RestartBeforeExecution(plan, session, lease, workspaceLease); + } catch (error) { + if (!(error instanceof RestartBeforeExecution)) workspaceLease.release(); + throw error; + } + } + if (isMutation(envelope)) { + if (!this.#options.getSuccessorLaunchAsync) { + throw new DaemonRequestDispatchError( + 'unsupported', + 'Native mutations require a successor launcher. No worker was started.' + ); + } + await this.#quiesceWarmSetAsync(session); + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint + }; + } + + commandIdentity = await getResolverLifecycle(this.#resolver).getCommandParameterIdentityAsync({ + envelope, + workspaceSession: session, + abortSignal: client.abortSignal + }); + if ( + this.#boundSession === session && + this.#commandIdentity === commandIdentity && + tier === WorkspaceInputChangeTier.Reuse && + !this.#forceReload && + session.invalidations.getSnapshot().isWatcherHealthy && + !session.invalidations.hasUnattributedUnknownChanges + ) { + projectFingerprint = await captureProjectConfigurationFingerprintAsync( + session.rushConfiguration, + this.#terminal + ); + if (projectFingerprint === this.#projectFingerprint) { + this.#lastReloadTier = WorkspaceInputChangeTier.Reuse; + this.#gate.downgradeExclusiveLease(lease, RequestExclusivityClass.SharedBuild); + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint + }; + } + } + await this.#quiesceWarmSetAsync(session); + const workspaceLease: IRequestLease = await admission.acquireAsync( + getWorkspaceRequestScheduler(session), + RequestExclusivityClass.Exclusive + ); + const nativeLock: LockFile | undefined = LockFile.tryAcquire( + session.rushConfiguration.commonTempFolder, + 'rush' + ); + if (!nativeLock) { + workspaceLease.release(); + throw new PhasedCommandEngineBusyError(); + } + try { + const before: IWorkspaceInputFingerprint = await this.#captureAsync(session, envelope); + let expectedFingerprint: IWorkspaceInputFingerprint = before; + const validationContext: { session?: IWorkspaceSession } = {}; + const previousResolver: IDaemonRequestResolver = this.#resolver; + const resolver: IDaemonRequestResolver = getResolverLifecycle(previousResolver).createForSession( + nativeLock, + async () => { + const replacementSession: IWorkspaceSession | undefined = validationContext.session; + if (!replacementSession) throw new Error('The replacement generation is not initialized.'); + const current: IWorkspaceInputFingerprint = await this.#captureAsync( + replacementSession, + envelope + ); + if ( + classifyWorkspaceInputChange(expectedFingerprint, current) !== WorkspaceInputChangeTier.Reuse + ) { + throw new WorkspaceEngineRecreationRequiredError(); + } + } + ); + this.#ownedResolvers.add(resolver); + try { + if (resolver === previousResolver) { + throw new Error('A new workspace generation must receive a new resolver instance.'); + } + getResolverLifecycle(resolver); + } catch (error) { + this.#cleanupFailure = error; + throw error; + } + this.#resolver = resolver; + try { + await previousResolver[Symbol.asyncDispose]?.(); + this.#ownedResolvers.delete(previousResolver); + } catch (error) { + this.#cleanupFailure = error; + throw error; + } + const replacementSession: IWorkspaceSession = await this.#options.provider.reloadAsync(); + validationContext.session = replacementSession; + session = replacementSession; + await resolver.resolveRequestAsync({ + envelope, + workspaceSession: session, + abortSignal: client.abortSignal + }); + const after: IWorkspaceInputFingerprint = await this.#captureAsync(session, envelope); + if (classifyWorkspaceInputChange(before, after) !== WorkspaceInputChangeTier.Reuse) { + this.#forceReload = true; + throw new WorkspaceEngineRecreationRequiredError(); + } + this.#resolver = resolver; + expectedFingerprint = after; + this.#boundSession = session; + this.#fingerprint = after; + this.#projectFingerprint = await captureProjectConfigurationFingerprintAsync( + session.rushConfiguration, + this.#terminal + ); + this.#commandIdentity = await getResolverLifecycle(resolver).getCommandParameterIdentityAsync({ + envelope, + workspaceSession: session, + abortSignal: client.abortSignal + }); + this.#lastReloadTier = WorkspaceInputChangeTier.Reload; + this.#forceReload = false; + fingerprint = after; + } catch (error) { + this.#forceReload = true; + if (error instanceof AggregateError) this.#cleanupFailure = error; + throw error; + } finally { + nativeLock.release(); + workspaceLease.release(); + } + this.#gate.downgradeExclusiveLease(lease, RequestExclusivityClass.SharedBuild); + return { + session, + resolver: this.#resolver, + generation: this.#options.provider.generation, + lease, + fingerprint + }; + } catch (error) { + if (!(error instanceof RestartBeforeExecution)) lease.release(); + throw error; + } finally { + if (ownsTransition) this.#transitioning = false; + } + } + + async #quiesceWarmSetAsync(session: IWorkspaceSession): Promise { + this.#forceReload = true; + try { + await session.quiesceWarmSetAsync?.(); + } catch (error) { + this.#cleanupFailure = error; + throw error; + } + } + + #captureAsync( + session: IWorkspaceSession, + envelope: IDaemonRequestEnvelope + ): Promise { + return captureWorkspaceInputFingerprintAsync({ + rushConfiguration: session.rushConfiguration, + environment: envelope.environment, + runtimePaths: this.#runtimePaths, + runtimeCache: this.#runtimeCache + }); + } + + #classify(fingerprint: IWorkspaceInputFingerprint, mutation: boolean): WorkspaceInputChangeTier { + if ( + fingerprint.selectedRushVersion !== Rush.version || + fingerprint.selectedRushVersion !== this.#options.rushVersion || + fingerprint.environmentHash !== this.#startupFingerprint.environmentHash || + fingerprint.runtimeHash !== this.#startupFingerprint.runtimeHash || + (!mutation && fingerprint.installationHash !== this.#startupFingerprint.installationHash) + ) + return WorkspaceInputChangeTier.Restart; + return fingerprint.configurationHash === this.#fingerprint.configurationHash + ? WorkspaceInputChangeTier.Reuse + : WorkspaceInputChangeTier.Reload; + } + + async #restartPlanAsync( + session: IWorkspaceSession, + envelope: IDaemonRequestEnvelope, + reason: IWorkspaceProcessRestartPlan['reason'] + ): Promise { + const fingerprint: IWorkspaceInputFingerprint = await this.#captureAsync(session, envelope); + const getLaunchAsync: GetWorkspaceSuccessorLaunchAsync | undefined = + this.#options.getSuccessorLaunchAsync; + if (!getLaunchAsync) + throw new Error( + `A new daemon process is required (${[ + fingerprint.runtimeHash !== this.#startupFingerprint.runtimeHash + ? `implementation: ${this.#runtimeCache.changedPaths.slice(0, 3).join(', ')}` + : '', + fingerprint.environmentHash !== this.#startupFingerprint.environmentHash ? 'environment' : '', + fingerprint.installationHash !== this.#startupFingerprint.installationHash ? 'installation' : '', + fingerprint.selectedRushVersion !== Rush.version || + fingerprint.selectedRushVersion !== this.#options.rushVersion + ? `selected Rush ${fingerprint.selectedRushVersion}; running ${Rush.version}` + : '' + ] + .filter(Boolean) + .join(', ')}), but this host has no successor launcher. No operation was scheduled or executed.` + ); + const context: IWorkspaceProcessRestartContext = { + repoRoot: session.metadata.repoRoot, + rushVersion: fingerprint.selectedRushVersion, + environment: Object.freeze({ ...envelope.environment }), + reason + }; + const launch: IWorkspaceSuccessorLaunch = await getLaunchAsync(context); + return { ...context, launch, failure: undefined }; + } + + async #executeMutationAsync( + generation: IPreparedGeneration, + envelope: IDaemonRequestEnvelope, + client: IDaemonRequestDispatchClient, + state: IExecutionState, + dispatchAsync: DispatchWorkspaceRequestAsync + ): Promise { + let restart: IWorkspaceProcessRestartPlan | undefined; + const prepareRestartAsync: () => Promise = async () => { + try { + restart = await this.#restartPlanAsync(generation.session, envelope, 'native-mutation'); + } catch (error) { + restart = { + repoRoot: generation.session.metadata.repoRoot, + rushVersion: generation.fingerprint.selectedRushVersion, + environment: Object.freeze({ ...envelope.environment }), + reason: 'native-mutation', + launch: undefined, + failure: error instanceof Error ? error : new Error(String(error)) + }; + } + }; + const resolver: IDaemonRequestResolver = createNativeMutationResolver( + envelope, + generation.fingerprint.selectedRushVersion, + async (context) => { + await prepareRestartAsync(); + if (restart?.failure) + context.terminal.writeErrorLine( + `Mutation completed, but successor startup is unavailable: ${restart.failure.message}` + ); + generation.session.retire?.(); + } + ); + try { + await dispatchAsync({ + envelope, + client, + workspaceSession: generation.session, + resolver, + onExecutionStarting: () => { + this.#assertGeneration(generation); + state.began = true; + } + }); + } finally { + if (state.began) { + if (!restart) await prepareRestartAsync(); + if (!state.resultDrained) { + restart = { + ...restart!, + launch: undefined, + failure: new Error( + 'Mutation result could not be drained; stopping without an automatic successor.' + ) + }; + } + this.#lastReloadTier = WorkspaceInputChangeTier.Restart; + this.#closing = true; + this.#restartPending = restart!.launch !== undefined && restart!.failure === undefined; + generation.session.retire?.(); + this.#options.onRestartRequested(restart!); + } + } + } + + #assertGeneration(generation: IPreparedGeneration): void { + assertWorkspaceRequestResourcesHealthy(generation.session); + generation.session.assertActive?.(); + if (generation.generation !== this.#options.provider.generation) { + throw new Error( + 'Workspace generation changed before execution. No operation was scheduled or executed.' + ); + } + } + + #cancelObservers(): void { + for (const observer of this.#observers) observer.abort(new Error('Workspace generation is changing.')); + } + + public [Symbol.asyncDispose](): Promise { + this.#closing = true; + this.#abortController.abort(); + this.#cancelObservers(); + this.#disposePromise ??= (async () => { + const lease: IRequestLease = await this.#gate.acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + const failures: unknown[] = []; + try { + for (const resolver of this.#ownedResolvers) { + try { + await resolver[Symbol.asyncDispose]?.(); + } catch (error) { + failures.push(error); + } + } + this.#ownedResolvers.clear(); + if (this.#cleanupFailure !== undefined && !failures.includes(this.#cleanupFailure)) { + failures.push(this.#cleanupFailure); + } + } finally { + lease.release(); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, 'Failed to dispose workspace resolvers.'); + })(); + return this.#disposePromise; + } +} + +function isMutation(envelope: IDaemonRequestEnvelope): boolean { + return ( + !isRushxInvocation(envelope) && + envelope.commandOrigin === 'built-in' && + ['install', 'update'].includes(envelope.commandName) + ); +} + +function isGraphRequest(envelope: IDaemonRequestEnvelope): boolean { + return ( + !isRushxInvocation(envelope) && envelope.commandOrigin === 'built-in' && envelope.commandName === 'daemon' + ); +} + +function getResolverLifecycle(resolver: IDaemonRequestResolver): IWorkspaceResolverLifecycle { + if (!resolver.workspaceLifecycle) { + throw new Error('A generation replacement lost its workspace resolver lifecycle capability.'); + } + return resolver.workspaceLifecycle; +} + +function isGraphWatch(envelope: IDaemonRequestEnvelope): boolean { + return isGraphRequest(envelope) && envelope.argv[1] === 'graph' && envelope.argv[2] === 'watch'; +} + +function preExecutionFailure(requestId: string, error: Error): IDaemonCommandResult { + return { requestId, exitCode: 1, outcome: 'failure', aborted: false, errorMessage: error.message }; +} + +function createLifecycleClient( + client: IDaemonRequestDispatchClient, + abortSignal: AbortSignal, + state: IExecutionState +): IDaemonRequestDispatchClient { + return { + abortSignal, + interactiveSession: client.interactiveSession, + sessionId: client.sessionId, + supportsRequestAdmission: client.supportsRequestAdmission, + getNextEventSequence: () => client.getNextEventSequence(), + writeEventAsync: (event) => { + state.began = true; + return client.writeEventAsync(event); + }, + writeLogChunkAsync: (operationId, stream, chunk) => { + state.began = true; + return client.writeLogChunkAsync(operationId, stream, chunk); + }, + writeQueuePositionAsync: (message) => client.writeQueuePositionAsync(message), + writeTerminalChunkAsync: (stream, chunk) => { + state.began = true; + return client.writeTerminalChunkAsync(stream, chunk); + }, + writeTerminalPolicyAsync: (result) => { + state.terminalAttempted = true; + return client.writeTerminalPolicyAsync(result); + }, + writeResultAsync: async (result) => { + state.terminalAttempted = true; + await client.writeResultAsync(result); + state.resultDrained = true; + } + }; +} diff --git a/libraries/rush-daemon/src/WorkspaceRequestResources.ts b/libraries/rush-daemon/src/WorkspaceRequestResources.ts new file mode 100644 index 0000000000..6cbd5c481e --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceRequestResources.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IWorkspaceSession } from './WorkspaceSession'; + +const CLEANUP_FAILURES: WeakMap = new WeakMap(); + +export class WorkspaceRequestResourceCleanupError extends Error { + public readonly requestId: string; + + public constructor(requestId: string, cause: unknown) { + const detail: string = cause instanceof Error ? cause.message : String(cause); + super(`Failed to release resources for request "${requestId}": ${detail}`, { cause }); + this.name = 'WorkspaceRequestResourceCleanupError'; + this.requestId = requestId; + } +} + +export function recordWorkspaceRequestCleanupFailure( + session: IWorkspaceSession, + requestId: string, + cause: unknown +): WorkspaceRequestResourceCleanupError { + const failure: WorkspaceRequestResourceCleanupError = + cause instanceof WorkspaceRequestResourceCleanupError + ? cause + : new WorkspaceRequestResourceCleanupError(requestId, cause); + if (!CLEANUP_FAILURES.has(session)) CLEANUP_FAILURES.set(session, failure); + session.retire?.(); + return failure; +} + +/** A command result, later disposal, or generation reload cannot certify a failed resource join. */ +export function assertWorkspaceRequestResourcesHealthy(session: IWorkspaceSession): void { + const failure: WorkspaceRequestResourceCleanupError | undefined = CLEANUP_FAILURES.get(session); + if (failure) throw failure; +} diff --git a/libraries/rush-daemon/src/WorkspaceResolverLifecycle.ts b/libraries/rush-daemon/src/WorkspaceResolverLifecycle.ts new file mode 100644 index 0000000000..4c223399ee --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceResolverLifecycle.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { LockFile } from '@rushstack/node-core-library'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRequestResolver, IResolveDaemonRequestOptions } from './DaemonRequestDispatcher'; + +/** Optional lifecycle capabilities retained by resolver decorators across workspace generations. @beta */ +export interface IWorkspaceResolverLifecycle { + getCommandParameterIdentityAsync(options: IResolveDaemonRequestOptions): Promise; + createForSession( + preparationLock?: LockFile, + validateGraphInputsAsync?: () => Promise + ): IDaemonRequestResolver; +} + +/** + * Forwards native lifecycle capabilities while preserving the outer resolver on every generation. + * Returns undefined when the delegated resolver has no workspace lifecycle. + * @beta + */ +export function wrapWorkspaceResolverLifecycle( + resolver: IDaemonRequestResolver, + wrap: (resolver: IDaemonRequestResolver) => IDaemonRequestResolver +): IWorkspaceResolverLifecycle | undefined { + const lifecycle: IWorkspaceResolverLifecycle | undefined = resolver.workspaceLifecycle; + return ( + lifecycle && { + getCommandParameterIdentityAsync: (options) => lifecycle.getCommandParameterIdentityAsync(options), + createForSession: (lock, validateAsync) => wrap(lifecycle.createForSession(lock, validateAsync)) + } + ); +} + +/** Recognizes explicit invocation metadata without treating a script name as a Rush command. @beta */ +export function isRushxInvocation(envelope: IDaemonRequestEnvelope): boolean { + return 'invocationKind' in envelope && envelope.invocationKind === 'rushx'; +} diff --git a/libraries/rush-daemon/src/WorkspaceSession.ts b/libraries/rush-daemon/src/WorkspaceSession.ts index 12cd66c20f..620fc006cb 100644 --- a/libraries/rush-daemon/src/WorkspaceSession.ts +++ b/libraries/rush-daemon/src/WorkspaceSession.ts @@ -2,16 +2,16 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { realpath } from 'node:fs/promises'; -import { RushConfiguration } from '@microsoft/rush-lib'; -import type { - IInputsSnapshot, - IOperationGraph, - RushSession -} from '@microsoft/rush-lib'; +import { PhasedCommandEngineBusyError, RushConfiguration } from '@microsoft/rush-lib'; +import type { IInputsSnapshot, IOperationGraph, RushSession } from '@microsoft/rush-lib'; import { WorkspaceInvalidationTracker } from './WorkspaceInvalidationTracker'; import { WorkspaceSessionFileWatcher } from './WorkspaceSessionFileWatcher'; +import { WorkspaceWarmSet, type IWorkspaceWarmSetStatus } from './WorkspaceWarmSet'; +import { getWorkspaceRequestScheduler } from './WorkspaceRequestAdmission'; +import { assertWorkspaceRequestResourcesHealthy } from './WorkspaceRequestResources'; import type { IWorkspaceEngineShape, IWorkspaceInvalidationReconciliation @@ -23,6 +23,8 @@ import type { * @beta */ export interface IWorkspaceSessionMetadata { + /** Host-local session generation. It changes only when a new session is installed. */ + readonly generation?: number; readonly projectCount: number; readonly projectNames: ReadonlyArray; readonly repoRoot: string; @@ -43,12 +45,13 @@ export interface IWorkspaceInvalidationWatcher extends AsyncDisposable { * Optional engine state supplied when reusable graph construction is available. * * @remarks - * The default session intentionally leaves the graph, plugin session, and inputs snapshot uninitialized. - * Their existing construction is command-specific and remains blocked on the reusable runner lifetime work. + * A session can supply components at creation or bind its command-dependent engine on the first request. * * @beta */ export interface IWorkspaceSessionComponents extends AsyncDisposable { + /** Acquire once for graph reconciliation/execution, not separately for each merged client. */ + readonly acquireExecutionLeaseAsync?: () => Promise; readonly engineShape?: IWorkspaceEngineShape; readonly inputsSnapshot?: IInputsSnapshot; readonly operationGraph?: IOperationGraph; @@ -90,6 +93,7 @@ export type CreateWorkspaceSessionComponentsAsync = ( * @beta */ export interface IWorkspaceSessionOptions { + readonly generation?: number; readonly createComponentsAsync?: CreateWorkspaceSessionComponentsAsync; readonly onError?: (error: Error) => void; readonly repoRoot: string; @@ -102,6 +106,15 @@ export interface IWorkspaceSessionOptions { * @beta */ export interface IWorkspaceSession extends AsyncDisposable { + /** Stops background footprint maintenance before acquiring reload or mutation locks. */ + quiesceWarmSetAsync?(): Promise; + readonly warmSetStatus?: IWorkspaceWarmSetStatus; + /** Rejects work retained beyond this session's lifetime. */ + assertActive?(): void; + /** Stops admission without starting resource cleanup, allowing an already produced result to drain. */ + retire?(): void; + /** Optional execution lease retained through the coalesced iteration's output and resource cleanup. */ + acquireExecutionLeaseAsync?(): Promise; readonly engineShape: IWorkspaceEngineShape | undefined; readonly inputsSnapshot: IInputsSnapshot | undefined; readonly invalidations: WorkspaceInvalidationTracker; @@ -109,6 +122,8 @@ export interface IWorkspaceSession extends AsyncDisposable { readonly operationGraph: IOperationGraph | undefined; readonly rushConfiguration: RushConfiguration; readonly rushSession: RushSession | undefined; + /** Binds the first command-dependent engine, if this session supports lazy engine initialization. */ + initializeEngineAsync?(factory: CreateWorkspaceSessionComponentsAsync): Promise; reconcileInvalidationsAsync(): Promise; } @@ -129,17 +144,20 @@ const EMPTY_WORKSPACE_SESSION_COMPONENTS: IWorkspaceSessionComponents = { * @beta */ export class WorkspaceSession implements IWorkspaceSession { - readonly #components: IWorkspaceSessionComponents; + #components: IWorkspaceSessionComponents; readonly #sessionOwnedProjectWatcher: IWorkspaceInvalidationWatcher | undefined; #disposePromise: Promise | undefined; #inputsSnapshot: IInputsSnapshot | undefined; #isDisposing: boolean = false; + #engineInitialization: Promise | undefined; + #warmSet: WorkspaceWarmSet | undefined; + #warmSetQuiescence: Promise | undefined; + #initializationCleanupFailure: unknown; + #onError: ((error: Error) => void) | undefined; public readonly invalidations: WorkspaceInvalidationTracker; public readonly metadata: IWorkspaceSessionMetadata; - public readonly operationGraph: IOperationGraph | undefined; public readonly rushConfiguration: RushConfiguration; - public readonly rushSession: RushSession | undefined; private constructor( rushConfiguration: RushConfiguration, @@ -154,8 +172,14 @@ export class WorkspaceSession implements IWorkspaceSession { this.#components = components; this.#sessionOwnedProjectWatcher = sessionOwnedProjectWatcher; this.#inputsSnapshot = components.inputsSnapshot; - this.operationGraph = components.operationGraph; - this.rushSession = components.rushSession; + } + + public get operationGraph(): IOperationGraph | undefined { + return this.#components.operationGraph; + } + + public get rushSession(): RushSession | undefined { + return this.#components.rushSession; } public get engineShape(): IWorkspaceEngineShape | undefined { @@ -166,12 +190,134 @@ export class WorkspaceSession implements IWorkspaceSession { return this.#inputsSnapshot; } + public get warmSetStatus(): IWorkspaceWarmSetStatus | undefined { + return this.#warmSet?.getStatus(); + } + + public quiesceWarmSetAsync(): Promise { + this.#warmSetQuiescence ??= this.#quiesceWarmSetOnceAsync(); + return this.#warmSetQuiescence; + } + + async #quiesceWarmSetOnceAsync(): Promise { + const warmSet: WorkspaceWarmSet | undefined = this.#warmSet; + const results: PromiseSettledResult[] = await Promise.allSettled([ + warmSet?.[Symbol.asyncDispose](), + this.#engineInitialization?.catch(() => undefined) + ]); + const errors: unknown[] = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ); + if (this.#warmSet !== warmSet) { + try { + await this.#warmSet?.[Symbol.asyncDispose](); + } catch (error) { + errors.push(error); + } + } + if (this.#initializationCleanupFailure !== undefined) errors.push(this.#initializationCleanupFailure); + if (errors.length) throw new AggregateError(errors, 'Failed to quiesce workspace warm resources.'); + } + + public assertActive(): void { + assertWorkspaceRequestResourcesHealthy(this); + if (this.#isDisposing) + throw new Error('This workspace generation has been disposed; execution has not begun.'); + } + + public retire(): void { + this.#isDisposing = true; + } + + /** Installs one all-project engine without replacing the watcher or losing retained invalidations. */ + public async initializeEngineAsync(factory: CreateWorkspaceSessionComponentsAsync): Promise { + assertWorkspaceRequestResourcesHealthy(this); + if (this.#isDisposing) throw new Error('The workspace session is being disposed.'); + if (this.#warmSetQuiescence) + throw new Error('The workspace session is quiescing; no new engine may attach.'); + if (!this.#engineInitialization) { + if (this.#components !== EMPTY_WORKSPACE_SESSION_COMPONENTS) { + throw new Error('Workspace components have already been supplied.'); + } + // Publish ownership before invoking user-supplied construction, which can reenter quiescence. + const initialization: Promise = Promise.resolve().then(() => + this.#initializeEngineAsync(factory) + ); + this.#engineInitialization = initialization; + void initialization.catch((error: unknown) => { + if ( + error instanceof PhasedCommandEngineBusyError && + this.#engineInitialization === initialization && + !this.#isDisposing + ) { + this.#engineInitialization = undefined; + } + }); + } + await this.#engineInitialization; + } + + public async acquireExecutionLeaseAsync(): Promise { + assertWorkspaceRequestResourcesHealthy(this); + if (this.#isDisposing) throw new Error('The workspace session is being disposed.'); + return await this.#components.acquireExecutionLeaseAsync?.(); + } + + async #initializeEngineAsync(factory: CreateWorkspaceSessionComponentsAsync): Promise { + const components: IWorkspaceSessionComponents = await factory({ + invalidations: this.invalidations, + rushConfiguration: this.rushConfiguration, + onError: this.#onError + }); + if (this.#isDisposing || this.#warmSetQuiescence || components.projectWatcher) { + await this.#disposeRejectedComponentsAsync(components); + throw new Error( + 'Cannot install engine components after quiescence/disposal or replace the session watcher.' + ); + } + this.#components = components; + this.#inputsSnapshot = components.inputsSnapshot; + this.#attachWarmSet(); + } + + async #disposeRejectedComponentsAsync(components: IWorkspaceSessionComponents): Promise { + this.#components = components; + this.#warmSet = components.operationGraph && WorkspaceWarmSet.getAttached(components.operationGraph); + try { + await this.#warmSet?.[Symbol.asyncDispose](); + await components[Symbol.asyncDispose](); + this.#components = EMPTY_WORKSPACE_SESSION_COMPONENTS; + } catch (error) { + // Keep ownership and fail the generation barrier even if the initialization request already failed. + this.#initializationCleanupFailure = error; + throw error; + } + } + + #attachWarmSet(): void { + const { operationGraph, acquireExecutionLeaseAsync, projectWatcher } = this.#components; + const watcher: IWorkspaceInvalidationWatcher | undefined = + projectWatcher ?? this.#sessionOwnedProjectWatcher; + if (operationGraph?.deleteResults && watcher instanceof WorkspaceSessionFileWatcher) { + this.#warmSet = + WorkspaceWarmSet.getAttached(operationGraph) ?? + WorkspaceWarmSet.attach({ + operationGraph, + configuration: this.rushConfiguration.daemon, + scheduler: getWorkspaceRequestScheduler(this), + acquireExecutionLeaseAsync: async () => await acquireExecutionLeaseAsync?.(), + watcher, + onDiagnostic: this.#onError + }); + } + } + /** Loads workspace identity, creates reusable components, and starts headless invalidation tracking. */ public static async createAsync(options: IWorkspaceSessionOptions): Promise { + const canonicalRepoRoot: string = await realpath(options.repoRoot); const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile( - path.join(options.repoRoot, 'rush.json') + path.join(canonicalRepoRoot, 'rush.json') ); - const canonicalRepoRoot: string = path.resolve(options.repoRoot); if (path.resolve(rushConfiguration.rushJsonFolder) !== canonicalRepoRoot) { throw new Error(`Rush configuration resolved outside the daemon workspace: ${options.repoRoot}`); } @@ -185,36 +331,45 @@ export class WorkspaceSession implements IWorkspaceSession { })) ?? EMPTY_WORKSPACE_SESSION_COMPONENTS; let projectWatcher: IWorkspaceInvalidationWatcher | undefined = components.projectWatcher; let sessionOwnedProjectWatcher: IWorkspaceInvalidationWatcher | undefined; + let session: WorkspaceSession | undefined; try { - const metadata: IWorkspaceSessionMetadata = createMetadata( - rushConfiguration, - options.rushVersion - ); + const metadata: IWorkspaceSessionMetadata = { + ...createMetadata(rushConfiguration, options.rushVersion), + generation: options.generation ?? 1 + }; if (!projectWatcher) { projectWatcher = new WorkspaceSessionFileWatcher({ onError: (error: Error) => { invalidations.markWatcherUnhealthy(); options.onError?.(error); }, - rushConfiguration + rushConfiguration, + projectNames: [] }); sessionOwnedProjectWatcher = projectWatcher; } - const session: WorkspaceSession = new WorkspaceSession( + session = new WorkspaceSession( rushConfiguration, metadata, invalidations, components, sessionOwnedProjectWatcher ); + session.#onError = options.onError; await projectWatcher.startAsync((changedPath: string | undefined) => invalidations.invalidate(changedPath) ); + session.#attachWarmSet(); // Changes before the watcher registered its callbacks cannot be observed path-by-path. invalidations.invalidateForInitialization(); return session; } catch (error) { const cleanupErrors: unknown[] = []; + try { + await session?.quiesceWarmSetAsync(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } try { await sessionOwnedProjectWatcher?.[Symbol.asyncDispose](); } catch (cleanupError) { @@ -244,19 +399,20 @@ export class WorkspaceSession implements IWorkspaceSession { /** Reconciles retained watcher changes with injected reusable engine state, when configured. */ public async reconcileInvalidationsAsync(): Promise { + assertWorkspaceRequestResourcesHealthy(this); if (this.#isDisposing) { throw new Error('The workspace session is being disposed.'); } if (!this.#components.reconcileInvalidationsAsync) { return undefined; } - const result: IWorkspaceInvalidationReconciliation = - await this.#components.reconcileInvalidationsAsync(); + const result: IWorkspaceInvalidationReconciliation = await this.#components.reconcileInvalidationsAsync(); this.#inputsSnapshot = result.inputsSnapshot; return result; } async #disposeOnceAsync(): Promise { + await this.quiesceWarmSetAsync(); let watcherError: unknown; try { await this.#sessionOwnedProjectWatcher?.[Symbol.asyncDispose](); @@ -278,6 +434,7 @@ export class WorkspaceSession implements IWorkspaceSession { if (watcherError !== undefined) { throw watcherError; } + assertWorkspaceRequestResourcesHealthy(this); } } diff --git a/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts b/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts index acee521b21..05cb910190 100644 --- a/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts +++ b/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts @@ -3,16 +3,18 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { once } from 'node:events'; import type { RushConfiguration } from '@microsoft/rush-lib'; import type { IWorkspaceInvalidationWatcher } from './WorkspaceSession'; +/** Options for the generation-owned workspace watcher. @beta */ export interface IWorkspaceSessionFileWatcherOptions { readonly onError?: (error: Error) => void; readonly rushConfiguration: RushConfiguration; readonly watchFactory?: WorkspaceWatchFactory; + /** Initially observed projects. Omitted preserves the all-project behavior. */ + readonly projectNames?: Iterable; } interface IWatchPath { @@ -20,7 +22,8 @@ interface IWatchPath { readonly recursive: boolean; } -type WorkspaceWatchFactory = ( +/** Creates an individual filesystem watcher. @beta */ +export type WorkspaceWatchFactory = ( folderPath: string, options: { encoding: 'utf8'; recursive: boolean }, listener: fs.WatchListener @@ -28,18 +31,38 @@ type WorkspaceWatchFactory = ( const PATH_SEGMENT_SEPARATOR_REGEXP: RegExp = /[\\/]/; +/** + * Keeps root/config observation permanent while allowing idle project watchers to be released. + * Scope changes must be serialized with graph requests and generation disposal by the owner. + * @beta + */ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatcher { readonly #onError: ((error: Error) => void) | undefined; readonly #watchFactory: WorkspaceWatchFactory; - readonly #watchPaths: ReadonlyArray; - readonly #watchers: Set = new Set(); + readonly #permanentPaths: ReadonlyArray; + readonly #projectFolders: ReadonlyMap; + readonly #initialProjectNames: ReadonlyArray; + readonly #watchers: Map = new Map(); + readonly #closing: Map> = new Map(); #onInvalidation: ((changedPath?: string) => void) | undefined; #disposed: boolean = false; public constructor(options: IWorkspaceSessionFileWatcherOptions) { this.#onError = options.onError; this.#watchFactory = options.watchFactory ?? fs.watch; - this.#watchPaths = getWatchPaths(options.rushConfiguration); + this.#permanentPaths = getPermanentWatchPaths(options.rushConfiguration); + this.#projectFolders = new Map( + options.rushConfiguration.projects.map((project) => [project.packageName, project.projectFolder]) + ); + this.#initialProjectNames = [...(options.projectNames ?? this.#projectFolders.keys())]; + this.#validateProjects(this.#initialProjectNames); + } + + /** Projects whose recursive observation is still resident, including pending/failed closes. */ + public get watchedProjectNames(): ReadonlySet { + return new Set( + [...this.#projectFolders].filter(([, folder]) => this.#watchers.has(folder)).map(([name]) => name) + ); } public async startAsync(onInvalidation: (changedPath?: string) => void): Promise { @@ -51,26 +74,98 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche } this.#onInvalidation = onInvalidation; - for (const watchPath of this.#watchPaths) { - this.#watchers.add(this.#createWatcher(watchPath)); + for (const watchPath of this.#permanentPaths) { + this.#watchers.set(watchPath.folderPath, this.#createWatcher(watchPath)); } + this.watchProjects(this.#initialProjectNames); } - public async [Symbol.asyncDispose](): Promise { - if (this.#disposed) { - return; + /** Restores project observation without running scripts. Does not suppress watcher errors. */ + public watchProjects(projectNames: Iterable): void { + if (this.#disposed || !this.#onInvalidation) { + throw new Error('Project observation requires a started, live workspace watcher.'); } - this.#disposed = true; - const closePromises: Promise[] = []; - for (const watcher of this.#watchers) { - closePromises.push(once(watcher, 'close')); - watcher.close(); + const names: string[] = [...projectNames]; + this.#validateProjects(names); + for (const name of names) { + const folderPath: string = this.#projectFolders.get(name)!; + const existing: fs.FSWatcher | undefined = this.#watchers.get(folderPath); + if (existing && this.#closing.has(existing)) { + throw new Error(`Project watcher is still closing: ${name}`); + } + if (!existing) { + this.#watchers.set(folderPath, this.#createWatcher({ folderPath, recursive: true })); + // Cover the observation gap without claiming an unknown graph/configuration mutation. + this.#onInvalidation(folderPath); + } + } + } + + /** Awaits close before removing accounting. Permanent root/config watchers are never removed. */ + public async unwatchProjectsAsync(projectNames: Iterable): Promise { + const names: string[] = [...projectNames]; + this.#validateProjects(names); + const permanentFolders: Set = new Set(this.#permanentPaths.map((entry) => entry.folderPath)); + const watchers: Set = new Set(); + for (const name of names) { + const folder: string = this.#projectFolders.get(name)!; + const watcher: fs.FSWatcher | undefined = this.#watchers.get(folder); + if (watcher && !permanentFolders.has(folder)) watchers.add(watcher); } - await Promise.all(closePromises); - this.#watchers.clear(); + await this.#closeWatchersAsync(watchers); + } + + public async [Symbol.asyncDispose](): Promise { + this.#disposed = true; + await this.#closeWatchersAsync(this.#watchers.values()); this.#onInvalidation = undefined; } + #validateProjects(names: Iterable): void { + for (const name of names) { + if (!this.#projectFolders.has(name)) throw new Error(`Unknown watched project: ${name}`); + } + } + + async #closeWatchersAsync(watchers: Iterable): Promise { + const results: PromiseSettledResult[] = await Promise.allSettled( + [...watchers].map((watcher) => this.#closeWatcherAsync(watcher)) + ); + const errors: unknown[] = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ); + if (errors.length) throw new AggregateError(errors, 'Failed to close workspace project watchers.'); + } + + #closeWatcherAsync(watcher: fs.FSWatcher): Promise { + const existing: Promise | undefined = this.#closing.get(watcher); + if (existing) return existing; + const closing: Promise = new Promise((resolve, reject) => { + function cleanup(): void { + watcher.off('close', onClose); + watcher.off('error', onError); + } + function onClose(): void { + cleanup(); + resolve(); + } + function onError(error: Error): void { + cleanup(); + reject(error); + } + watcher.once('close', onClose); + watcher.once('error', onError); + try { + watcher.close(); + } catch (error) { + cleanup(); + reject(error); + } + }).finally(() => this.#closing.delete(watcher)); + this.#closing.set(watcher, closing); + return closing; + } + #createWatcher(watchPath: IWatchPath): fs.FSWatcher { const watcher: fs.FSWatcher = this.#watchFactory( watchPath.folderPath, @@ -80,31 +175,31 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche const changedFilename: string | undefined = filename ?? undefined; if (!isIgnoredPath(changedFilename)) { this.#onInvalidation?.( - changedFilename === undefined - ? undefined - : path.resolve(watchPath.folderPath, changedFilename) + changedFilename === undefined ? undefined : path.resolve(watchPath.folderPath, changedFilename) ); } } ); watcher.on('error', (error: Error) => { this.#onInvalidation?.(); - this.#onError?.(error); + if (this.#onError) this.#onError(error); + else process.emitWarning(error, { code: 'RUSH_DAEMON_WATCHER_ERROR' }); + }); + watcher.once('close', () => { + if (this.#watchers.get(watchPath.folderPath) === watcher) { + this.#watchers.delete(watchPath.folderPath); + } }); - watcher.once('close', () => this.#watchers.delete(watcher)); watcher.unref(); return watcher; } } -function getWatchPaths(rushConfiguration: RushConfiguration): ReadonlyArray { +function getPermanentWatchPaths(rushConfiguration: RushConfiguration): ReadonlyArray { const recursiveFolders: Set = new Set([rushConfiguration.commonRushConfigFolder]); for (const subspace of rushConfiguration.subspaces) { recursiveFolders.add(subspace.getSubspaceConfigFolderPath()); } - for (const project of rushConfiguration.projects) { - recursiveFolders.add(project.projectFolder); - } return [ { folderPath: rushConfiguration.rushJsonFolder, recursive: false }, ...Array.from(recursiveFolders, (folderPath: string) => ({ folderPath, recursive: true })) diff --git a/libraries/rush-daemon/src/WorkspaceSessionProvider.ts b/libraries/rush-daemon/src/WorkspaceSessionProvider.ts index 85ba8eba51..09421100c6 100644 --- a/libraries/rush-daemon/src/WorkspaceSessionProvider.ts +++ b/libraries/rush-daemon/src/WorkspaceSessionProvider.ts @@ -6,6 +6,8 @@ import type { IWorkspaceSessionOptions, WorkspaceSessionFactory } from './WorkspaceSession'; +import { getWorkspaceGenerationToken } from './WorkspaceGeneration'; +import { assertWorkspaceRequestResourcesHealthy } from './WorkspaceRequestResources'; export class WorkspaceSessionProvider implements AsyncDisposable { readonly #factory: WorkspaceSessionFactory; @@ -15,6 +17,23 @@ export class WorkspaceSessionProvider implements AsyncDisposable { #initializationPromise: Promise | undefined; #session: IWorkspaceSession | undefined; #disposed: boolean = false; + #generation: number = 1; + #reloadPromise: Promise | undefined; + #cleanupFailure: unknown; + + public get generation(): number { + return this.#generation; + } + + /** Reads installed state without initializing, awaiting or replacing a session or graph. */ + public get currentSession(): IWorkspaceSession | undefined { + return this.#session; + } + + /** The installed session token, absent while no session is installed. */ + public get currentGenerationToken(): string | undefined { + return this.#session && getWorkspaceGenerationToken(this.#session); + } public constructor(factory: WorkspaceSessionFactory, options: IWorkspaceSessionOptions) { this.#factory = factory; @@ -25,6 +44,8 @@ export class WorkspaceSessionProvider implements AsyncDisposable { if (this.#disposed) { return Promise.reject(new Error('The workspace session provider has been disposed.')); } + if (this.#cleanupFailure !== undefined) return Promise.reject(this.#cleanupFailure); + if (this.#reloadPromise) return this.#reloadPromise; if (this.#session) { return Promise.resolve(this.#session); } @@ -47,9 +68,45 @@ export class WorkspaceSessionProvider implements AsyncDisposable { return this.#disposePromise; } + /** The caller must hold lifecycle/workspace admission and the native preparation lock. */ + public reloadAsync(): Promise { + if (this.#disposed) return Promise.reject(new Error('The workspace session provider has been disposed.')); + if (this.#cleanupFailure !== undefined) return Promise.reject(this.#cleanupFailure); + if (!this.#reloadPromise) { + const reload: Promise = this.#reloadOnceAsync(); + this.#reloadPromise = reload; + void reload + .finally(() => { + if (this.#reloadPromise === reload) this.#reloadPromise = undefined; + }) + .catch(() => undefined); + } + return this.#reloadPromise; + } + + async #reloadOnceAsync(): Promise { + const oldSession: IWorkspaceSession | undefined = this.#session ?? (await this.#initializationPromise); + if (oldSession) { + try { + assertWorkspaceRequestResourcesHealthy(oldSession); + oldSession.operationGraph?.discardScheduledIteration(); + await oldSession[Symbol.asyncDispose](); + assertWorkspaceRequestResourcesHealthy(oldSession); + } catch (error) { + this.#cleanupFailure = error; + throw error; + } + } + this.#session = undefined; + this.#initializationPromise = undefined; + this.#generation++; + return await this.#initializeAsync(); + } + async #disposeOnceAsync(): Promise { this.#disposed = true; try { + await this.#reloadPromise?.catch(() => undefined); const session: IWorkspaceSession | undefined = this.#session ?? (await this.#initializationPromise?.then( @@ -58,6 +115,7 @@ export class WorkspaceSessionProvider implements AsyncDisposable { )); if (session) { await session[Symbol.asyncDispose](); + assertWorkspaceRequestResourcesHealthy(session); } else { await this.#initializationDisposalPromise; } @@ -69,11 +127,12 @@ export class WorkspaceSessionProvider implements AsyncDisposable { } async #initializeAsync(): Promise { - const session: IWorkspaceSession = await this.#factory(this.#options); + const session: IWorkspaceSession = await this.#factory({ + ...this.#options, + generation: this.#generation + }); if (this.#disposed) { - this.#initializationDisposalPromise = Promise.resolve().then(() => - session[Symbol.asyncDispose]() - ); + this.#initializationDisposalPromise = Promise.resolve().then(() => session[Symbol.asyncDispose]()); await this.#initializationDisposalPromise; throw new Error('The workspace session provider was disposed during initialization.'); } diff --git a/libraries/rush-daemon/src/WorkspaceStatus.ts b/libraries/rush-daemon/src/WorkspaceStatus.ts new file mode 100644 index 0000000000..9a35089cd0 --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceStatus.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { WorkspaceInputChangeTier } from '@microsoft/rush-lib'; +import type { IDaemonWorkspaceStatus } from '@rushstack/rush-daemon-protocol'; + +import type { IWorkspaceSession } from './WorkspaceSession'; +import type { WorkspaceSessionProvider } from './WorkspaceSessionProvider'; + +/** Status must never initialize a cold graph or wait behind reload/execution leases. */ +export function getWorkspaceStatus( + provider: WorkspaceSessionProvider, + lastReloadTier?: WorkspaceInputChangeTier +): IDaemonWorkspaceStatus { + const session: IWorkspaceSession | undefined = provider.currentSession; + return { + generation: provider.generation, + lastReloadTier, + generationToken: provider.currentGenerationToken, + graphInitialized: session?.operationGraph !== undefined, + warmSet: session?.warmSetStatus + }; +} diff --git a/libraries/rush-daemon/src/WorkspaceWarmSet.ts b/libraries/rush-daemon/src/WorkspaceWarmSet.ts new file mode 100644 index 0000000000..17461ea2da --- /dev/null +++ b/libraries/rush-daemon/src/WorkspaceWarmSet.ts @@ -0,0 +1,522 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + OperationStatus, + PhasedCommandEngineBusyError, + resolveDaemonConfiguration, + type IDaemonConfigurationJson, + type IOperationExecutionResult, + type IOperationGraph, + type IOperationRunner, + type Operation +} from '@microsoft/rush-lib'; +import type { IDaemonWarmSetStatus } from '@rushstack/rush-daemon-protocol'; +import { isResourceFreeNullOperationRunner } from '@microsoft/rush-lib/lib/logic/operations/NullOperationRunner'; + +import { + RequestExclusivityClass, + RequestSchedulerError, + RequestSchedulerErrorCode, + type IRequestLease, + type RequestScheduler +} from './RequestScheduler'; +import { compareWarmSetRanks, type IWarmSetRank } from './WarmSetRanking'; +import type { WorkspaceSessionFileWatcher } from './WorkspaceSessionFileWatcher'; + +/** The runtime footprint/latency policy, independent of command correctness. @beta */ +export type WorkspaceWarmSetConfiguration = Pick< + IDaemonConfigurationJson, + 'watch' | 'warmIdleTimeoutSeconds' | 'warmMemoryBudgetMB' | 'warmSetMaxProjects' | 'autoWarmByTelemetry' +>; + +/** Attachment contract for one generation's real graph and watcher. @beta */ +export interface IWorkspaceWarmSetOptions { + readonly operationGraph: IOperationGraph; + readonly configuration: WorkspaceWarmSetConfiguration; + /** The same workspace scheduler used by builds, global requests and graph mutations. */ + readonly scheduler: RequestScheduler; + /** Acquires native repository ownership, after exclusive workspace admission. */ + readonly acquireExecutionLeaseAsync: () => Promise; + /** Already started; ownership/disposal stays with the generation. */ + readonly watcher: WorkspaceSessionFileWatcher; + /** Additional subscriptions/resources to protect. Changes must use the workspace scheduler. */ + readonly getProtectedOperations?: () => ReadonlySet; + /** Optimization failures/pressure only; must not turn command results into failures. */ + readonly onDiagnostic?: (error: Error) => void; +} + +/** A sampled footprint, not a hard process/tree RSS guarantee. @beta */ +export type IWorkspaceWarmSetStatus = IDaemonWarmSetStatus; + +interface IProjectHistory { + readonly operations: Operation[]; + lastUsed: number; + frequency: number; +} + +interface IOperationTiming { + coldDurationMs: number | undefined; + timeSavedMs: number | undefined; +} + +interface IWarmProject extends IWarmSetRank { + readonly operations: ReadonlyArray; + readonly protected: boolean; +} + +const PLUGIN_NAME: string = 'WorkspaceWarmSet'; +const RETRY_DELAY_MS: number = 1000; +const MAX_POLL_DELAY_MS: number = 30_000; +const BYTES_PER_MB: number = 1024 * 1024; +const ATTACHED_GRAPHS: WeakMap = new WeakMap(); + +/** + * Retains explicitly requested work; never schedules operations or changes their enabled/result policy. + * Attach once per generation. Dispose before disposing its graph/watcher and outside request leases. + * @beta + */ +export class WorkspaceWarmSet implements AsyncDisposable { + readonly #options: IWorkspaceWarmSetOptions; + readonly #projects: Map = new Map(); + readonly #timings: Map = new Map(); + readonly #reusedRunners: WeakSet = new WeakSet(); + readonly #cleanupFailures: Map = new Map(); + #configuration: Readonly>; + #timer: NodeJS.Timeout | undefined; + #maintenance: Promise | undefined; + #disposed: boolean = false; + #deferredReason: IWorkspaceWarmSetStatus['deferredReason']; + #pressureKey: string | undefined; + #leaseReleaseFailure: Error | undefined; + #watcherPolicyFailure: Error | undefined; + + private constructor(options: IWorkspaceWarmSetOptions) { + this.#options = options; + this.#configuration = resolveWarmConfiguration(options.configuration); + const now: number = performance.now(); + for (const operation of options.operationGraph.operations) { + const name: string = operation.associatedProject.packageName; + let project: IProjectHistory | undefined = this.#projects.get(name); + if (!project) { + project = { operations: [], lastUsed: now, frequency: 0 }; + this.#projects.set(name, project); + } + project.operations.push(operation); + } + const graph: IOperationGraph = options.operationGraph; + graph.hooks.configureIteration.tap({ name: PLUGIN_NAME, stage: Infinity }, () => { + if (this.#disposed) return; + const requested: string[] = []; + const requestedAt: number = performance.now(); + for (const [name, project] of this.#projects) { + if (!project.operations.some((operation) => operation.enabled !== false)) continue; + project.lastUsed = requestedAt; + project.frequency++; + requested.push(name); + } + try { + if (this.#configuration.watch) options.watcher.watchProjects(requested); + } catch (error) { + this.#reportWatcherPolicyFailure(error); + } + this.#schedule(0); + }); + graph.hooks.beforeExecuteOperationAsync.tap(PLUGIN_NAME, (record) => { + if (!this.#disposed && record.operation.runner?.isActive) this.#reusedRunners.add(record); + return undefined; + }); + graph.hooks.afterExecuteIterationAsync.tap({ name: PLUGIN_NAME, stage: Infinity }, (status, records) => { + if (!this.#disposed) { + try { + this.#recordTimings(records); + } catch (error) { + this.#diagnose(new Error('Could not read warm-set timing telemetry.', { cause: error })); + } + } + return status; + }); + graph.hooks.onIdle.tap(PLUGIN_NAME, () => this.#schedule(0)); + this.#schedule(0); + } + + /** + * Attaches policies to the actual graph. The owner must refresh inputs and revalidate effective + * configuration on EVERY request, including requests for projects without watchers. + */ + public static attach(options: IWorkspaceWarmSetOptions): WorkspaceWarmSet { + if (!options.operationGraph.deleteResults) { + throw new Error('Warm-set attachment requires a graph supporting safe retained-result deletion.'); + } + if (ATTACHED_GRAPHS.has(options.operationGraph)) { + throw new Error('A warm set is already attached to this graph.'); + } + const controller: WorkspaceWarmSet = new WorkspaceWarmSet(options); + ATTACHED_GRAPHS.set(options.operationGraph, controller); + return controller; + } + + /** Lets a generation adopt an integration-supplied controller without installing duplicate hooks. */ + public static getAttached(graph: IOperationGraph): WorkspaceWarmSet | undefined { + return ATTACHED_GRAPHS.get(graph); + } + + /** Revalidates observation and warm-resource policy and applies it on the next idle maintenance turn. */ + public updateConfiguration(configuration: WorkspaceWarmSetConfiguration): void { + if (this.#disposed) throw new Error('The workspace warm set is disposed.'); + this.#configuration = resolveWarmConfiguration(configuration); + this.#schedule(0); + } + + /** Reads actual retained resources and available producer measurements without mutating the graph. */ + public getStatus(): IWorkspaceWarmSetStatus { + const projects: IWarmProject[] = this.#rankProjects(); + let measuredRunnerMemoryBytes: number = 0; + let unmeasuredRunnerCount: number = 0; + for (const operation of this.#options.operationGraph.operations) { + const runner: IOperationRunner | undefined = operation.runner; + if (!runner || runner.isActive === false || (!runner.isActive && !runner.closeAsync)) continue; + if (!runner.isActive && !this.#options.operationGraph.resultByOperation.has(operation)) continue; + const bytes: number | undefined = runner.residentMemoryBytes; + if (isMeasuredMemory(bytes)) measuredRunnerMemoryBytes += bytes; + else unmeasuredRunnerCount++; + } + const daemonResidentMemoryBytes: number = process.memoryUsage().rss; + return { + configuration: this.#configuration, + maintenanceState: this.#getMaintenanceState(), + maintenanceFailure: this.#leaseReleaseFailure?.message, + retainedProjectNames: projects.map((project) => project.key), + projectRanks: projects.map((project) => ({ + projectName: project.key, + frequency: project.frequency, + lastUsed: project.lastUsed, + timeSavedMs: project.timeSavedMs, + measuredRunnerMemoryBytes: project.residentMemoryBytes || undefined + })), + protectedProjectNames: projects.filter((project) => project.protected).map((project) => project.key), + watchedProjectNames: [...this.#options.watcher.watchedProjectNames].sort(), + daemonResidentMemoryBytes, + measuredRunnerMemoryBytes, + unmeasuredRunnerCount, + overMemoryBudget: + daemonResidentMemoryBytes + measuredRunnerMemoryBytes > + this.#configuration.warmMemoryBudgetMB * BYTES_PER_MB, + overProjectLimit: projects.length > this.#configuration.warmSetMaxProjects, + deferredReason: this.#deferredReason, + cleanupFailures: [ + ...this.#cleanupFailures.values(), + ...(this.#watcherPolicyFailure ? [this.#watcherPolicyFailure.message] : []) + ] + }; + } + + #getMaintenanceState(): IWorkspaceWarmSetStatus['maintenanceState'] { + if (this.#leaseReleaseFailure) return 'failed'; + if (!this.#disposed) return 'running'; + return this.#maintenance ? 'quiescing' : 'stopped'; + } + + /** Runs an idle pass, or reports why it was deferred. Optional cleanup never changes build results. */ + public maintainAsync(): Promise { + if (!this.#maintenance) { + this.#maintenance = this.#maintainOnceAsync().finally(() => { + this.#maintenance = undefined; + if (!this.#disposed && !this.#leaseReleaseFailure) { + const now: number = performance.now(); + const delay: number = Math.min( + MAX_POLL_DELAY_MS, + ...this.#rankProjects().map((project) => { + const remaining: number = + project.lastUsed + this.#configuration.warmIdleTimeoutSeconds * 1000 - now; + return remaining > 0 ? Math.max(1, remaining) : RETRY_DELAY_MS; + }) + ); + this.#schedule( + this.#deferredReason || this.#watcherPolicyFailure ? Math.min(delay, RETRY_DELAY_MS) : delay + ); + } + }); + } + return this.#maintenance; + } + + public async [Symbol.asyncDispose](): Promise { + this.#disposed = true; + this.#deferredReason = 'disposed'; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = undefined; + await this.#maintenance; + if (this.#leaseReleaseFailure) throw this.#leaseReleaseFailure; + } + + #schedule(delay: number): void { + if (this.#disposed || this.#leaseReleaseFailure) return; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = setTimeout(() => { + this.#timer = undefined; + // Runtime failures are diagnosed inside the maintenance boundary, not delivered as build failures. + void this.maintainAsync().catch((error: unknown) => this.#diagnose(error)); + }, delay); + this.#timer.unref(); + } + + async #maintainOnceAsync(): Promise { + let admission: IRequestLease | undefined; + let nativeLease: AsyncDisposable | undefined; + this.#deferredReason = undefined; + try { + if (this.#disposed || this.#leaseReleaseFailure) { + this.#deferredReason = 'disposed'; + } else { + admission = await this.#options.scheduler.acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive, + noWait: true + }); + const graph: IOperationGraph = this.#options.operationGraph; + if (this.#disposed) { + this.#deferredReason = 'disposed'; + } else if (isGraphBusy(graph)) { + this.#deferredReason = 'graph-busy'; + } else { + nativeLease = await this.#options.acquireExecutionLeaseAsync(); + if (this.#disposed) this.#deferredReason = 'disposed'; + else if (isGraphBusy(graph)) this.#deferredReason = 'graph-busy'; + else { + await this.#evictIdleAsync(); + if (!this.#disposed) await this.#reconcileWatcherPolicyAsync(); + } + } + } + } catch (error) { + if (error instanceof RequestSchedulerError && error.code === RequestSchedulerErrorCode.NoWait) { + this.#deferredReason = 'workspace-busy'; + } else if (error instanceof PhasedCommandEngineBusyError) { + this.#deferredReason = 'native-busy'; + } else { + this.#diagnose( + new Error('Warm-set maintenance failed; retained resources may still be resident.', { + cause: error + }) + ); + } + } finally { + try { + await nativeLease?.[Symbol.asyncDispose](); + } catch (error) { + this.#leaseReleaseFailure = new Error('Failed to release the warm-set native execution lease.', { + cause: error + }); + this.#diagnose(this.#leaseReleaseFailure); + } finally { + admission?.release(); + } + } + const status: IWorkspaceWarmSetStatus = this.getStatus(); + this.#reportPressure(status); + return status; + } + + async #reconcileWatcherPolicyAsync(): Promise { + const { watcher } = this.#options; + try { + const projects: IWarmProject[] = this.#rankProjects(); + if (this.#configuration.watch) { + watcher.watchProjects( + projects.filter((project) => !this.#cleanupFailures.has(project.key)).map((project) => project.key) + ); + } else { + await watcher.unwatchProjectsAsync( + projects.filter((project) => !project.protected).map((project) => project.key) + ); + } + this.#watcherPolicyFailure = undefined; + } catch (error) { + this.#reportWatcherPolicyFailure(error); + } + } + + #reportWatcherPolicyFailure(error: unknown): void { + const detail: string = error instanceof Error ? error.message : String(error); + this.#watcherPolicyFailure = new Error(`Failed to apply daemon.watch project observation: ${detail}`, { + cause: error + }); + this.#diagnose(this.#watcherPolicyFailure); + } + + async #evictIdleAsync(): Promise { + const { operationGraph: graph, watcher } = this.#options; + // Retention and eviction use exactly the same ordering, reversed only to release the lowest value first. + for (const project of this.#rankProjects().reverse()) { + if (this.#disposed) break; + if (project.protected) continue; + const status: IWorkspaceWarmSetStatus = this.getStatus(); + const expired: boolean = + performance.now() - project.lastUsed >= this.#configuration.warmIdleTimeoutSeconds * 1000; + const unrequested: boolean = + project.frequency === 0 && + project.operations.every( + (operation) => !graph.resultByOperation.has(operation) && !operation.runner?.isActive + ); + if (!unrequested && !expired && !status.overMemoryBudget && !status.overProjectLimit) continue; + try { + await graph.closeRunnersAsync(project.operations); + if (project.operations.some((operation) => operation.runner?.isActive)) { + throw new Error('A runner still reports active resources after close.'); + } + await watcher.unwatchProjectsAsync([project.key]); + graph.deleteResults!(project.operations); + this.#cleanupFailures.delete(project.key); + } catch (error) { + const detail: string = error instanceof Error ? error.message : String(error); + const message: string = `Could not evict warm project "${project.key}"; retained records were not dropped. ${detail}`; + this.#cleanupFailures.set(project.key, message); + this.#diagnose(new Error(message, { cause: error })); + } + } + } + + #rankProjects(): IWarmProject[] { + const { operationGraph: graph, watcher, getProtectedOperations } = this.#options; + const protectedOperations: ReadonlySet | undefined = getProtectedOperations?.(); + const watched: ReadonlySet = watcher.watchedProjectNames; + const projects: IWarmProject[] = []; + for (const [key, history] of this.#projects) { + const resident: Operation[] = history.operations.filter( + (operation) => graph.resultByOperation.has(operation) || operation.runner?.isActive + ); + if (!resident.length && !watched.has(key)) continue; + let residentMemoryBytes: number | undefined = 0; + let timeSavedMs: number | undefined = 0; + let resourceOperationCount: number = 0; + for (const operation of resident) { + if (isResourceFreeNullOperationRunner(operation.runner)) continue; + resourceOperationCount++; + const memory: number | undefined = operation.runner?.residentMemoryBytes; + const saved: number | undefined = this.#timings.get(operation)?.timeSavedMs; + residentMemoryBytes = + residentMemoryBytes !== undefined && isMeasuredMemory(memory) + ? residentMemoryBytes + memory + : undefined; + timeSavedMs = timeSavedMs !== undefined && saved !== undefined ? timeSavedMs + saved : undefined; + } + if (resourceOperationCount === 0) { + residentMemoryBytes = undefined; + timeSavedMs = undefined; + } + projects.push({ + key, + ...history, + residentMemoryBytes, + timeSavedMs, + protected: history.operations.some((operation) => protectedOperations?.has(operation)) + }); + } + return projects.sort((a, b) => compareWarmSetRanks(a, b, this.#configuration.autoWarmByTelemetry)); + } + + #recordTimings(records: ReadonlyMap): void { + const now: number = performance.now(); + for (const project of this.#projects.values()) { + if (project.operations.some((operation) => operation.enabled !== false)) project.lastUsed = now; + } + for (const [operation, record] of records) { + if ( + !record.enabled || + !operation.runner?.reportTiming || + ![OperationStatus.Success, OperationStatus.SuccessWithWarning, OperationStatus.FromCache].includes( + record.status + ) + ) + continue; + const durationMs: number = record.stopwatch.duration * 1000; + if (!Number.isFinite(durationMs) || durationMs < 0) continue; + let timing: IOperationTiming | undefined = this.#timings.get(operation); + if (!timing) { + timing = { coldDurationMs: undefined, timeSavedMs: undefined }; + this.#timings.set(operation, timing); + } + const baseline: number | undefined = + record.status === OperationStatus.FromCache ? record.nonCachedDurationMs : timing.coldDurationMs; + if ( + baseline !== undefined && + Number.isFinite(baseline) && + baseline >= 0 && + (record.status === OperationStatus.FromCache || this.#reusedRunners.has(record)) + ) { + timing.timeSavedMs = Math.max(0, baseline - durationMs); + } else if (!this.#reusedRunners.has(record) && record.status !== OperationStatus.FromCache) { + timing.coldDurationMs = durationMs; + timing.timeSavedMs = undefined; + } + } + } + + #reportPressure(status: IWorkspaceWarmSetStatus): void { + // A queued project-cap cleanup is normal during a request; status still exposes the deferral. + if (status.deferredReason && !status.overMemoryBudget) return; + const key: string | undefined = + status.overMemoryBudget || status.overProjectLimit + ? JSON.stringify([ + status.overMemoryBudget, + status.overProjectLimit, + status.deferredReason, + status.protectedProjectNames, + status.cleanupFailures, + status.unmeasuredRunnerCount + ]) + : undefined; + if (key !== undefined && key !== this.#pressureKey) { + this.#diagnose( + new Error( + `Warm-set pressure remains (budget ${this.#configuration.warmMemoryBudgetMB} MiB, ` + + `limit ${this.#configuration.warmSetMaxProjects} projects): daemon RSS ${status.daemonResidentMemoryBytes} bytes, ` + + `measured child RSS ${status.measuredRunnerMemoryBytes} bytes, ${status.unmeasuredRunnerCount} unmeasured runners, ` + + `${status.retainedProjectNames.length} retained projects, ${status.protectedProjectNames.length} protected. ` + + `Deferred: ${status.deferredReason ?? 'no'}. Active/protected resources and remaining daemon memory cannot be forced below the budget.` + ) + ); + } + this.#pressureKey = key; + } + + #diagnose(error: unknown): void { + const diagnostic: Error = error instanceof Error ? error : new Error(String(error)); + try { + if (this.#options.onDiagnostic) this.#options.onDiagnostic(diagnostic); + else + process.emitWarning(diagnostic.message, { code: 'RUSH_DAEMON_WARM_SET', detail: diagnostic.stack }); + } catch (callbackError) { + process.emitWarning( + `Warm-set diagnostic callback failed: ${String(callbackError)}. ${diagnostic.message}`, + { code: 'RUSH_DAEMON_WARM_SET' } + ); + } + } +} + +function isMeasuredMemory(bytes: number | undefined): bytes is number { + return bytes !== undefined && Number.isSafeInteger(bytes) && bytes > 0; +} + +function isGraphBusy(graph: IOperationGraph): boolean { + return ( + graph.hasScheduledIteration || + graph.status === OperationStatus.Executing || + graph.abortController.signal.aborted + ); +} + +function resolveWarmConfiguration( + configuration: WorkspaceWarmSetConfiguration +): Readonly> { + const { watch, warmIdleTimeoutSeconds, warmMemoryBudgetMB, warmSetMaxProjects, autoWarmByTelemetry } = + resolveDaemonConfiguration(configuration, {}); + return Object.freeze({ + watch, + warmIdleTimeoutSeconds, + warmMemoryBudgetMB, + warmSetMaxProjects, + autoWarmByTelemetry + }); +} diff --git a/libraries/rush-daemon/src/index.ts b/libraries/rush-daemon/src/index.ts index f60edd925d..89039a0c43 100644 --- a/libraries/rush-daemon/src/index.ts +++ b/libraries/rush-daemon/src/index.ts @@ -9,6 +9,9 @@ export { DaemonRequestDispatcher, type IDaemonRequestDispatchClient, type IDaemonRequestResolver, + type IDaemonRequestLifecycle, + type IDispatchWorkspaceRequestOptions, + type DispatchWorkspaceRequestAsync, type IResolvedDaemonGlobalRequest, type IResolvedDaemonPhasedRequest, type IResolveDaemonRequestOptions, @@ -18,10 +21,7 @@ export { type IDaemonInteractiveConnection, type IDaemonInteractiveRequestOptions } from './DaemonInteractiveConnection'; -export { - DaemonRequiresInProcessError, - evaluateDaemonTerminalPolicy -} from './DaemonTerminalPolicy'; +export { DaemonRequiresInProcessError, evaluateDaemonTerminalPolicy } from './DaemonTerminalPolicy'; export { type IInteractiveRequestControlClient, type IInteractiveRequestInputSink, @@ -89,3 +89,29 @@ export { } from './WorkspaceInvalidationTracker'; export { type IPhasedRequestClient } from './PhasedRequestClient'; export { PhasedRequestRouter } from './PhasedRequestRouter'; +export { ProductionDaemonRequestResolver } from './ProductionDaemonRequestResolver'; +export { getWorkspaceGenerationToken } from './WorkspaceGeneration'; +export { RushDaemonRequestResolver } from './RushDaemonRequestResolver'; +export { RushXDaemonRequestResolver } from './RushXDaemonRequestResolver'; +export { + WorkspaceWarmSet, + type IWorkspaceWarmSetOptions, + type IWorkspaceWarmSetStatus, + type WorkspaceWarmSetConfiguration +} from './WorkspaceWarmSet'; +export { + WorkspaceSessionFileWatcher, + type IWorkspaceSessionFileWatcherOptions, + type WorkspaceWatchFactory +} from './WorkspaceSessionFileWatcher'; +export { + wrapWorkspaceResolverLifecycle, + isRushxInvocation, + type IWorkspaceResolverLifecycle +} from './WorkspaceResolverLifecycle'; +export { + type GetWorkspaceSuccessorLaunchAsync, + type IWorkspaceProcessRestartContext, + type IWorkspaceProcessRestartResult, + type IWorkspaceSuccessorLaunch +} from './WorkspaceProcessRestart'; diff --git a/libraries/rush-daemon/src/serveRushDaemon.ts b/libraries/rush-daemon/src/serveRushDaemon.ts index a597058686..929b28bbf0 100644 --- a/libraries/rush-daemon/src/serveRushDaemon.ts +++ b/libraries/rush-daemon/src/serveRushDaemon.ts @@ -3,6 +3,7 @@ import { RushDaemonHost } from './RushDaemonHost'; import type { IRushDaemonHostOptions } from './RushDaemonHost'; +import { getInstalledWorkspaceSuccessorLaunchAsync } from './WorkspaceProcessRestart'; /** * Options for the daemon serve lifecycle. @@ -27,9 +28,21 @@ export async function serveRushDaemonAsync(options: IRushDaemonServeOptions): Pr : createProcessShutdownSignal(); let host: RushDaemonHost | undefined; try { - host = await RushDaemonHost.startAsync(options); + host = await RushDaemonHost.startAsync({ + ...options, + getSuccessorLaunchAsync: + options.getSuccessorLaunchAsync ?? + (async (context) => { + if (options.startupOptions && Object.keys(options.startupOptions).length > 0) { + throw new Error('Custom startup options require an explicit successor launcher.'); + } + return await getInstalledWorkspaceSuccessorLaunchAsync(context); + }) + }); await options.onReady?.(host); - await waitForAbortAsync(signalRegistration.signal); + await waitForShutdownAsync(host, signalRegistration.signal); + await host.closeAsync(); + await host.restartCompleted; } finally { signalRegistration.dispose(); await host?.closeAsync(); @@ -55,9 +68,16 @@ function createProcessShutdownSignal(): IShutdownSignalRegistration { }; } -function waitForAbortAsync(signal: AbortSignal): Promise { +function waitForShutdownAsync(host: RushDaemonHost, signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve(); } - return new Promise((resolve: () => void) => signal.addEventListener('abort', () => resolve(), { once: true })); + return new Promise((resolve: () => void) => { + const onAbort: () => void = () => resolve(); + signal.addEventListener('abort', onAbort, { once: true }); + void host.closed.then(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }); + }); } diff --git a/libraries/rush-daemon/src/test/DaemonGraphFixturePaths.test.ts b/libraries/rush-daemon/src/test/DaemonGraphFixturePaths.test.ts new file mode 100644 index 0000000000..bb9a5cbb5c --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonGraphFixturePaths.test.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { removeTestFolderAsync } from './TestProcessExit'; + +it('uses the physical fixture folder even when the temporary root is reached through an alias', async () => { + const root: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fixture-path-')); + const physical: string = path.join(root, 'physical temporary directory'); + const alias: string = path.join(root, 'alias'); + let temporaryRoot: jest.SpyInstance | undefined; + let fixture: DaemonGraphTestFixture | undefined; + try { + fs.mkdirSync(physical); + fs.symlinkSync(physical, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const realOs = jest.requireActual('node:os'); + temporaryRoot = jest.spyOn(realOs, 'tmpdir').mockReturnValue(alias); + fixture = new DaemonGraphTestFixture(); + expect(fixture.folder).toBe(fs.realpathSync.native(fixture.folder)); + expect(path.dirname(fixture.folder)).toBe(fs.realpathSync.native(physical)); + } finally { + temporaryRoot?.mockRestore(); + await fixture?.[Symbol.asyncDispose](); + await removeTestFolderAsync(root); + } +}); diff --git a/libraries/rush-daemon/src/test/DaemonGraphFixtureSetup.test.ts b/libraries/rush-daemon/src/test/DaemonGraphFixtureSetup.test.ts new file mode 100644 index 0000000000..c4d7f83414 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonGraphFixtureSetup.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +import { RushDaemonHost } from '../RushDaemonHost'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import * as testProcessExit from './TestProcessExit'; + +describe('daemon graph fixture initialization ownership', () => { + let cleanup: (() => Promise) | undefined; + afterEach(async () => { + const pending: (() => Promise) | undefined = cleanup; + cleanup = undefined; + await pending?.(); + }); + + it('joins and disposes a late real host without publishing the cancelled fixture', async () => { + const ready = createDeferred(); + const release = createDeferred(); + const controller: AbortController = new AbortController(); + const startAsync = RushDaemonHost.startAsync; + const start = jest.spyOn(RushDaemonHost, 'startAsync').mockImplementationOnce(async (options) => { + const host: RushDaemonHost = await startAsync(options); + ready.resolve(host); + await release.promise; + return host; + }); + let captured: DaemonGraphTestFixture | undefined; + let published: DaemonGraphTestFixture | undefined; + const setup: Promise = DaemonGraphTestFixture.createAsync( + (created) => { + captured = created; + }, + true, + controller.signal + ).then((created) => { + published = created; + }); + const outcome = Promise.allSettled([setup]); + let settled: boolean = false; + const joined = outcome.then((result) => { + settled = true; + return result; + }); + const cleanupAsync = async (): Promise => { + controller.abort(); + release.resolve(); + try { + const [result] = await joined; + if (result.status === 'rejected' && result.reason !== controller.signal.reason) throw result.reason; + } finally { + start.mockRestore(); + } + }; + cleanup = cleanupAsync; + let closed: boolean = false; + try { + const host: RushDaemonHost = await ready.promise; + void host.closed.then(() => { + closed = true; + }); + controller.abort(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(closed).toBe(false); + expect(fs.existsSync(captured!.folder)).toBe(true); + expect(published).toBeUndefined(); + release.resolve(); + expect(await joined).toEqual([{ status: 'rejected', reason: controller.signal.reason }]); + expect(closed).toBe(true); + expect(fs.existsSync(captured!.folder)).toBe(false); + expect(fs.existsSync(host.paths.lockfilePath)).toBe(false); + expect(published).toBeUndefined(); + } finally { + await cleanupAsync(); + } + }); + + it('cleans partial setup and preserves its original failure', async () => { + const failure: Error = new Error('injected partial graph fixture setup failure'); + let captured: DaemonGraphTestFixture | undefined; + await expect( + DaemonGraphTestFixture.createAsync((created) => { + captured = created; + throw failure; + }) + ).rejects.toBe(failure); + expect(fs.existsSync(captured!.folder)).toBe(false); + expect(captured!.host).toBeUndefined(); + }); + + it('reports setup and disposal failures together without losing either error', async () => { + const failure: Error = new Error('injected graph fixture setup failure'); + const cleanupFailure: Error = new Error('injected graph fixture cleanup failure'); + let captured: DaemonGraphTestFixture | undefined; + const remove = jest.spyOn(testProcessExit, 'removeTestFolderAsync').mockRejectedValueOnce(cleanupFailure); + try { + await expect( + DaemonGraphTestFixture.createAsync((created) => { + captured = created; + throw failure; + }) + ).rejects.toMatchObject({ errors: [failure, cleanupFailure] }); + expect(fs.existsSync(captured!.folder)).toBe(true); + } finally { + remove.mockRestore(); + if (captured) await captured[Symbol.asyncDispose](); + } + }); + + it('cancels before startup without starting a host', async () => { + const controller: AbortController = new AbortController(); + const start = jest.spyOn(RushDaemonHost, 'startAsync'); + let captured: DaemonGraphTestFixture | undefined; + try { + await expect( + DaemonGraphTestFixture.createAsync( + (created) => { + captured = created; + controller.abort(); + }, + true, + controller.signal + ) + ).rejects.toBe(controller.signal.reason); + expect(start).not.toHaveBeenCalled(); + expect(fs.existsSync(captured!.folder)).toBe(false); + } finally { + start.mockRestore(); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/DaemonGraphGeneration.test.ts b/libraries/rush-daemon/src/test/DaemonGraphGeneration.test.ts new file mode 100644 index 0000000000..85691cc703 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonGraphGeneration.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IOperationGraph } from '@microsoft/rush-lib'; +import type { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol'; + +import { DaemonGraphTestFixture, responseSnapshot } from './DaemonGraphTestFixture'; +import { assertSuccessfulNativeBuild } from './NativeBuildTestResult'; + +// These cases start real hosts and run Git/build subprocesses rather than mocked unit work. +jest.setTimeout(15_000); + +// Allow the host's 5s close/drain limit plus joins and fixture removal, without extending test budgets. +const GRAPH_FIXTURE_CLEANUP_TIMEOUT_MS: number = 10_000; + +describe('graph-generation fencing over the native daemon wire', () => { + let fixturePromise: Promise; + let pendingWork: Promise | undefined; + beforeEach(async () => { + pendingWork = undefined; + fixturePromise = DaemonGraphTestFixture.createAsync(); + await fixturePromise; + }); + afterEach(async () => { + const work: Promise | undefined = pendingWork; + const [created] = await Promise.allSettled([fixturePromise]); + try { + if (created.status === 'fulfilled') await created.value.host.closeAsync(); + } finally { + // Jest reports the test/setup failure; join its entire continuation before another fixture can start. + await Promise.allSettled([work]); + if (created.status === 'fulfilled') await created.value[Symbol.asyncDispose](); + } + }, GRAPH_FIXTURE_CLEANUP_TIMEOUT_MS); + + function runWithFixtureAsync(work: (fixture: DaemonGraphTestFixture) => Promise): Promise { + pendingWork = fixturePromise.then(work); + return pendingWork; + } + + it('keeps a stable token for unchanged sessions, including cold snapshots', () => + runWithFixtureAsync(async (fixture) => { + const cold = responseSnapshot(await fixture.graphAsync('status')); + expect(cold.workspaceGeneration).toEqual(expect.any(String)); + expect(responseSnapshot(await fixture.graphAsync('show')).workspaceGeneration).toBe( + cold.workspaceGeneration + ); + await fixture.buildSuccessfullyAsync(); + const initialized = responseSnapshot(await fixture.graphAsync('show')); + expect(initialized.workspaceGeneration).not.toBe(cold.workspaceGeneration); + await fixture.buildSuccessfullyAsync(); + expect(responseSnapshot(await fixture.graphAsync('status')).workspaceGeneration).toBe( + initialized.workspaceGeneration + ); + })); + + it.each([undefined, 'another-session-token'])( + 'rejects missing or stale token %s before mutation', + (token) => + runWithFixtureAsync(async (fixture) => { + await fixture.buildSuccessfullyAsync(); + const before = responseSnapshot(await fixture.graphAsync('status')); + const result = await fixture.runAsync(['daemon', 'graph', 'scope-out', '--project', 'a'], { + expectedWorkspaceGeneration: token + }); + expect(result.terminal).toMatchObject({ + kind: 'requestRejected', + payload: { code: 'invalidRequest' } + }); + expect(responseSnapshot(await fixture.graphAsync('status'))).toEqual(before); + expect(fixture.runs()).toEqual(['a', 'b']); + }) + ); + + describe('after an initial native build', () => { + let previousGeneration: string | undefined; + let previousGraph: IOperationGraph | undefined; + let previousPid: number; + let stale: IDaemonRequestEnvelope; + beforeEach(() => + runWithFixtureAsync(async (fixture) => { + // Real Git/build subprocess preparation can exceed the unit-test budget on shared Windows runners. + await fixture.buildSuccessfullyAsync(); + previousGeneration = responseSnapshot(await fixture.graphAsync('show')).workspaceGeneration; + previousGraph = fixture.session.operationGraph; + previousPid = process.pid; + stale = fixture.envelope(['daemon', 'graph', 'scope-out', '--project', 'a']); + }) + ); + + it('rejects an old reference after same-process soft reload without applying it to the replacement graph', () => + runWithFixtureAsync(async (fixture) => { + assertSuccessfulNativeBuild( + await fixture.runAsync(['rebuild', '--to', 'b', '--parallelism', '3']), + fixture.session.operationGraph + ); + const current = responseSnapshot(await fixture.graphAsync('status')); + expect(process.pid).toBe(previousPid); + expect(fixture.session.operationGraph).not.toBe(previousGraph); + expect(current.workspaceGeneration).not.toBe(previousGeneration); + expect((await fixture.runAsync([...stale.argv], stale)).terminal).toMatchObject({ + kind: 'requestRejected', + payload: { code: 'invalidRequest' } + }); + expect(responseSnapshot(await fixture.graphAsync('status'))).toEqual(current); + expect(responseSnapshot(await fixture.graphAsync('scope-out', '--project', 'a'))).toMatchObject({ + workspaceGeneration: current.workspaceGeneration, + operations: [{ enabled: false }, { enabled: false }, { enabled: false }] + }); + })); + }); + + it('does not reuse tokens after host replacement', () => + runWithFixtureAsync(async (fixture) => { + const old = responseSnapshot(await fixture.graphAsync('status')); + await fixture.restartAsync(); + expect(responseSnapshot(await fixture.graphAsync('status')).workspaceGeneration).not.toBe( + old.workspaceGeneration + ); + })); +}); diff --git a/libraries/rush-daemon/src/test/DaemonGraphRequestRouter.test.ts b/libraries/rush-daemon/src/test/DaemonGraphRequestRouter.test.ts new file mode 100644 index 0000000000..aa1ed334e2 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonGraphRequestRouter.test.ts @@ -0,0 +1,377 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { OperationStatus, type IOperationGraph } from '@microsoft/rush-lib'; +import { LockFile } from '@rushstack/node-core-library'; +import type { + IDaemonEventEnvelope, + IDaemonInitializedGraphSnapshot, + IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { DaemonGraphChanges, getDaemonGraphObserver } from '../DaemonGraphObserver'; +import { RequestExclusivityClass } from '../RequestScheduler'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { DaemonWireRequestClient } from '../DaemonWireRequestClient'; +import { DaemonGraphTestFixture, graphSnapshot, responseSnapshot } from './DaemonGraphTestFixture'; +import { createDeferred, type DaemonRequestWireClient } from './DaemonRequestWireTestUtilities'; + +describe('experimental native graph over daemon transport', () => { + let fixture: DaemonGraphTestFixture; + let initialization: Promise; + let setupAbortController: AbortController; + beforeEach(() => { + setupAbortController = new AbortController(); + initialization = DaemonGraphTestFixture.createAsync(undefined, true, setupAbortController.signal).then( + (created) => { + fixture = created; + } + ); + return initialization; + }); + afterEach(async () => { + // A Jest hook timeout does not cancel initialization or its assignment continuation. + setupAbortController.abort(); + const [result] = await Promise.allSettled([initialization]); + if (result.status === 'rejected') { + if (result.reason !== setupAbortController.signal.reason) throw result.reason; + return; + } + await fixture[Symbol.asyncDispose](); + }); + + async function snapshotAsync(...argv: string[]): Promise { + const snapshot = responseSnapshot(await fixture.graphAsync(...argv)); + if (!snapshot.initialized) throw new Error('Expected an initialized graph.'); + return snapshot; + } + + async function watchAsync(): Promise<{ client: DaemonRequestWireClient; request: IDaemonRequestEnvelope }> { + const client: DaemonRequestWireClient = await fixture.connectAsync(); + const request: IDaemonRequestEnvelope = fixture.envelope(['daemon', 'graph', 'watch']); + await client.sendControlAsync({ kind: 'requestStart', payload: request }); + expect(graphSnapshot(await client.readFrameAsync()).initialized).toBe(true); + return { client, request }; + } + + it('reports cold state without creating a graph or running scripts', async () => { + for (const verb of ['show', 'status']) { + expect(responseSnapshot(await fixture.graphAsync(verb))).toMatchObject({ initialized: false }); + expect(responseSnapshot(await fixture.graphAsync(verb))).not.toHaveProperty('operations'); + } + for (const argv of [ + ['watch'], + ['pause'], + ['resume'], + ['scope-in', '--project', 'a'], + ['invalidate', '--project', 'a'] + ]) { + expect((await fixture.graphAsync(...argv)).terminal).toMatchObject({ + kind: 'requestRejected', + payload: { code: 'routingFailed', message: expect.stringContaining('uninitialized') } + }); + } + expect(fixture.session.operationGraph).toBeUndefined(); + expect(fixture.runs()).toEqual([]); + }); + + it('rejects a disabled gate and malformed reserved requests before the production resolver', async () => { + const invalidRequests: Partial[] = [ + { environment: {} }, + { environment: { RUSH_DAEMON_EXPERIMENTAL: '0' } }, + { commandOrigin: 'custom' as const }, + { commandName: 'build' }, + { terminal: { isTTY: false, supportsColor: false, acceptsStdin: true } } + ]; + for (const overrides of invalidRequests) { + expect((await fixture.runAsync(['daemon', 'graph', 'show'], overrides)).terminal.kind).toBe( + 'requestRejected' + ); + } + expect(fixture.session.operationGraph).toBeUndefined(); + expect(fixture.runs()).toEqual([]); + }); + + describe('with a real initialized graph', () => { + beforeEach(async () => { + await fixture.buildSuccessfullyAsync(); + }); + + it('maps exact selectors to native dependency-safe enablement and validates all selectors first', async () => { + const initial = await snapshotAsync('show'); + expect(initial.operations).toEqual([ + { + operationId: 'a (compile)', + projectName: 'a', + phaseName: '_phase:compile', + enabled: true, + status: 'SUCCESS', + dependencyIds: [] + }, + { + operationId: 'b (compile)', + projectName: 'b', + phaseName: '_phase:compile', + enabled: true, + status: 'SUCCESS', + dependencyIds: ['a (compile)'] + }, + { + operationId: 'c (compile)', + projectName: 'c', + phaseName: '_phase:compile', + enabled: false, + status: 'SKIPPED', + dependencyIds: [] + } + ]); + const privateRequest = await fixture.runAsync(['daemon', 'graph', 'show'], { + environment: { RUSH_DAEMON_EXPERIMENTAL: '1', GRAPH_SECRET: 'never-serialize-this-secret' } + }); + expect(JSON.stringify(responseSnapshot(privateRequest))).not.toContain('never-serialize-this-secret'); + const out = await snapshotAsync('scope-out', '--project', 'a'); + expect(out.operations.map((op) => op.enabled)).toEqual([false, false, false]); + const inside = await snapshotAsync('scope-in', '--operation', 'b (compile)'); + expect(inside.operations.map((op) => op.enabled)).toEqual([true, true, false]); + for (const argv of [ + ['scope-out', '--project', 'a', '--project', 'missing'], + ['invalidate', '--operation', 'a (compile)', '--operation', 'missing'], + ['scope-in', '--project', 'a', '--bad', 'b'], + ['scope-in'], + ['show', '--project', 'a'], + ['unknown'], + ['scope-out', '--operation'] + ]) { + expect((await fixture.graphAsync(...argv)).terminal).toMatchObject({ + kind: 'requestRejected', + payload: { code: 'invalidRequest' } + }); + expect((await snapshotAsync('status')).operations).toEqual(inside.operations); + } + const bOut = await snapshotAsync('scope-out', '--operation', 'b (compile)'); + expect(bOut.operations.map((op) => op.enabled)).toEqual([false, false, false]); + expect(fixture.runs()).toEqual(['a', 'b']); + }); + + it('invalidates without executing and preserves manual mode across an explicit native build', async () => { + expect((await snapshotAsync('pause')).pauseNextIteration).toBe(true); + const invalidated = await snapshotAsync('invalidate', '--project', 'a'); + expect(invalidated.operations[0].status).toBe('READY'); + expect(invalidated.hasScheduledIteration).toBe(false); + expect(fixture.runs()).toEqual(['a', 'b']); + fixture.write('a/input.txt', 'two'); + await fixture.buildSuccessfullyAsync(); + expect((await snapshotAsync('status')).pauseNextIteration).toBe(true); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + expect((await snapshotAsync('resume')).pauseNextIteration).toBe(false); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + }); + + it('observes real builds, invalidations and idle without a lease, and cleans cancel/disconnect subscribers', async () => { + const graph: IOperationGraph = fixture.session.operationGraph!; + const observer = getDaemonGraphObserver(graph); + const taps: number = graph.hooks.onGraphStateChanged.taps.length; + const { client, request } = await watchAsync(); + try { + expect(observer.subscriberCount).toBe(1); + fixture.session.invalidations.invalidate('a/input.txt'); + let snapshot = graphSnapshot(await client.readFrameAsync()); + expect(snapshot.invalidations.changedPathCount).toBeGreaterThan(0); + fixture.write('a/input.txt', 'two'); + const build = fixture.buildSuccessfullyAsync(); + let sawExecuting: boolean = false; + while (!sawExecuting) { + snapshot = graphSnapshot(await client.readFrameAsync()); + sawExecuting = snapshot.initialized && snapshot.operations.some((op) => op.status === 'EXECUTING'); + } + expect((await build).terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + do { + snapshot = graphSnapshot(await client.readFrameAsync()); + } while (!snapshot.initialized || snapshot.status !== 'SUCCESS'); + expect(snapshot.operations[0].status).toBe('SUCCESS'); + await client.sendControlAsync({ kind: 'requestCancel', payload: { requestId: request.requestId } }); + expect((await client.readTerminalAsync(request.requestId)).terminal).toMatchObject({ + kind: 'requestResult', + payload: { outcome: 'aborted', aborted: true, exitCode: 130 } + }); + expect(observer.subscriberCount).toBe(0); + } finally { + await client.closeAsync(); + } + for (let i: number = 0; i < 3; i++) { + const next = await watchAsync(); + await next.client.closeAsync(); + // Admission on another connection is a transport barrier after the disconnect. + await fixture.graphAsync('pause'); + expect(observer.subscriberCount).toBe(0); + expect(graph.hooks.onGraphStateChanged.taps).toHaveLength(taps); + } + await fixture.buildSuccessfullyAsync(); + }); + + it('coalesces updates to one pending notification and removes resources immediately on cancellation', async () => { + const graph: IOperationGraph = fixture.session.operationGraph!; + const abort: AbortController = new AbortController(); + const changes = new DaemonGraphChanges(fixture.session, graph, abort.signal); + expect(await changes.nextAsync()).toBe(true); + for (let i: number = 0; i < 10000; i++) fixture.session.invalidations.invalidate(); + expect(await changes.nextAsync()).toBe(true); + let delivered: boolean = false; + const waiting = changes.nextAsync().then((value) => { + delivered = true; + return value; + }); + await Promise.resolve(); + expect(delivered).toBe(false); + abort.abort(); + expect(getDaemonGraphObserver(graph).subscriberCount).toBe(0); + expect(await waiting).toBe(false); + changes[Symbol.dispose](); + }); + + it('bounds a backpressured wire watch and cancels it without blocking another client', async () => { + const graph: IOperationGraph = fixture.session.operationGraph!; + const observer = getDaemonGraphObserver(graph); + const { client, request } = await watchAsync(); + const blocked = createDeferred(); + const release = createDeferred(); + const original = DaemonWireRequestClient.prototype.writeEventAsync; + let blockedWrites: number = 0; + const writer = jest + .spyOn(DaemonWireRequestClient.prototype, 'writeEventAsync') + .mockImplementation(async function ( + this: DaemonWireRequestClient, + event: IDaemonEventEnvelope + ): Promise { + const payload = event.payload as { data?: { requestId?: string } }; + if (payload.data?.requestId === request.requestId) { + blockedWrites++; + blocked.resolve(); + await release.promise; + } + await original.call(this, event); + }); + try { + fixture.session.invalidations.invalidate('a/input.txt'); + await blocked.promise; + for (let i: number = 0; i < 10000; i++) fixture.session.invalidations.invalidate('a/input.txt'); + expect(blockedWrites).toBe(1); + expect(observer.subscriberCount).toBe(1); + expect(responseSnapshot(await fixture.graphAsync('pause'))).toMatchObject({ + pauseNextIteration: true + }); + await client.sendControlAsync({ kind: 'requestCancel', payload: { requestId: request.requestId } }); + for (let i: number = 0; i < 100 && observer.subscriberCount !== 0; i++) await delayAsync(5); + expect(observer.subscriberCount).toBe(0); + expect(blockedWrites).toBe(1); + release.resolve(); + expect((await client.readTerminalAsync(request.requestId)).terminal).toMatchObject({ + kind: 'requestResult', + payload: { outcome: 'aborted', exitCode: 130 } + }); + } finally { + release.resolve(); + writer.mockRestore(); + await client.closeAsync(); + } + }); + + it('queues mutations behind an active build and reports no-wait admission failure', async () => { + const { client, request } = await watchAsync(); + try { + fixture.write('a/input.txt', 'two'); + const build = fixture.buildSuccessfullyAsync(); + for (;;) { + const snapshot = graphSnapshot(await client.readFrameAsync()); + if (snapshot.initialized && snapshot.status === OperationStatus.Executing) break; + } + const rejected = await fixture.runAsync(['daemon', 'graph', 'scope-out', '--project', 'a'], { + admission: { noWait: true } + }); + expect(rejected.terminal).toMatchObject({ + kind: 'requestResult', + payload: { outcome: 'failure', admissionErrorCode: 'no-wait' } + }); + const mutation = fixture.graphAsync('scope-out', '--project', 'a'); + await build; + expect(responseSnapshot(await mutation)).toMatchObject({ + operations: [{ enabled: false }, { enabled: false }, { enabled: false }] + }); + await client.sendControlAsync({ kind: 'requestCancel', payload: { requestId: request.requestId } }); + await client.readTerminalAsync(request.requestId); + } finally { + await client.closeAsync(); + } + }); + + it('resumes a prepared automatic iteration under admission until native idle', async () => { + await fixture.graphAsync('pause'); + const graph: IOperationGraph = fixture.session.operationGraph!; + const scheduler = getWorkspaceRequestScheduler(fixture.session); + const lease = await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive }); + const preparingLease: AsyncDisposable | undefined = + await fixture.session.acquireExecutionLeaseAsync?.(); + try { + fixture.write('a/input.txt', 'two'); + await fixture.session.reconcileInvalidationsAsync(); + graph.invalidateOperations(); + expect(await graph.scheduleIterationAsync({ inputsSnapshot: fixture.session.inputsSnapshot })).toBe( + true + ); + } finally { + await preparingLease?.[Symbol.asyncDispose](); + lease.release(); + } + expect((await snapshotAsync('status')).hasScheduledIteration).toBe(true); + expect(fixture.runs()).toEqual(['a', 'b']); + fixture.write('a/input.txt', 'three'); + const started = createDeferred(); + const release = createDeferred(); + graph.hooks.beforeExecuteIterationAsync.tapPromise('hold-resume-lease', async () => { + started.resolve(); + await release.promise; + }); + const { client, request } = await watchAsync(); + try { + const resumed = fixture.graphAsync('resume'); + await started.promise; + for (;;) { + const snapshot = graphSnapshot(await client.readFrameAsync()); + if (snapshot.initialized && snapshot.status === OperationStatus.Executing) break; + } + await expect( + scheduler.acquireAsync({ + exclusivityClass: RequestExclusivityClass.SharedBuild, + noWait: true + }) + ).rejects.toMatchObject({ code: 'NO_WAIT' }); + const nativeProbe: LockFile | undefined = LockFile.tryAcquire( + fixture.session.rushConfiguration.commonTempFolder, + 'rush' + ); + try { + expect(nativeProbe).toBeUndefined(); + } finally { + nativeProbe?.release(); + } + release.resolve(); + expect(responseSnapshot(await resumed)).toMatchObject({ + pauseNextIteration: false, + hasScheduledIteration: false, + status: 'SUCCESS' + }); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + expect(scheduler.activeRequestCount).toBe(0); + await fixture.buildSuccessfullyAsync(); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + await client.sendControlAsync({ kind: 'requestCancel', payload: { requestId: request.requestId } }); + await client.readTerminalAsync(request.requestId); + } finally { + release.resolve(); + await client.closeAsync(); + } + }); + }); +}); diff --git a/libraries/rush-daemon/src/test/DaemonGraphTestFixture.ts b/libraries/rush-daemon/src/test/DaemonGraphTestFixture.ts new file mode 100644 index 0000000000..21b01d472c --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonGraphTestFixture.ts @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { + DaemonFrameType, + decodeDaemonEventFrame, + RUSHD_GRAPH_SNAPSHOT, + type IDaemonFrame, + type IDaemonGraphSnapshotPayload, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; + +import { ProductionDaemonRequestResolver } from '../ProductionDaemonRequestResolver'; +import type { IDaemonRequestResolver } from '../DaemonRequestDispatcher'; +import { RushDaemonHost } from '../RushDaemonHost'; +import { WorkspaceSession } from '../WorkspaceSession'; +import { getWorkspaceGenerationToken } from '../WorkspaceGeneration'; +import type { GetWorkspaceSuccessorLaunchAsync } from '../WorkspaceProcessRestart'; +import { + createWireEnvelope, + DaemonRequestWireClient, + type ITerminalExchange +} from './DaemonRequestWireTestUtilities'; +import { assertSuccessfulNativeBuild } from './NativeBuildTestResult'; +import { removeTestFolderAsync } from './TestProcessExit'; + +export class DaemonGraphTestFixture implements AsyncDisposable { + public session!: WorkspaceSession; + public host!: RushDaemonHost; + public getSuccessorLaunchAsync: GetWorkspaceSuccessorLaunchAsync | undefined; + public readonly folder: string = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-graph-')) + ); + public readonly environment: Record = { + ...Object.fromEntries( + Object.entries(process.env).filter((pair): pair is [string, string] => pair[1] !== undefined) + ) + }; + private _nextId: number = 0; + private _lifecycle: boolean = true; + + public static async createAsync( + configure?: (fixture: DaemonGraphTestFixture) => void, + lifecycle: boolean = true, + signal?: AbortSignal + ): Promise { + const fixture: DaemonGraphTestFixture = new DaemonGraphTestFixture(); + fixture._lifecycle = lifecycle; + try { + signal?.throwIfAborted(); + fixture.write( + 'rush.json', + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + // Graph-control assertions must not depend on the surrounding Jest worker's RSS. + // Warm-policy fixtures explicitly override this budget to exercise real pressure. + daemon: { warmMemoryBudgetMB: 100_000 }, + projectFolderMinDepth: 1, + projects: ['a', 'b', 'c'].map((name) => ({ packageName: name, projectFolder: name })) + }) + ); + fixture.write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\nruns.txt\n'); + fixture.write('common/temp/last-link.flag', '{}'); + fixture.write('common/config/rush/npm-shrinkwrap.json', '{"lockfileVersion":3,"packages":{}}'); + fixture.write( + 'common/config/rush/command-line.json', + JSON.stringify({ + phases: [{ name: '_phase:compile', dependencies: { upstream: ['_phase:compile'] } }], + commands: [ + { + commandKind: 'phased', + name: 'build', + phases: ['_phase:compile'], + incremental: true, + enableParallelism: true + } + ] + }) + ); + for (const name of ['a', 'b', 'c']) { + fixture.write( + `${name}/package.json`, + JSON.stringify({ + name, + version: '1.0.0', + dependencies: name === 'b' ? { a: '1.0.0' } : {}, + scripts: { '_phase:compile': 'node build.cjs' } + }) + ); + fixture.write(`${name}/input.txt`, 'one'); + fixture.write( + `${name}/build.cjs`, + "const fs=require('node:fs');const name=require('./package.json').name;" + + "fs.appendFileSync('../runs.txt',name+'\\n');console.log('building-'+name);" + + "setTimeout(()=>console.log('finished-'+name),150);" + ); + } + configure?.(fixture); + signal?.throwIfAborted(); + execFileSync('git', ['init', '--quiet'], { cwd: fixture.folder }); + execFileSync('git', ['config', '--local', 'core.autocrlf', 'false'], { cwd: fixture.folder }); + execFileSync('git', ['add', '.'], { cwd: fixture.folder }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Graph Test', + '-c', + 'user.email=graph@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ], + { cwd: fixture.folder } + ); + await fixture._startAsync(); + // Host startup owns its partial resources; join it before disposing a late result. + signal?.throwIfAborted(); + return fixture; + } catch (error) { + try { + await fixture[Symbol.asyncDispose](); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Failed to initialize and dispose the graph fixture.' + ); + } + throw error; + } + } + + private async _startAsync(): Promise { + const resolver: IDaemonRequestResolver = new ProductionDaemonRequestResolver(); + this.host = await RushDaemonHost.startAsync({ + repoRoot: this.folder, + rushVersion: Rush.version, + daemonVersion: 'graph-test', + getSuccessorLaunchAsync: this.getSuccessorLaunchAsync, + requestResolver: this._lifecycle + ? resolver + : { + resolveRequestAsync: (options) => resolver.resolveRequestAsync(options), + [Symbol.asyncDispose]: async () => { + await resolver[Symbol.asyncDispose]?.(); + } + }, + createWorkspaceSessionAsync: async (options) => { + this.session = await WorkspaceSession.createAsync(options); + return this.session; + } + }); + } + + public async restartAsync(): Promise { + await this.host.closeAsync(); + await this._startAsync(); + } + + public write(name: string, text: string): void { + const filename: string = path.join(this.folder, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, text); + } + + public runs(): string[] { + const filename: string = path.join(this.folder, 'runs.txt'); + return fs.existsSync(filename) ? fs.readFileSync(filename, 'utf8').trim().split('\n') : []; + } + + public async connectAsync(): Promise { + const client: DaemonRequestWireClient = await DaemonRequestWireClient.connectAsync( + this.host.paths.socketPath + ); + await client.handshakeAsync(); + return client; + } + + public envelope(argv: string[], overrides: Partial = {}): IDaemonRequestEnvelope { + return createWireEnvelope(`graph-${++this._nextId}`, argv[0], this.folder, { + argv, + commandOrigin: 'built-in', + expectedWorkspaceGeneration: + argv[0] === 'daemon' ? getWorkspaceGenerationToken(this.session) : undefined, + environment: + argv[0] === 'daemon' ? { ...this.environment, RUSH_DAEMON_EXPERIMENTAL: '1' } : this.environment, + terminal: { isTTY: false, supportsColor: false }, + ...overrides + }); + } + + public async runAsync( + argv: string[], + overrides: Partial = {} + ): Promise { + const client: DaemonRequestWireClient = await this.connectAsync(); + const payload: IDaemonRequestEnvelope = this.envelope(argv, overrides); + try { + await client.sendControlAsync({ kind: 'requestStart', payload }); + return await client.readTerminalAsync(payload.requestId); + } finally { + await client.closeAsync(); + } + } + + public graphAsync(...argv: string[]): Promise { + return this.runAsync(['daemon', 'graph', ...argv]); + } + + public buildAsync(): Promise { + return this.runAsync(['build', '--to', 'b', '--parallelism', '3']); + } + + public async buildSuccessfullyAsync(): Promise { + const exchange: ITerminalExchange = await this.buildAsync(); + assertSuccessfulNativeBuild(exchange, this.session.operationGraph); + return exchange; + } + + public async [Symbol.asyncDispose](): Promise { + await this.host?.closeAsync(); + await removeTestFolderAsync(this.folder, true); + } +} + +export function graphSnapshot(frame: IDaemonFrame): IDaemonGraphSnapshotPayload['snapshot'] { + expect(frame.kind).toBe(DaemonFrameType.event); + const event = decodeDaemonEventFrame(frame.payload); + expect(event).toMatchObject({ type: 'extension', payload: { name: RUSHD_GRAPH_SNAPSHOT } }); + const payload = event.payload as { data: IDaemonGraphSnapshotPayload }; + return payload.data.snapshot; +} + +export function responseSnapshot(exchange: ITerminalExchange): IDaemonGraphSnapshotPayload['snapshot'] { + expect(exchange.terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + const events: IDaemonFrame[] = exchange.frames.filter((frame) => frame.kind === DaemonFrameType.event); + expect(events).toHaveLength(1); + expect( + exchange.frames.some( + (frame) => frame.kind === DaemonFrameType.logStdout || frame.kind === DaemonFrameType.logStderr + ) + ).toBe(false); + return graphSnapshot(events[0]); +} diff --git a/libraries/rush-daemon/src/test/DaemonIdleShutdown.test.ts b/libraries/rush-daemon/src/test/DaemonIdleShutdown.test.ts new file mode 100644 index 0000000000..401702dca3 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonIdleShutdown.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { RushDaemonHost } from '../RushDaemonHost'; +import type { IRushDaemonHostOptions } from '../RushDaemonHost'; +import type { GlobalCommandExecutor } from '../GlobalCommandRequestRouter'; +import { serveRushDaemonAsync } from '../serveRushDaemon'; +import { + CallbackDaemonRequestResolver, + createDeferred, + createWireEnvelope, + DaemonRequestWireClient +} from './DaemonRequestWireTestUtilities'; +import { TestWorkspaceSession } from './TestWorkspaceSession'; + +const IDLE_TIMEOUT_SECONDS: number = 10; +const IDLE_TIMEOUT_MS: number = 10000; + +describe('daemon idle shutdown', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-idle-')); + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + fs.rmSync(repoRoot, { force: true, recursive: true }); + }); + + function createOptions(): IRushDaemonHostOptions { + return { + createWorkspaceSessionAsync: () => Promise.resolve(new TestWorkspaceSession(repoRoot)), + daemonVersion: 'idle-test', + idleTimeoutSeconds: IDLE_TIMEOUT_SECONDS, + repoRoot, + rushVersion: '5.178.1' + }; + } + + it('finishes serving and removes transport artifacts after idle shutdown', async () => { + const ready = createDeferred(); + const serving: Promise = serveRushDaemonAsync({ + ...createOptions(), + onReady: (host) => ready.resolve(host), + shutdownSignal: new AbortController().signal + }); + const host: RushDaemonHost = await ready.promise; + try { + await jest.advanceTimersByTimeAsync(IDLE_TIMEOUT_MS); + await serving; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined(); + await expect(host.closed).resolves.toBeUndefined(); + } finally { + await host.closeAsync(); + } + }); + + it('protects pending resolution and execution, then expires an idle connected client', async () => { + const resolving = createDeferred(); + const resolved = createDeferred(); + const executing = createDeferred(); + const executed = createDeferred(); + const host: RushDaemonHost = await RushDaemonHost.startAsync({ + ...createOptions(), + requestResolver: new CallbackDaemonRequestResolver(async () => { + resolving.resolve(); + await resolved.promise; + const executorAsync: GlobalCommandExecutor = async () => { + executing.resolve(); + await executed.promise; + return { exitCode: 0 }; + }; + return { + kind: 'global', + executor: executorAsync + }; + }) + }); + const client: DaemonRequestWireClient = await DaemonRequestWireClient.connectAsync(host.paths.socketPath); + try { + await client.handshakeAsync(); + await client.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope('idle-request', 'custom', repoRoot) + }); + await resolving.promise; + await jest.advanceTimersByTimeAsync(IDLE_TIMEOUT_MS); + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined(); + resolved.resolve(); + await executing.promise; + await jest.advanceTimersByTimeAsync(IDLE_TIMEOUT_MS); + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined(); + executed.resolve(); + expect((await client.readTerminalAsync('idle-request')).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + await jest.advanceTimersByTimeAsync(IDLE_TIMEOUT_MS); + await host.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined(); + } finally { + resolved.resolve(); + executed.resolve(); + await client.closeAsync(); + await host.closeAsync(); + } + }); + + it('releases activity after an unsupported request instead of pinning the daemon forever', async () => { + const host: RushDaemonHost = await RushDaemonHost.startAsync(createOptions()); + const client: DaemonRequestWireClient = await DaemonRequestWireClient.connectAsync(host.paths.socketPath); + try { + await client.handshakeAsync(); + await client.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope('unsupported', 'build', repoRoot) + }); + expect((await client.readTerminalAsync('unsupported')).terminal.kind).toBe('requestRejected'); + await jest.advanceTimersByTimeAsync(IDLE_TIMEOUT_MS); + await host.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined(); + } finally { + await client.closeAsync(); + await host.closeAsync(); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/DaemonIdleTimer.test.ts b/libraries/rush-daemon/src/test/DaemonIdleTimer.test.ts new file mode 100644 index 0000000000..8b67e7e341 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonIdleTimer.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonIdleTimer } from '../DaemonIdleTimer'; + +const IDLE_TIMEOUT_SECONDS: number = 10; +const IDLE_TIMEOUT_MS: number = 10000; + +describe(DaemonIdleTimer.name, () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('does not shut down unless a timeout was configured', () => { + const onIdle: jest.Mock = jest.fn(); + const timer: DaemonIdleTimer = new DaemonIdleTimer(undefined); + timer.start(onIdle); + jest.runAllTimers(); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('starts the idle deadline only after the host is ready', () => { + const onIdle: jest.Mock = jest.fn(); + const timer: DaemonIdleTimer = new DaemonIdleTimer(IDLE_TIMEOUT_SECONDS); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS); + timer.start(onIdle); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS - 1); + expect(onIdle).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('waits for every request and gives the last completion a full idle interval', () => { + const onIdle: jest.Mock = jest.fn(); + const timer: DaemonIdleTimer = new DaemonIdleTimer(IDLE_TIMEOUT_SECONDS); + timer.start(onIdle); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS - 1); + const releaseFirst: () => void = timer.acquire(); + const releaseSecond: () => void = timer.acquire(); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS); + releaseFirst(); + releaseFirst(); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS); + expect(onIdle).not.toHaveBeenCalled(); + releaseSecond(); + jest.advanceTimersByTime(IDLE_TIMEOUT_MS - 1); + expect(onIdle).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('does not rearm when a request finishes during shutdown', () => { + const onIdle: jest.Mock = jest.fn(); + const timer: DaemonIdleTimer = new DaemonIdleTimer(IDLE_TIMEOUT_SECONDS); + timer.start(onIdle); + const release: () => void = timer.acquire(); + timer[Symbol.dispose](); + release(); + jest.runAllTimers(); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it.each([0, -1, NaN, Infinity, 2147484])('rejects invalid timeout %s', (timeout: number) => { + expect(() => new DaemonIdleTimer(timeout)).toThrow(RangeError); + }); +}); diff --git a/libraries/rush-daemon/src/test/DaemonRequestWireGlobal.test.ts b/libraries/rush-daemon/src/test/DaemonRequestWireGlobal.test.ts index 3e4614107a..d5c647f880 100644 --- a/libraries/rush-daemon/src/test/DaemonRequestWireGlobal.test.ts +++ b/libraries/rush-daemon/src/test/DaemonRequestWireGlobal.test.ts @@ -99,6 +99,31 @@ describe('daemon global request wire integration', () => { } }); + it('does not intercept custom commands named daemon when graph features are enabled', async () => { + const repoRoot: string = createRepoRoot(); + const executorAsync: GlobalCommandExecutor = async (context) => { + context.terminal.write('custom daemon'); + return { exitCode: 0 }; + }; + const resolver = new CallbackDaemonRequestResolver(async () => ({ + kind: 'global', executor: executorAsync + })); + const host: RushDaemonHost = await RushDaemonHost.startAsync(createHostOptions(repoRoot, resolver)); + const client: DaemonRequestWireClient = await connectAsync(host); + try { + const exchange: ITerminalExchange = await startAsync(client, createWireEnvelope('custom-daemon', 'daemon', repoRoot, { + argv: ['daemon', 'graph'], + commandOrigin: 'custom', + environment: { RUSH_DAEMON_EXPERIMENTAL: '1' } + })); + expect(exchange.terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + expect(readLogText(exchange)).toBe('custom daemon'); + } finally { + await client.closeAsync(); + await host.closeAsync(); + } + }); + it('isolates global cwd and environment while preserving raw ordered output and exit codes', async () => { const repoRoot: string = createRepoRoot(); const firstCwd: string = fs.mkdtempSync(path.join(repoRoot, 'first-')); diff --git a/libraries/rush-daemon/src/test/DaemonShutdown.test.ts b/libraries/rush-daemon/src/test/DaemonShutdown.test.ts new file mode 100644 index 0000000000..284d26a697 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonShutdown.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { createDaemonHello, DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import { readDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { RushDaemonHost } from '../RushDaemonHost'; +import { DaemonRequestWireClient } from './DaemonRequestWireTestUtilities'; +import { TestWorkspaceSession } from './TestWorkspaceSession'; + +describe('daemon management shutdown', () => { + let repoRoot: string; + let host: RushDaemonHost; + let client: DaemonRequestWireClient; + + beforeEach(async () => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-shutdown-')); + host = await RushDaemonHost.startAsync({ + createWorkspaceSessionAsync: () => Promise.resolve(new TestWorkspaceSession(repoRoot)), + daemonVersion: 'shutdown-test', + repoRoot, + rushVersion: '5.178.1' + }); + client = await DaemonRequestWireClient.connectAsync(host.paths.socketPath); + }); + + afterEach(async () => { + await client.closeAsync(); + await host.closeAsync(); + fs.rmSync(repoRoot, { force: true, recursive: true }); + }); + + it('acknowledges shutdown before closing and releasing the endpoint', async () => { + await client.sendControlAsync(createDaemonHello(DAEMON_PROTOCOL_VERSION)); + expect((await client.readControlAsync()).kind).toBe('helloAck'); + await client.sendControlAsync({ kind: 'shutdown', payload: {} }); + expect(await client.readControlAsync()).toEqual({ kind: 'shutdownAck', payload: {} }); + await client.closed; + await host.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined(); + }); + + it('requires a handshake before a client can stop the daemon', async () => { + await client.sendControlAsync({ kind: 'shutdown', payload: {} }); + expect((await client.readControlAsync()).kind).toBe('error'); + await client.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined(); + }); + + it('does not accept lifecycle controls from an older negotiated minor', async () => { + await client.sendControlAsync(createDaemonHello({ ...DAEMON_PROTOCOL_VERSION, minor: 5 })); + expect((await client.readControlAsync()).kind).toBe('helloAck'); + await client.sendControlAsync({ kind: 'shutdown', payload: {} }); + expect((await client.readControlAsync()).kind).toBe('error'); + await client.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined(); + }); + + it('reports live process identity and memory in status probes', async () => { + await client.handshakeAsync(); + await client.sendControlAsync({ kind: 'ping', payload: {} }); + const pong = await client.readControlAsync(); + expect(pong).toMatchObject({ + kind: 'pong', + payload: { pid: process.pid, residentMemoryBytes: expect.any(Number) } + }); + }); +}); diff --git a/libraries/rush-daemon/src/test/DaemonShutdownOwnership.test.ts b/libraries/rush-daemon/src/test/DaemonShutdownOwnership.test.ts new file mode 100644 index 0000000000..f17c1e8a30 --- /dev/null +++ b/libraries/rush-daemon/src/test/DaemonShutdownOwnership.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { readDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { RushDaemonHost } from '../RushDaemonHost'; +import type { IRushDaemonHostOptions } from '../RushDaemonHost'; +import { + CallbackDaemonRequestResolver, + createDeferred, + createWireEnvelope, + DaemonRequestWireClient +} from './DaemonRequestWireTestUtilities'; +import { TestWorkspaceSession } from './TestWorkspaceSession'; +import { captureTestDaemonListenerAsync } from './TestDaemonListener'; +import { WorkspaceRequestResourceCleanupError } from '../WorkspaceRequestResources'; +import type { GlobalCommandExecutor } from '../GlobalCommandRequestRouter'; + +describe('daemon shutdown ownership', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-ownership-')); + }); + afterEach(() => fs.rmSync(repoRoot, { force: true, recursive: true })); + + function createOptions(onDispose?: () => Promise): IRushDaemonHostOptions { + return { + createWorkspaceSessionAsync: () => Promise.resolve(new TestWorkspaceSession(repoRoot, onDispose)), + daemonVersion: 'ownership-test', + repoRoot, + rushVersion: '5.178.1' + }; + } + + it('prevents a successor from starting before the old workspace has disposed', async () => { + const disposing = createDeferred(); + const disposed = createDeferred(); + const host: RushDaemonHost = await RushDaemonHost.startAsync(createOptions(async () => { + disposing.resolve(); + await disposed.promise; + })); + const closing: Promise = host.closeAsync(); + try { + await disposing.promise; + expect(readDaemonLockfile(host.paths.lockfilePath)?.pid).toBe(process.pid); + await expect(RushDaemonHost.startAsync(createOptions())).rejects.toMatchObject({ + code: 'daemonAlreadyRunning' + }); + } finally { + disposed.resolve(); + await closing; + } + const successor: RushDaemonHost = await RushDaemonHost.startAsync(createOptions()); + await successor.closeAsync(); + }); + + it('fails closed when workspace cleanup cannot complete successfully', async () => { + const { value: host, listener } = await captureTestDaemonListenerAsync(() => + RushDaemonHost.startAsync(createOptions(async () => { + throw new Error('workspace cleanup failed'); + })) + ); + try { + await expect(host.closeAsync()).rejects.toThrow('workspace cleanup failed'); + await host.closed; + expect(readDaemonLockfile(host.paths.lockfilePath)?.pid).toBe(process.pid); + if (process.platform !== 'win32') expect(fs.existsSync(host.paths.socketPath)).toBe(true); + await expect(RushDaemonHost.startAsync(createOptions())).rejects.toMatchObject({ + code: 'daemonAlreadyRunning' + }); + } finally { + await listener.closeAsync(); + } + }); + + it('retains failed global-request ownership even without a native lifecycle or session failure hook', async () => { + const { value: host, listener } = await captureTestDaemonListenerAsync(() => + RushDaemonHost.startAsync({ + ...createOptions(), + requestResolver: new CallbackDaemonRequestResolver(async () => { + const executorAsync: GlobalCommandExecutor = async (context) => { + context.registerDisposable({ + [Symbol.asyncDispose]: async () => { + throw new Error('global resource did not close'); + } + }); + return { exitCode: 0 }; + }; + return { kind: 'global', executor: executorAsync }; + }) + }) + ); + const client = await DaemonRequestWireClient.connectAsync(host.paths.socketPath); + try { + await client.handshakeAsync(); + await client.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope('global-resource-failure', 'custom', repoRoot) + }); + expect((await client.readTerminalAsync('global-resource-failure')).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 1, errorMessage: expect.stringContaining('global resource did not close') } + }); + await expect(host.closeAsync()).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + await expect(host.closeAsync()).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + expect(readDaemonLockfile(host.paths.lockfilePath)?.pid).toBe(process.pid); + await expect(RushDaemonHost.startAsync(createOptions())).rejects.toMatchObject({ + code: 'daemonAlreadyRunning' + }); + } finally { + await client.closeAsync(); + await listener.closeAsync(); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/GlobalCommandRequestRouter.test.ts b/libraries/rush-daemon/src/test/GlobalCommandRequestRouter.test.ts index f6df503022..98e4d8fc67 100644 --- a/libraries/rush-daemon/src/test/GlobalCommandRequestRouter.test.ts +++ b/libraries/rush-daemon/src/test/GlobalCommandRequestRouter.test.ts @@ -14,7 +14,8 @@ import type { } from '@rushstack/rush-daemon-protocol'; import { DaemonRequiresInProcessError } from '../DaemonTerminalPolicy'; -import type { IGlobalCommandExecutionContext } from '../GlobalCommandExecutionContext'; +import type { IGlobalCommandExecutionContext, IGlobalCommandSpawnOptions } from '../GlobalCommandExecutionContext'; +import { GlobalCommandExecutionContext } from '../GlobalCommandExecutionContext'; import type { IResolvedGlobalCommandRequest, IResolveGlobalCommandRequestOptions @@ -30,6 +31,14 @@ import { type IInteractiveRequestSession } from '../InteractiveRequestInputRouter'; import { TestWorkspaceSession, TEST_REPO_ROOT } from './TestWorkspaceSession'; +import * as linuxProcessGroupExit from '../LinuxProcessGroupExit'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import { + assertWorkspaceRequestResourcesHealthy, + WorkspaceRequestResourceCleanupError +} from '../WorkspaceRequestResources'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { RequestExclusivityClass } from '../RequestScheduler'; const TEXT_DECODER: InstanceType = new TextDecoder(); const FIRST_CWD: string = path.join(TEST_REPO_ROOT, 'libraries', 'rush-daemon'); @@ -213,6 +222,49 @@ describe(GlobalCommandRequestRouter.name, () => { expect(process.env.RUSHD_CONTEXT_TEST).toBe(processEnvironmentValue); }); + it('uses validated full child environment/cwd overrides without inheriting omitted request variables', async () => { + const router: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(new TestWorkspaceSession(TEST_REPO_ROOT)); + const client: TestGlobalCommandClient = new TestGlobalCommandClient(); + const result = await router.executeAsync( + router.resolveRequest(createRequestOptions('overrides', FIRST_CWD, { OMITTED: 'secret' }, 80)), + async (context) => { + const child = context.spawnChild(process.execPath, [ + '-e', 'console.log(JSON.stringify({cwd:process.cwd(),value:process.env.VALUE,omitted:process.env.OMITTED}))' + ], { cwd: SECOND_CWD, environment: { VALUE: 'child' } }); + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', () => resolve()); + }); + expect(context.cwd).toBe(getCanonicalPath(FIRST_CWD)); + expect(context.environment.get('OMITTED')).toBe('secret'); + return { exitCode: 0 }; + }, + client + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(client.chunks.map(({ text }) => text).join(''))) + .toEqual({ cwd: getCanonicalPath(SECOND_CWD), value: 'child' }); + }); + + it.each([ + { cwd: path.dirname(TEST_REPO_ROOT) }, + { cwd: '.' }, + { environment: { 'BAD=NAME': 'value' } }, + { environment: { VALUE: 'bad\0value' } }, + { environment: {}, environmentOverlay: {} } + ])('rejects invalid child overrides before spawning: %j', async (options) => { + const router: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(new TestWorkspaceSession(TEST_REPO_ROOT)); + const client: TestGlobalCommandClient = new TestGlobalCommandClient(); + await router.executeAsync( + router.resolveRequest(createRequestOptions('invalid-overrides', FIRST_CWD, {}, 80)), + async (context) => { + expect(() => context.spawnChild(process.execPath, ['-e', ''], options)).toThrow(); + return { exitCode: 0 }; + }, client + ); + expect(client.results[0].exitCode).toBe(0); + }); + it('preserves a global command exit code and delivers it exactly once', async () => { const router: GlobalCommandRequestRouter = new GlobalCommandRequestRouter( new TestWorkspaceSession(TEST_REPO_ROOT) @@ -324,7 +376,7 @@ describe(GlobalCommandRequestRouter.name, () => { const processKillSpy: jest.SpyInstance = jest .spyOn(process, 'kill') .mockImplementation((pid: number, signal?: string | number): true => { - return pid < 0 ? true : originalProcessKill(pid, signal); + return pid < 0 && signal === 'SIGKILL' ? true : originalProcessKill(pid, signal); }); let childPid: number | undefined; try { @@ -351,6 +403,49 @@ describe(GlobalCommandRequestRouter.name, () => { } ); + (process.platform === 'linux' ? it : it.skip)( + 'does not complete a global request before its exited leader group is quiescent', + async () => { + const session: TestWorkspaceSession = new TestWorkspaceSession(TEST_REPO_ROOT); + const router: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(session); + let release!: () => void; + let entered!: () => void; + const draining = new Promise((resolve) => { release = resolve; }); + const inspecting = new Promise((resolve) => { entered = resolve; }); + const waitSpy = jest.spyOn(linuxProcessGroupExit, 'waitForLinuxProcessGroupExitAsync') + .mockImplementation(async () => { entered(); await draining; }); + let childPid: number | undefined; + let completed: boolean = false; + const execution = router.executeAsync( + router.resolveRequest(createRequestOptions('group-quiescence', FIRST_CWD, {}, 80)), + async (context) => { + const child = context.spawnChild(process.execPath, ['-e', '']); + childPid = child.pid; + await new Promise((resolve) => child.once('close', () => resolve())); + return { exitCode: 0 }; + }, + new TestGlobalCommandClient() + ).then((result) => { completed = true; return result; }); + try { + await Promise.race([ + inspecting, + execution.then(() => { throw new Error('The request completed before group inspection.'); }) + ]); + expect(waitSpy).toHaveBeenCalledWith(childPid); + expect(completed).toBe(false); + release(); + expect(await execution).toMatchObject({ exitCode: 0 }); + } finally { + release(); + try { + await execution; + } finally { + waitSpy.mockRestore(); + } + } + } + ); + it('reports child spawn failures during request cleanup', async () => { const session: TestWorkspaceSession = new TestWorkspaceSession(TEST_REPO_ROOT); const router: GlobalCommandRequestRouter = new GlobalCommandRequestRouter(session); @@ -374,6 +469,7 @@ describe(GlobalCommandRequestRouter.name, () => { exitCode: 1, outcome: 'failure' }); + expect(() => assertWorkspaceRequestResourcesHealthy(session)).not.toThrow(); }); it('rejects non-string values in untrusted environment snapshots and overlays', async () => { @@ -733,11 +829,111 @@ describe(GlobalCommandRequestRouter.name, () => { new TestGlobalCommandClient() ) ).resolves.toMatchObject({ - errorMessage: 'synchronous cleanup failure', + errorMessage: expect.stringContaining('synchronous cleanup failure'), exitCode: 1, outcome: 'failure' }); expect(disposalOrder).toEqual(['last', 'throwing', 'first']); + expect(() => assertWorkspaceRequestResourcesHealthy(session)).toThrow(WorkspaceRequestResourceCleanupError); + }); + + it('drains a failed resource result but fences already queued and later workspace admission', async () => { + const session = new TestWorkspaceSession(TEST_REPO_ROOT); + const router = new GlobalCommandRequestRouter(session); + const entered = createDeferred(); + const release = createDeferred(); + const resultDraining = createDeferred(); + const resultRelease = createDeferred(); + const client = new TestGlobalCommandClient(); + client.onResultAsync = async () => { + resultDraining.resolve(); + await resultRelease.promise; + }; + const first = router.executeAsync( + router.resolveRequest(createRequestOptions('failed-owner', FIRST_CWD, {}, 80)), + async (context) => { + context.registerDisposable({ + [Symbol.asyncDispose]: async () => { + entered.resolve(); + await release.promise; + throw new Error('owned resource still active'); + } + }); + return { exitCode: 0 }; + }, + client + ); + await entered.promise; + const next = jest.fn(async () => ({ exitCode: 0 })); + const queued = router.executeAsync( + router.resolveRequest(createRequestOptions('queued-after-owner', FIRST_CWD, {}, 80)), + next, + new TestGlobalCommandClient() + ); + const rejected = expect(queued).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + try { + release.resolve(); + await resultDraining.promise; + expect(client.results).toEqual([]); + expect(next).not.toHaveBeenCalled(); + await expect(getWorkspaceRequestScheduler(session).acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive, + noWait: true + })).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + resultRelease.resolve(); + expect(await first).toMatchObject({ exitCode: 1, outcome: 'failure' }); + await rejected; + expect(next).not.toHaveBeenCalled(); + expect(client.results).toHaveLength(1); + await expect(router.executeAsync( + router.resolveRequest(createRequestOptions('later-after-owner', FIRST_CWD, {}, 80)), + next, + new TestGlobalCommandClient() + )).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + } finally { + release.resolve(); + resultRelease.resolve(); + await first; + await rejected; + } + }); + + it('shares disposal and its sticky failure with concurrent, reentrant and later callers', async () => { + const session = new TestWorkspaceSession(TEST_REPO_ROOT); + const router = new GlobalCommandRequestRouter(session); + const context = new GlobalCommandExecutionContext( + router.resolveRequest(createRequestOptions('shared-cleanup', FIRST_CWD, {}, 80)), + new TestGlobalCommandClient(), + session + ); + const entered = createDeferred(); + const release = createDeferred(); + const dispose = jest.fn(async () => { + entered.resolve(); + await release.promise; + throw new Error('shared resource failure'); + }); + context.registerDisposable({ [Symbol.asyncDispose]: dispose }); + let reentrant: Promise | undefined; + context.abortSignal.addEventListener('abort', () => { + reentrant = context[Symbol.asyncDispose](); + }, { once: true }); + const first = context[Symbol.asyncDispose](); + const second = context[Symbol.asyncDispose](); + const rejected = expect(first).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + try { + await entered.promise; + expect(second).toBe(first); + expect(reentrant).toBe(first); + expect(dispose).toHaveBeenCalledTimes(1); + release.resolve(); + await rejected; + expect(context[Symbol.asyncDispose]()).toBe(first); + await expect(second).rejects.toThrow('shared resource failure'); + } finally { + release.resolve(); + await rejected; + } }); it('surfaces disconnect write failures after deterministic cleanup', async () => { @@ -768,6 +964,12 @@ describe(GlobalCommandRequestRouter.name, () => { ) ).rejects.toThrow('client disconnected'); expect(resourceDisposed).toBe(true); + expect(() => assertWorkspaceRequestResourcesHealthy(session)).not.toThrow(); + await expect(router.executeAsync( + router.resolveRequest(createRequestOptions('after-disconnect', FIRST_CWD, {}, 80)), + async () => ({ exitCode: 0 }), + new TestGlobalCommandClient() + )).resolves.toMatchObject({ exitCode: 0 }); }); it('rejects invalid or cross-workspace resolved requests before execution', async () => { diff --git a/libraries/rush-daemon/src/test/GraphInvalidationNotifications.test.ts b/libraries/rush-daemon/src/test/GraphInvalidationNotifications.test.ts new file mode 100644 index 0000000000..9179d91975 --- /dev/null +++ b/libraries/rush-daemon/src/test/GraphInvalidationNotifications.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WorkspaceInvalidationTracker } from '../WorkspaceInvalidationTracker'; + +describe('graph invalidation notifications', () => { + it('notifies on initialization, paths, unknown changes, acknowledgements and watcher failure', () => { + const tracker: WorkspaceInvalidationTracker = new WorkspaceInvalidationTracker(); + const notify = jest.fn(); + const unsubscribe = tracker.subscribe(notify); + tracker.invalidateForInitialization(); + tracker.invalidate('a/input.txt'); + tracker.invalidate(); + tracker.acknowledgeThrough(tracker.getSnapshot().sequence); + tracker.markWatcherUnhealthy(); + expect(notify).toHaveBeenCalledTimes(5); + expect(tracker.getSnapshot()).toMatchObject({ hasUnknownChanges: true, isWatcherHealthy: false }); + unsubscribe(); + unsubscribe(); + tracker.invalidate('b/input.txt'); + expect(notify).toHaveBeenCalledTimes(5); + }); + + it('isolates a failed observer from invalidation tracking and other subscribers', () => { + const tracker: WorkspaceInvalidationTracker = new WorkspaceInvalidationTracker(); + const warning = jest.spyOn(process, 'emitWarning').mockImplementation(() => undefined); + try { + tracker.subscribe(() => { throw new Error('subscriber failed'); }); + const healthy = jest.fn(); + tracker.subscribe(healthy); + tracker.invalidate('a/input.txt'); + expect(healthy).toHaveBeenCalledTimes(1); + expect(tracker.getSnapshot().changedPaths).toEqual(['a/input.txt']); + expect(warning).toHaveBeenCalledWith(expect.any(Error), { code: 'RUSH_DAEMON_INVALIDATION_CALLBACK_ERROR' }); + } finally { + warning.mockRestore(); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/InteractiveInputEnd.test.ts b/libraries/rush-daemon/src/test/InteractiveInputEnd.test.ts new file mode 100644 index 0000000000..e522e9c133 --- /dev/null +++ b/libraries/rush-daemon/src/test/InteractiveInputEnd.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { encodeDaemonStdinChunk } from '@rushstack/rush-daemon-protocol'; + +import { + InteractiveRequestInputRouter, + type IInteractiveRequestInputSink, + type IInteractiveRequestSession +} from '../InteractiveRequestInputRouter'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; + +const REQUEST_ID: string = 'input-end'; + +describe('request input admission and EOF', () => { + let router: InteractiveRequestInputRouter; + let abort: AbortController; + let onFailure: jest.Mock; + let ready: jest.Mock; + let session: IInteractiveRequestSession; + + beforeEach(() => { + router = new InteractiveRequestInputRouter(); + abort = new AbortController(); + onFailure = jest.fn(); + ready = jest.fn(async () => {}); + session = router.register({ + acceptsStdin: true, + requestId: REQUEST_ID, + onFailure, + client: { + abortSignal: abort.signal, + writeRawModeControlAsync: async () => {}, + writeInputReadyAsync: ready + } + }); + }); + + it('announces an attached sink and delivers EOF after all preceding bytes', async () => { + const writing = createDeferred(); + const written = createDeferred(); + const events: string[] = []; + const sink: IInteractiveRequestInputSink = { + writeInputAsync: async (chunk) => { + events.push(Buffer.from(chunk).toString('hex')); + writing.resolve(); + await written.promise; + }, + endInputAsync: async () => { events.push('EOF'); } + }; + expect(ready).not.toHaveBeenCalled(); + session.attachInputSink(sink); + const input: Promise = router.routeStdinFrameAsync(encodeDaemonStdinChunk({ + requestId: REQUEST_ID, + chunk: Uint8Array.of(0, 3, 255) + })); + await writing.promise; + expect(ready).toHaveBeenCalledWith(REQUEST_ID); + const eof: Promise = router.routeStdinEndAsync(REQUEST_ID); + expect(events).toEqual(['0003ff']); + await expect(router.routeStdinFrameAsync(encodeDaemonStdinChunk({ + requestId: REQUEST_ID, + chunk: Uint8Array.of(1) + }))).rejects.toMatchObject({ code: 'inputEnded' }); + expect(() => router.routeStdinEndAsync(REQUEST_ID)).toThrow('inputEnded'); + written.resolve(); + await Promise.all([input, eof]); + await session.finishAsync(); + expect(events).toEqual(['0003ff', 'EOF']); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it('delivers empty input as EOF rather than a fabricated zero-length data write', async () => { + const writeInputAsync = jest.fn(async () => {}); + const endInputAsync = jest.fn(async () => {}); + session.attachInputSink({ writeInputAsync, endInputAsync }); + await router.routeStdinEndAsync(REQUEST_ID); + await session.finishAsync(); + expect(writeInputAsync).not.toHaveBeenCalled(); + expect(endInputAsync).toHaveBeenCalledTimes(1); + expect(ready).toHaveBeenCalledTimes(1); + }); + + it('cancels EOF waiting for an input sink without blocking request cleanup', async () => { + const eof: Promise = router.routeStdinEndAsync(REQUEST_ID); + const rejected: Promise = expect(eof).rejects.toMatchObject({ code: 'completedRequest' }); + abort.abort(); + await rejected; + await session.finishAsync(); + expect(ready).not.toHaveBeenCalled(); + }); + + it('surfaces a destination that cannot accept EOF', async () => { + session.attachInputSink({ writeInputAsync: async () => {} }); + await expect(router.routeStdinEndAsync(REQUEST_ID)).rejects.toThrow('does not support EOF'); + await expect(session.finishAsync()).rejects.toThrow('does not support EOF'); + expect(onFailure).toHaveBeenCalledTimes(1); + }); + + it('propagates a failed admission write through request cleanup', async () => { + ready.mockRejectedValue(new Error('admission output failed')); + session.attachInputSink({ writeInputAsync: async () => {}, endInputAsync: async () => {} }); + await expect(session.finishAsync()).rejects.toThrow('admission output failed'); + expect(onFailure).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libraries/rush-daemon/src/test/LinuxProcessGroupExit.test.ts b/libraries/rush-daemon/src/test/LinuxProcessGroupExit.test.ts new file mode 100644 index 0000000000..5d4af13f54 --- /dev/null +++ b/libraries/rush-daemon/src/test/LinuxProcessGroupExit.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('node:child_process', () => ({ + ...jest.requireActual('node:child_process'), + execFile: jest.fn() +})); + +import * as childProcess from 'node:child_process'; + +import { waitForLinuxProcessGroupExitAsync } from '../LinuxProcessGroupExit'; + +const GROUP_ID: number = 123_456_789; +const execFileMock = jest.mocked(childProcess.execFile); +const gone = (): never => { + throw Object.assign(new Error('No such process group'), { code: 'ESRCH' }); +}; + +function reportPs( + args: Parameters, + stdout: string, + error: childProcess.ExecFileException | undefined = undefined, + stderr: string = '' +): childProcess.ChildProcess { + const callback = args.at(-1); + if (typeof callback !== 'function') throw new Error('Expected a process-list callback.'); + callback(error ?? null, stdout, stderr); + return new childProcess.ChildProcess(); +} + +describe('Linux subprocess group completion', () => { + beforeEach(() => { + execFileMock.mockReset(); + jest.spyOn(process, 'kill').mockReturnValue(true); + execFileMock.mockImplementation((...args) => reportPs(args, 'Z\n')); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('avoids process inspection when the captured group has disappeared', async () => { + jest.mocked(process.kill).mockImplementation(gone); + await waitForLinuxProcessGroupExitAsync(GROUP_ID); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('waits for live members rather than treating signal delivery as completion', async () => { + let queries: number = 0; + execFileMock.mockImplementation((...args) => reportPs(args, ++queries === 1 ? 'R\nZ\n' : 'Z\nZ\n')); + await waitForLinuxProcessGroupExitAsync(GROUP_ID); + expect(queries).toBe(2); + expect( + jest.mocked(process.kill).mock.calls.every(([pid, signal]) => pid === -GROUP_ID && signal === 0) + ).toBe(true); + }); + + it('does not treat an empty inspection as disappearance while the group remains visible', async () => { + jest.mocked(process.kill).mockReturnValueOnce(true).mockImplementationOnce(gone); + execFileMock.mockImplementation((...args) => + reportPs(args, '', Object.assign(new Error('No matching processes'), { code: 1 })) + ); + await waitForLinuxProcessGroupExitAsync(GROUP_ID); + expect(process.kill).toHaveBeenCalledTimes(2); + }); + + it('surfaces inspection failures instead of completing cleanup', async () => { + const failure = Object.assign(new Error('Cannot execute ps'), { code: 'ENOENT' }); + execFileMock.mockImplementation((...args) => reportPs(args, '', failure)); + await expect(waitForLinuxProcessGroupExitAsync(GROUP_ID)).rejects.toBe(failure); + }); + + it('rejects inspection diagnostics rather than trusting incomplete process state', async () => { + execFileMock.mockImplementation((...args) => reportPs(args, 'Z\n', undefined, 'inspection warning')); + await expect(waitForLinuxProcessGroupExitAsync(GROUP_ID)).rejects.toThrow('inspection warning'); + }); + + it('does not mistake permission denial for a released group', async () => { + const failure = Object.assign(new Error('Permission denied'), { code: 'EPERM' }); + jest.mocked(process.kill).mockImplementation(() => { + throw failure; + }); + await expect(waitForLinuxProcessGroupExitAsync(GROUP_ID)).rejects.toBe(failure); + }); + + it('bounds the wait for members that remain live', async () => { + execFileMock.mockImplementation((...args) => reportPs(args, 'S\n')); + await expect(waitForLinuxProcessGroupExitAsync(GROUP_ID, 25)).rejects.toThrow('did not exit within 25ms'); + }); + + it.each([0, -1, 1.5, process.pid])('rejects invalid or unowned group ID %s', async (pid) => { + await expect(waitForLinuxProcessGroupExitAsync(pid)).rejects.toThrow('owned child process group'); + expect(process.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/libraries/rush-daemon/src/test/NativeBuildTestResult.test.ts b/libraries/rush-daemon/src/test/NativeBuildTestResult.test.ts new file mode 100644 index 0000000000..3f9e53042f --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeBuildTestResult.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonFrameType, encodeDaemonLogChunk } from '@rushstack/rush-daemon-protocol'; + +import type { ITerminalExchange } from './DaemonRequestWireTestUtilities'; +import { assertSuccessfulNativeBuild } from './NativeBuildTestResult'; + +it('includes the actual operation stream and full terminal failure instead of an exit-code-only diff', () => { + const exchange: ITerminalExchange = { + terminal: { + kind: 'requestResult', + payload: { + requestId: 'failure', + exitCode: 1, + outcome: 'failure', + aborted: false, + errorMessage: 'native runner failed' + } + }, + frames: [ + { + kind: DaemonFrameType.logStdout, + payload: encodeDaemonLogChunk({ + operationId: 'a (compile)', + chunk: new TextEncoder().encode('Invoking the real script\n') + }) + }, + { + kind: DaemonFrameType.logStderr, + payload: encodeDaemonLogChunk({ + operationId: 'a (compile)', + chunk: new TextEncoder().encode('Error: write EPIPE\n at fixtureChild (build.cjs:8:9)\n') + }) + } + ] + }; + expect(() => assertSuccessfulNativeBuild(exchange)).toThrow( + /native runner failed[\s\S]*\[stdout a \(compile\)\][\s\S]*Invoking the real script[\s\S]*\[stderr a \(compile\)\][\s\S]*Error: write EPIPE[\s\S]*build\.cjs:8:9/ + ); +}); + +it('does not convert a rejected request into a successful graph', () => { + const exchange: ITerminalExchange = { + frames: [], + terminal: { + kind: 'requestRejected', + payload: { requestId: 'rejected', code: 'routingFailed', message: 'native snapshot unavailable' } + } + }; + expect(() => assertSuccessfulNativeBuild(exchange)).toThrow('native snapshot unavailable'); +}); diff --git a/libraries/rush-daemon/src/test/NativeBuildTestResult.ts b/libraries/rush-daemon/src/test/NativeBuildTestResult.ts new file mode 100644 index 0000000000..808a58fad6 --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeBuildTestResult.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IOperationGraph } from '@microsoft/rush-lib'; +import { + DaemonFrameType, + decodeDaemonEventFrame, + decodeDaemonLogChunk +} from '@rushstack/rush-daemon-protocol'; + +import type { ITerminalExchange } from './DaemonRequestWireTestUtilities'; + +/** Preserve the actual operation failure, not merely a Jest diff showing exitCode 1. */ +export function assertSuccessfulNativeBuild(exchange: ITerminalExchange, graph?: IOperationGraph): void { + if (exchange.terminal.kind === 'requestResult' && exchange.terminal.payload.exitCode === 0) return; + const details: string[] = ['Native build failed:', JSON.stringify(exchange.terminal, undefined, 2)]; + const events: string[] = []; + for (const frame of exchange.frames) { + if (frame.kind === DaemonFrameType.logStdout || frame.kind === DaemonFrameType.logStderr) { + const { operationId, chunk } = decodeDaemonLogChunk(frame.payload); + details.push( + `[${frame.kind === DaemonFrameType.logStdout ? 'stdout' : 'stderr'} ${operationId}]\n${new TextDecoder().decode(chunk)}` + ); + } else if (frame.kind === DaemonFrameType.event) { + events.push(JSON.stringify(decodeDaemonEventFrame(frame.payload), undefined, 2)); + } + } + for (const record of graph?.resultByOperation.values() ?? []) { + if (record.error) { + details.push(`[${record.operation.name}] ${record.error.stack ?? record.error.message}`); + } + } + throw new Error([...details, ...events].join('\n')); +} diff --git a/libraries/rush-daemon/src/test/NativeEngineTestCommands.ts b/libraries/rush-daemon/src/test/NativeEngineTestCommands.ts new file mode 100644 index 0000000000..b334e12e96 --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeEngineTestCommands.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as net from 'node:net'; +import { spawn, type ChildProcess } from 'node:child_process'; + +import { createDeferred, type IDeferred } from './DaemonRequestWireTestUtilities'; + +export interface INativeCommandResult { + readonly exitCode: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +/** Runs an actual native Rush action in its own process, not as the daemon's execution engine. */ +export function runNativeCommandAsync( + repoRoot: string, + argv: ReadonlyArray +): Promise { + const parserPath: string = require.resolve('@microsoft/rush-lib/lib/cli/RushCommandLineParser'); + const script: string = ` + const { RushCommandLineParser } = require(${JSON.stringify(parserPath)}); + new RushCommandLineParser({ cwd: ${JSON.stringify(repoRoot)} }) + .executeAsync(${JSON.stringify(argv)}) + .then((success) => { if (!success) process.exitCode = 1; }) + .catch((error) => { console.error(error); process.exitCode = 1; }); + `; + const child: ChildProcess = spawn(process.execPath, ['-e', script], { + cwd: repoRoot, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout: string = ''; + let stderr: string = ''; + child.stdout!.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr!.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => resolve({ exitCode: exitCode ?? undefined, stdout, stderr })); + }); +} + +export interface INativeScriptGate extends AsyncDisposable { + readonly entered: Promise; + releaseAsync(): Promise; +} + +/** A socket handshake makes active-script tests deterministic without sleeps or polling. */ +export async function createNativeScriptGateAsync( + repoRoot: string, + projectName: string +): Promise { + const entered: IDeferred = createDeferred(); + const sockets: Set = new Set(); + const server: net.Server = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + entered.resolve(); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address: net.AddressInfo | string | null = server.address(); + if (!address || typeof address === 'string') throw new Error('The native script gate did not bind.'); + const gateFile: string = path.join(repoRoot, 'common', 'temp', `gate-${projectName}.json`); + fs.writeFileSync(gateFile, JSON.stringify({ port: address.port })); + let releasePromise: Promise | undefined; + const releaseAsync: () => Promise = () => { + releasePromise ??= new Promise((resolve, reject) => { + fs.rmSync(gateFile, { force: true }); + for (const socket of sockets) socket.end('continue'); + server.close((error) => (error ? reject(error) : resolve())); + }); + return releasePromise; + }; + return { + entered: entered.promise, + releaseAsync, + [Symbol.asyncDispose]: releaseAsync + }; +} diff --git a/libraries/rush-daemon/src/test/NativeIpcFixture.test.ts b/libraries/rush-daemon/src/test/NativeIpcFixture.test.ts new file mode 100644 index 0000000000..85ee596563 --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeIpcFixture.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import childProcess from 'node:child_process'; + +import { WarmSetTestFixture } from './WarmSetTestFixture'; + +describe('explicit Node IPC fixture child', () => { + let fixture: WarmSetTestFixture | undefined; + let spawn: jest.SpyInstance | undefined; + beforeEach(async () => { + spawn = jest.spyOn(childProcess, 'spawn'); + fixture = await WarmSetTestFixture.createAsync({ ipc: true }); + }); + beforeEach(async () => { + await fixture!.fixture.buildSuccessfullyAsync(); + }); + afterEach(async () => { + try { + await fixture?.[Symbol.asyncDispose](); + } finally { + spawn?.mockRestore(); + } + }); + + it('owns the real IPC channel directly, retains native launch context and reuses the process', async () => { + const launches = spawn!.mock.calls.filter(([, args]) => args?.[0] === 'build.cjs'); + expect(launches).toHaveLength(2); + for (const [command, args, options] of launches) { + expect(command).toBe(process.execPath); + expect(args).toEqual(['build.cjs']); + expect(options).toMatchObject({ + shell: false, + env: { INIT_CWD: fixture!.fixture.session.rushConfiguration.commonTempFolder }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }); + } + expect(fixture!.operation('a').runner?.isActive).toBe(true); + expect(fixture!.operation('b').runner?.isActive).toBe(true); + expect(fixture!.warm.getStatus().measuredRunnerMemoryBytes).toBeGreaterThan(0); + fixture!.fixture.write('a/input.txt', 'reused'); + await fixture!.fixture.buildSuccessfullyAsync(); + expect(spawn!.mock.calls.filter(([, args]) => args?.[0] === 'build.cjs')).toHaveLength(2); + expect(fixture!.fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + }); +}); diff --git a/libraries/rush-daemon/src/test/NativeMutationCleanupFailure.test.ts b/libraries/rush-daemon/src/test/NativeMutationCleanupFailure.test.ts new file mode 100644 index 0000000000..d3bdbf5157 --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeMutationCleanupFailure.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { connectOrStartDaemonAsync } from '@rushstack/rush-client-core'; +import { readDaemonLockfile, type DaemonFrameListener } from '@rushstack/rush-daemon-transport'; + +import * as linuxProcessGroupExit from '../LinuxProcessGroupExit'; +import { getInstalledWorkspaceSuccessorLaunchAsync } from '../WorkspaceProcessRestart'; +import { WorkspaceRequestResourceCleanupError } from '../WorkspaceRequestResources'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { captureTestDaemonListenerAsync } from './TestDaemonListener'; +import { removeTestFolderAsync, waitForTestProcessExitAsync } from './TestProcessExit'; + +jest.setTimeout(30_000); + +(process.platform === 'linux' ? describe : describe.skip)('native mutation cleanup ownership', () => { + let fixture: DaemonGraphTestFixture; + let listener: DaemonFrameListener; + let workerPid: number | undefined; + + beforeEach(async () => { + workerPid = undefined; + const captured = await captureTestDaemonListenerAsync(() => + DaemonGraphTestFixture.createAsync((created) => { + created.getSuccessorLaunchAsync = getInstalledWorkspaceSuccessorLaunchAsync; + }) + ); + fixture = captured.value; + listener = captured.listener; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + if (workerPid !== undefined) await waitForTestProcessExitAsync(workerPid); + try { + await fixture.host.closeAsync(); + } catch (error) { + if (!(error instanceof WorkspaceRequestResourceCleanupError)) throw error; + } + // Only inspection is injected; the real worker is joined before this explicit test-only release. + await listener.closeAsync(); + await removeTestFolderAsync(fixture.folder, true); + }); + + it.each([ + ['install', 'inspection'], + ['install', 'timeout'], + ['update', 'inspection'], + ['update', 'timeout'] + ])('retains ownership after %s worker join %s failure, even after the result drains', async (command, kind) => { + const nativeChildProcess: typeof childProcess = jest.requireActual('node:child_process'); + const originalOwner = readDaemonLockfile(fixture.host.paths.lockfilePath); + const originalWait = linuxProcessGroupExit.waitForLinuxProcessGroupExitAsync; + const originalKill = process.kill.bind(process); + const originalExecFile = nativeChildProcess.execFile; + const diagnostics: unknown[] = []; + jest.spyOn(process, 'emitWarning').mockImplementation((error) => { + diagnostics.push(error); + }); + const spawn = jest.spyOn(nativeChildProcess, 'spawn'); + jest.spyOn(linuxProcessGroupExit, 'waitForLinuxProcessGroupExitAsync') + .mockImplementation(async (pid) => { + workerPid = pid; + await originalWait(pid, 25); + }); + jest.spyOn(process, 'kill').mockImplementation((pid, signal) => + workerPid !== undefined && pid === -workerPid && signal === 0 + ? true + : originalKill(pid, signal) + ); + let inspections: number = 0; + jest.spyOn(nativeChildProcess, 'execFile').mockImplementation((...args) => { + if (args[0] !== 'ps' || args[1]?.[0] !== '--sid' || args[1]?.[1] !== String(workerPid)) { + return originalExecFile(...args); + } + inspections++; + const callback = args.at(-1); + if (typeof callback !== 'function') throw new Error('Expected a process inspection callback.'); + callback( + kind === 'inspection' ? Object.assign(new Error('Cannot inspect owned worker'), { code: 'ENOENT' }) : null, + kind === 'inspection' ? '' : 'S\n', + '' + ); + return new childProcess.ChildProcess(); + }); + // --help uses the real native worker/parser without installing dependencies in this failure fixture. + const result = await fixture.runAsync([command, '--help']); + const detail: string = kind === 'inspection' ? 'Cannot inspect owned worker' : 'did not exit within 25ms'; + expect(result.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 1, outcome: 'failure', aborted: false, errorMessage: expect.stringContaining(detail) } + }); + expect(result.terminal).not.toHaveProperty('payload.retryAfterRestart'); + expect(inspections).toBeGreaterThan(0); + expect(workerPid).toBeDefined(); + expect(spawn.mock.calls.filter(([, args]) => + Array.isArray(args) && args.some((arg) => typeof arg === 'string' && arg.endsWith('/NativeMutationWorker.js')) + )).toHaveLength(1); + await expect(fixture.host.restartCompleted).rejects.toBeInstanceOf(WorkspaceRequestResourceCleanupError); + await expect(fixture.host.closeAsync()).rejects.toThrow(detail); + expect(diagnostics.some((error) => error instanceof WorkspaceRequestResourceCleanupError)).toBe(true); + expect(readDaemonLockfile(fixture.host.paths.lockfilePath)).toEqual(originalOwner); + expect(fs.existsSync(fixture.host.paths.socketPath)).toBe(true); + expect(() => fixture.session.assertActive()).toThrow(WorkspaceRequestResourceCleanupError); + expect(fixture.host.workspaceGeneration).toBe(1); + expect(fixture.runs()).toEqual([]); + + const marker: string = path.join(fixture.folder, 'unexpected-starter'); + await expect(connectOrStartDaemonAsync({ + paths: fixture.host.paths, + startupTimeoutMs: 100, + timeoutMs: 25, + startCommand: { + command: process.execPath, + args: ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started')`], + cwd: fixture.folder, + environment: fixture.environment + } + })).rejects.toThrow(/still exists|deadline/); + expect(fs.existsSync(marker)).toBe(false); + expect(readDaemonLockfile(fixture.host.paths.lockfilePath)).toEqual(originalOwner); + expect(spawn.mock.calls.some(([, args]) => + Array.isArray(args) && args.some((arg) => typeof arg === 'string' && arg.endsWith('/runDaemonStartup.js')) + )).toBe(false); + }); +}); diff --git a/libraries/rush-daemon/src/test/NativeWorkspacePaths.test.ts b/libraries/rush-daemon/src/test/NativeWorkspacePaths.test.ts new file mode 100644 index 0000000000..f1fd113c8b --- /dev/null +++ b/libraries/rush-daemon/src/test/NativeWorkspacePaths.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush, type IOperationGraph } from '@microsoft/rush-lib'; +import { FileSystem } from '@rushstack/node-core-library'; + +import { WorkspaceSession } from '../WorkspaceSession'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { createNativeEngineAsync } from './WarmGenerationTestUtilities'; + +jest.setTimeout(30_000); + +describe('native daemon workspace paths', () => { + it('loads canonical session configuration for real Git snapshots through a workspace alias', async () => { + const fixture: DaemonGraphTestFixture = await DaemonGraphTestFixture.createAsync(); + const aliasFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-session-alias-')); + try { + const root: string = await fs.promises.realpath(fixture.folder); + const alias: string = path.join(aliasFolder, 'workspace'); + await FileSystem.createSymbolicLinkJunctionAsync({ linkTargetPath: root, newLinkPath: alias }); + const session: WorkspaceSession = await WorkspaceSession.createAsync({ + repoRoot: alias, rushVersion: Rush.version + }); + try { + expect(session.metadata.repoRoot).toBe(root); + expect(session.rushConfiguration.rushJsonFolder).toBe(root); + const engine = await createNativeEngineAsync(session.rushConfiguration); + try { + expect(engine.inputsSnapshot).toBeDefined(); + expect(fixture.runs()).toEqual([]); + } finally { + await engine[Symbol.asyncDispose](); + } + } finally { + await session[Symbol.asyncDispose](); + } + } finally { + await fixture[Symbol.asyncDispose](); + fs.rmSync(aliasFolder, { recursive: true, force: true }); + } + }); + + it.each(['workspace', 'project'])( + 'selects a project through a %s alias and reuses its real graph through the canonical path', + async (kind) => { + const fixture: DaemonGraphTestFixture = await DaemonGraphTestFixture.createAsync((created) => { + created.write('a/nested/input.txt', 'nested'); + }); + const aliasFolder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-native-alias-')); + try { + const root: string = fs.realpathSync.native(fixture.folder); + const alias: string = path.join(aliasFolder, 'alias'); + await FileSystem.createSymbolicLinkJunctionAsync({ + linkTargetPath: kind === 'workspace' ? root : path.join(root, 'a'), + newLinkPath: alias + }); + const cwd: string = + kind === 'workspace' ? path.join(alias, 'a', 'nested') : path.join(alias, 'nested'); + expect(cwd.startsWith(root)).toBe(false); + expect(fixture.session.rushConfiguration.rushJsonFolder).toBe(root); + + expect( + (await fixture.runAsync(['build', '--only', '.', '--parallelism', '3'], { cwd })).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { + exitCode: 0, + scheduled: true, + operationResults: [{ operationId: 'a (compile)', status: 'SUCCESS' }] + } + }); + const graph: IOperationGraph = fixture.session.operationGraph!; + expect(graph.operations.size).toBe(3); + expect(fixture.runs()).toEqual(['a']); + + for (const selectedCwd of [path.join(root, 'a', 'nested'), cwd]) { + expect( + ( + await fixture.runAsync(['build', '--only', 'path:..', '--parallelism', '3'], { + cwd: selectedCwd + }) + ).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false } + }); + expect(fixture.session.operationGraph).toBe(graph); + expect(fixture.runs()).toEqual(['a']); + } + } finally { + try { + await fixture[Symbol.asyncDispose](); + } finally { + fs.rmSync(aliasFolder, { recursive: true, force: true }); + } + } + } + ); + + it('rejects a cwd escaping through a workspace symlink before preparing or executing a graph', async () => { + const fixture: DaemonGraphTestFixture = await DaemonGraphTestFixture.createAsync(); + const outside: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-native-outside-')); + try { + const escape: string = path.join(fixture.folder, 'common/temp/escape'); + await FileSystem.createSymbolicLinkJunctionAsync({ + linkTargetPath: outside, + newLinkPath: escape + }); + expect( + (await fixture.runAsync(['build', '--only', 'a', '--parallelism', '3'], { cwd: escape })).terminal + ).toMatchObject({ + kind: 'requestRejected', + payload: { + code: 'unsupported', + message: 'The command working directory must be inside the daemon workspace.' + } + }); + expect(fixture.session.operationGraph).toBeUndefined(); + expect(fixture.runs()).toEqual([]); + } finally { + try { + await fixture[Symbol.asyncDispose](); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } + } + }); +}); diff --git a/libraries/rush-daemon/src/test/PhasedCommandEnvironment.test.ts b/libraries/rush-daemon/src/test/PhasedCommandEnvironment.test.ts new file mode 100644 index 0000000000..e17a594fcb --- /dev/null +++ b/libraries/rush-daemon/src/test/PhasedCommandEnvironment.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { createNativeEngineAsync } from './WarmGenerationTestUtilities'; + +describe('native engine environment preparation', () => { + it.each(['PATH', 'Path', 'pAtH'])('preserves inherited %s while prepending repository tools', async (name) => { + const fixture = await DaemonGraphTestFixture.createAsync(); + try { + const engine = await createNativeEngineAsync(fixture.session.rushConfiguration); + try { + const transform = engine.operationGraph.hooks.createEnvironmentForOperation.taps.find( + (tap) => tap.name === 'PhasedCommandEngine' + ); + if (!transform) throw new Error('Expected the installed native engine environment transform.'); + const inherited: string = ['native-node-bin', 'native-tools'].join(path.delimiter); + const input: NodeJS.ProcessEnv = Object.freeze({ [name]: inherited, CUSTOM_VALUE: 'unchanged' }); + const output: NodeJS.ProcessEnv = transform.fn(input); + const prefix: string = path.join(fixture.session.rushConfiguration.commonTempFolder, 'node_modules', '.bin'); + expect(output.PATH).toBe( + `${prefix}${path.delimiter}${process.platform === 'win32' || name === 'PATH' ? inherited : ''}` + ); + expect(output.CUSTOM_VALUE).toBe('unchanged'); + expect(input).toEqual({ [name]: inherited, CUSTOM_VALUE: 'unchanged' }); + if (process.platform === 'win32') { + expect(Object.keys(output).filter((key) => key.toUpperCase() === 'PATH')).toEqual(['PATH']); + } else if (name !== 'PATH') { + expect(output[name]).toBe(inherited); + } + expect(fixture.runs()).toEqual([]); + } finally { + await engine[Symbol.asyncDispose](); + } + } finally { + await fixture[Symbol.asyncDispose](); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/ProductionDaemonRequestResolver.test.ts b/libraries/rush-daemon/src/test/ProductionDaemonRequestResolver.test.ts new file mode 100644 index 0000000000..25a97f6165 --- /dev/null +++ b/libraries/rush-daemon/src/test/ProductionDaemonRequestResolver.test.ts @@ -0,0 +1,1435 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { + PhasedCommandEngine, + Rush, + RushProjectConfiguration, + RushUserConfiguration, + type IOperationGraph, + type Operation, + type OperationEnabledState, + type RushConfigurationProject +} from '@microsoft/rush-lib'; +import { + DaemonFrameType, + decodeDaemonEventFrame, + decodeDaemonLogChunk, + type IDaemonPhasedRequestResult, + type IDaemonRequestEnvelope +} from '@rushstack/rush-daemon-protocol'; +import { NoOpTerminalProvider, Terminal, TerminalProviderSeverity } from '@rushstack/terminal'; +import { StandardScriptUpdater } from '@microsoft/rush-lib/lib/logic/StandardScriptUpdater'; +import { RushConfiguration as InternalRushConfiguration } from '@microsoft/rush-lib/lib/api/RushConfiguration'; + +import { ProductionDaemonRequestResolver } from '../ProductionDaemonRequestResolver'; +import { RushDaemonHost } from '../RushDaemonHost'; +import { WorkspaceSession } from '../WorkspaceSession'; +import { stopSuccessorAsync } from './WorkspaceLifecycleTestProcess'; +import { removeTestFolderAsync } from './TestProcessExit'; +import { readDaemonLockfile } from '@rushstack/rush-daemon-transport'; +import { EngineTerminalProvider } from '../EngineTerminalProvider'; +import { getInstalledWorkspaceSuccessorLaunchAsync } from '../WorkspaceProcessRestart'; +import type { + GetWorkspaceSuccessorLaunchAsync, + IWorkspaceProcessRestartResult +} from '../WorkspaceProcessRestart'; +import type { IResolveDaemonRequestOptions, ResolvedDaemonRequest } from '../DaemonRequestDispatcher'; +import type { IDaemonRequestResolver } from '../DaemonRequestDispatcher'; +import { + isRushxInvocation, + wrapWorkspaceResolverLifecycle, + type IWorkspaceResolverLifecycle +} from '../WorkspaceResolverLifecycle'; +import { PhasedRequestRouter } from '../PhasedRequestRouter'; +import { TestPhasedRequestClient } from './PhasedRequestRouterTestUtilities'; +import { + createNativeScriptGateAsync, + runNativeCommandAsync, + type INativeCommandResult, + type INativeScriptGate +} from './NativeEngineTestCommands'; +import { + DaemonRequestWireClient, + createDeferred, + createWireEnvelope, + type IDeferred, + type ITerminalExchange +} from './DaemonRequestWireTestUtilities'; + +const RUSH_VERSION: string = Rush.version; +jest.setTimeout(30_000); + +interface IFixture extends AsyncDisposable { + readonly repoRoot: string; + readonly host: RushDaemonHost; + readonly session: WorkspaceSession; + readonly client: DaemonRequestWireClient; +} + +interface IFixtureOptions { + readonly getSuccessorLaunchAsync?: GetWorkspaceSuccessorLaunchAsync; + readonly onSessionCreated?: (session: WorkspaceSession) => void; + readonly resolver?: IDaemonRequestResolver; +} + +class DecoratedTestResolver implements IDaemonRequestResolver { + public readonly workspaceLifecycle: IWorkspaceResolverLifecycle | undefined; + private readonly _inner: IDaemonRequestResolver; + private readonly _events: string[]; + private readonly _id: number; + private _disposed: boolean = false; + + public constructor(inner: IDaemonRequestResolver, events: string[]) { + this._inner = inner; + this._events = events; + this._id = events.filter((event) => event.startsWith('created')).length; + events.push(`created:${this._id}`); + this.workspaceLifecycle = wrapWorkspaceResolverLifecycle( + inner, + (replacement) => new DecoratedTestResolver(replacement, events) + ); + } + + public async resolveRequestAsync(options: IResolveDaemonRequestOptions): Promise { + if (this._disposed) throw new Error('A disposed resolver was invoked.'); + if (isRushxInvocation(options.envelope)) { + this._events.push(`isolated:${this._id}`); + throw new Error('Explicit isolated invocation reached the decorated resolver.'); + } + return await this._inner.resolveRequestAsync(options); + } + + public async [Symbol.asyncDispose](): Promise { + if (this._disposed) throw new Error('A resolver was disposed twice.'); + this._disposed = true; + this._events.push(`disposed:${this._id}`); + await this._inner[Symbol.asyncDispose]?.(); + } +} + +async function createFixtureAsync( + cache: boolean = false, + configurationKind: 'direct' | 'rig' | 'inherited' = 'direct', + options: IFixtureOptions = {} +): Promise { + const repoRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-native-engine-')); + const cacheNamespace: string = path.basename(repoRoot); + const userConfiguration: RushUserConfiguration = await RushUserConfiguration.initializeAsync(); + const cacheFolder: string = path.join( + userConfiguration.buildCacheFolder ?? path.join(repoRoot, 'common/temp/build-cache'), + cacheNamespace + ); + const write: (name: string, text: string) => void = (name, text) => { + const filename: string = path.join(repoRoot, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, text); + }; + write( + 'rush.json', + JSON.stringify({ + rushVersion: RUSH_VERSION, + npmVersion: '10.0.0', + // Retention assertions must not depend on the surrounding Jest worker's accumulated RSS. + daemon: { warmMemoryBudgetMB: 100_000 }, + projectFolderMinDepth: 2, + projectFolderMaxDepth: 2, + projects: ['a', 'b', 'c'].map((name) => ({ + packageName: name, + projectFolder: `projects/${name}` + })) + }) + ); + write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\n**/node_modules/\nruns.txt\n'); + write('common/temp/last-link.flag', '{}'); + write('common/config/rush/npm-shrinkwrap.json', '{"lockfileVersion":3,"packages":{}}'); + write( + 'common/config/rush/command-line.json', + JSON.stringify({ + phases: [{ name: '_phase:compile', dependencies: { upstream: ['_phase:compile'] } }], + commands: [ + { + commandKind: 'phased', + name: 'build', + phases: ['_phase:compile'], + incremental: true, + enableParallelism: true + } + ], + parameters: [ + { + parameterKind: 'flag', + longName: '--production', + description: 'Production build', + associatedCommands: ['build'], + associatedPhases: ['_phase:compile'] + } + ] + }) + ); + if (cache) { + write( + 'common/config/rush/build-cache.json', + JSON.stringify({ + buildCacheEnabled: true, + cacheProvider: 'local-only', + cacheEntryNamePattern: `${cacheNamespace}/[hash]` + }) + ); + } + for (const name of ['a', 'b', 'c']) { + write( + `projects/${name}/package.json`, + JSON.stringify({ + name, + version: '1.0.0', + scripts: { '_phase:compile': 'node build.cjs' }, + dependencies: name === 'b' ? { a: '1.0.0' } : {} + }) + ); + write( + `projects/${name}/config/rush-project.json`, + JSON.stringify({ + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }) + ); + write(`projects/${name}/input.txt`, 'one'); + write( + `projects/${name}/build.cjs`, + ` +const fs = require('node:fs'); +const path = require('node:path'); +const name = require('./package.json').name; +const input = fs.readFileSync('input.txt', 'utf8'); +(async () => { +const gateFile = path.resolve('../../common/temp/gate-' + name + '.json'); +if (fs.existsSync(gateFile)) { + const { port } = JSON.parse(fs.readFileSync(gateFile, 'utf8')); + await new Promise((resolve, reject) => { + const socket = require('node:net').connect(port, '127.0.0.1'); + socket.once('error', reject); + socket.once('data', () => { socket.end(); resolve(); }); + }); +} +fs.appendFileSync('../../runs.txt', name + ':' + input + ':' + process.argv.slice(2).join(' ') + '\\n'); +fs.mkdirSync('lib', { recursive: true }); +fs.writeFileSync('lib/output.txt', input); +console.log('built-' + name + '-' + input); +if (input === 'warning') console.error('warning-' + name); +if (input === 'failure') process.exitCode = 7; +})().catch((error) => { console.error(error); process.exitCode = 1; }); +` + ); + } + if (configurationKind === 'rig') { + fs.rmSync(path.join(repoRoot, 'projects/a/config/rush-project.json')); + write('projects/a/config/rig.json', '{"rigPackageName":"fixture-rig"}'); + write('projects/a/node_modules/fixture-rig/package.json', '{"name":"fixture-rig","version":"1.0.0"}'); + write( + 'projects/a/node_modules/fixture-rig/profiles/default/config/rush-project.json', + JSON.stringify({ + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }) + ); + } else if (configurationKind === 'inherited') { + write( + 'common/temp/inherited-rush-project.json', + JSON.stringify({ + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }) + ); + write( + 'projects/a/config/rush-project.json', + JSON.stringify({ + extends: '../../../common/temp/inherited-rush-project.json', + incrementalBuildIgnoredGlobs: ['ignored.txt'] + }) + ); + } + execFileSync('git', ['init', '--quiet'], { cwd: repoRoot }); + execFileSync('git', ['config', '--local', 'core.autocrlf', 'false'], { cwd: repoRoot }); + execFileSync('git', ['add', '.'], { cwd: repoRoot }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Engine Test', + '-c', + 'user.email=engine@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ], + { cwd: repoRoot } + ); + let session: WorkspaceSession | undefined; + let host: RushDaemonHost | undefined; + try { + host = await RushDaemonHost.startAsync({ + repoRoot, + rushVersion: RUSH_VERSION, + daemonVersion: 'native-engine-test', + requestResolver: options.resolver ?? new ProductionDaemonRequestResolver(), + getSuccessorLaunchAsync: options.getSuccessorLaunchAsync, + createWorkspaceSessionAsync: async (sessionOptions) => { + session = await WorkspaceSession.createAsync(sessionOptions); + options.onSessionCreated?.(session); + return session; + } + }); + const client: DaemonRequestWireClient = await DaemonRequestWireClient.connectAsync(host.paths.socketPath); + await client.handshakeAsync(); + const runningHost: RushDaemonHost = host; + return { + repoRoot, + host, + get session(): WorkspaceSession { + return session!; + }, + client, + [Symbol.asyncDispose]: async () => { + await client.closeAsync().finally(() => runningHost.closeAsync()); + if (cache) await removeTestFolderAsync(cacheFolder, true); + await removeTestFolderAsync(repoRoot, true); + } + }; + } catch (error) { + await host?.closeAsync(); + if (cache) await removeTestFolderAsync(cacheFolder, true); + await removeTestFolderAsync(repoRoot, true); + throw error; + } +} + +async function runAsync( + fixture: IFixture, + requestId: string, + argv: string[], + overrides: Partial = {} +): Promise { + const environment: Record = {}; + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) environment[name] = value; + } + await fixture.client.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope(requestId, argv[0], fixture.repoRoot, { + argv, + environment, + commandOrigin: 'built-in', + ...overrides + }) + }); + return await fixture.client.readTerminalAsync(requestId); +} + +function runs(fixture: IFixture): string[] { + const filename: string = path.join(fixture.repoRoot, 'runs.txt'); + return fs.existsSync(filename) ? fs.readFileSync(filename, 'utf8').trim().split('\n') : []; +} + +function logText(exchange: ITerminalExchange): string { + return exchange.frames + .filter((frame) => frame.kind === DaemonFrameType.logStdout || frame.kind === DaemonFrameType.logStderr) + .map((frame) => Buffer.from(decodeDaemonLogChunk(frame.payload).chunk).toString()) + .join(''); +} + +function requestEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ); +} + +describe('native production daemon engine', () => { + it('preserves resolver decoration and isolated invocation routing across native generation reloads', async () => { + const events: string[] = []; + const fixture: IFixture = await createFixtureAsync(false, 'direct', { + resolver: new DecoratedTestResolver(new ProductionDaemonRequestResolver(), events) + }); + try { + expect((await runAsync(fixture, 'initial-decorated', ['build', '--only', 'a'])).terminal).toMatchObject( + { + kind: 'requestResult', + payload: { exitCode: 0 } + } + ); + const graph: IOperationGraph | undefined = fixture.session.operationGraph; + const filename: string = path.join(fixture.repoRoot, 'projects/a/package.json'); + const json: { scripts: Record } = JSON.parse(fs.readFileSync(filename, 'utf8')); + json.scripts['_phase:compile'] = 'node build.cjs --decorated-reload'; + fs.writeFileSync(filename, JSON.stringify(json)); + expect( + (await runAsync(fixture, 'reloaded-decorated', ['build', '--only', 'a'])).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session.operationGraph).not.toBe(graph); + const isolated: { invocationKind: 'rushx'; commandOrigin: 'built-in' } = { + invocationKind: 'rushx', + commandOrigin: 'built-in' + }; + expect( + (await runAsync(fixture, 'isolated-decorated', ['daemon', 'graph', 'show'], isolated)).terminal + ).toMatchObject({ + kind: 'requestRejected', + payload: { message: 'Explicit isolated invocation reached the decorated resolver.' } + }); + expect(events).toEqual([ + 'created:0', + 'created:1', + 'disposed:0', + 'created:2', + 'disposed:1', + 'isolated:2' + ]); + expect(runs(fixture)).toEqual(['a:one:', 'a:one:--decorated-reload']); + } finally { + await fixture[Symbol.asyncDispose](); + } + expect(events.at(-1)).toBe('disposed:2'); + }); + + it('keeps tier0 session and graph identity for unchanged content, including metadata touches', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const session: WorkspaceSession = fixture.session; + const graph: IOperationGraph | undefined = session.operationGraph; + const filename: string = path.join(fixture.repoRoot, 'projects/a/config/rush-project.json'); + fs.writeFileSync(filename, fs.readFileSync(filename)); + expect((await runAsync(fixture, 'unchanged', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false } + }); + expect(fixture.session).toBe(session); + expect(fixture.session.operationGraph).toBe(graph); + expect(runs(fixture)).toEqual(['a:one:']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('keeps the selected native SDK handoff instead of restarting for a foreign client engine path', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + await runAsync(fixture, 'initial-sdk', ['build', '--only', 'a']); + const graph: IOperationGraph | undefined = fixture.session.operationGraph; + const environment: Record = { + ...requestEnvironment(), + _RUSH_LIB_PATH: path.join(fixture.repoRoot, 'foreign-client-engine.js') + }; + expect((await runAsync(fixture, 'foreign-sdk', ['build', '--only', 'a'], { environment })).terminal) + .toMatchObject({ kind: 'requestResult', payload: { exitCode: 0, scheduled: false } }); + expect(fixture.session.operationGraph).toBe(graph); + expect(environment._RUSH_LIB_PATH).toBe(path.join(fixture.repoRoot, 'foreign-client-engine.js')); + expect(runs(fixture)).toEqual(['a:one:']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('replaces configuration and command shape in-process without using a disposed generation', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const first: WorkspaceSession = fixture.session; + const firstGraph: IOperationGraph = first.operationGraph!; + const firstGeneration: number = fixture.host.workspaceGeneration; + const packageFile: string = path.join(fixture.repoRoot, 'projects/a/package.json'); + const packageJson: { scripts: Record } = JSON.parse( + fs.readFileSync(packageFile, 'utf8') + ); + packageJson.scripts['_phase:compile'] = 'node build.cjs --new-definition'; + fs.writeFileSync(packageFile, JSON.stringify(packageJson)); + expect((await runAsync(fixture, 'configuration', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session).not.toBe(first); + expect(fixture.session.operationGraph).not.toBe(firstGraph); + expect(firstGraph.abortController.signal.aborted).toBe(true); + await expect(first.acquireExecutionLeaseAsync()).rejects.toThrow('disposed'); + expect(fixture.host.workspaceGeneration).toBeGreaterThan(firstGeneration); + expect(readDaemonLockfile(fixture.host.paths.lockfilePath)?.pid).toBe(process.pid); + const second: WorkspaceSession = fixture.session; + expect((await runAsync(fixture, 'shape', ['rebuild', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session).not.toBe(second); + expect(runs(fixture)).toEqual(['a:one:', 'a:one:--new-definition', 'a:one:--new-definition']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('never executes an old resolved graph after a racing configuration reload', async () => { + const fixture: IFixture = await createFixtureAsync(); + const entered: IDeferred = createDeferred(); + const release: IDeferred = createDeferred(); + let second: DaemonRequestWireClient | undefined; + let oldRequest: Promise | undefined; + let newRequest: Promise | undefined; + let delayed: boolean = false; + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const oldSession: WorkspaceSession = fixture.session; + const resolveAsync: ProductionDaemonRequestResolver['resolveRequestAsync'] = + ProductionDaemonRequestResolver.prototype.resolveRequestAsync; + jest + .spyOn(ProductionDaemonRequestResolver.prototype, 'resolveRequestAsync') + .mockImplementation(async function ( + this: ProductionDaemonRequestResolver, + options: IResolveDaemonRequestOptions + ) { + const resolved: ResolvedDaemonRequest = await resolveAsync.call(this, options); + if (options.envelope.requestId === 'old' && !delayed) { + delayed = true; + entered.resolve(); + await release.promise; + } + return resolved; + }); + oldRequest = runAsync(fixture, 'old', ['build', '--only', 'a']); + await entered.promise; + const packageFile: string = path.join(fixture.repoRoot, 'projects/a/package.json'); + const packageJson: { scripts: Record } = JSON.parse( + fs.readFileSync(packageFile, 'utf8') + ); + packageJson.scripts['_phase:compile'] = 'node build.cjs --raced-definition'; + fs.writeFileSync(packageFile, JSON.stringify(packageJson)); + second = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await second.handshakeAsync(); + await second.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope('new', 'build', fixture.repoRoot, { + commandOrigin: 'built-in', + argv: ['build', '--only', 'a'], + environment: requestEnvironment() + }) + }); + expect((await second.readControlAsync()).kind).toBe('queuePosition'); + newRequest = second.readTerminalAsync('new'); + expect(oldSession.operationGraph!.abortController.signal.aborted).toBe(false); + release.resolve(); + for (const result of await Promise.all([oldRequest, newRequest])) { + expect(result.terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + } + expect(oldSession.operationGraph!.abortController.signal.aborted).toBe(true); + expect(runs(fixture)).toEqual(['a:one:', 'a:one:--raced-definition']); + } finally { + release.resolve(); + await oldRequest; + await newRequest; + jest.restoreAllMocks(); + await second?.closeAsync(); + await fixture[Symbol.asyncDispose](); + } + }); + + it('drains typed pre-execution results for accepted queued clients before hard restart closes them', async () => { + const selecting: IDeferred = createDeferred(); + const release: IDeferred = createDeferred(); + const fixture: IFixture = await createFixtureAsync(false, 'direct', { + getSuccessorLaunchAsync: async (context) => { + selecting.resolve(); + await release.promise; + return await getInstalledWorkspaceSuccessorLaunchAsync(context); + } + }); + let second: DaemonRequestWireClient | undefined; + let firstResult: Promise | undefined; + let secondResult: Promise | undefined; + try { + await runAsync(fixture, 'initialize-before-restart', ['build', '--only', 'a']); + const environment: Record = { ...requestEnvironment(), RUSHD_TEST_INPUT: 'changed' }; + second = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await second.handshakeAsync(); + firstResult = runAsync(fixture, 'restart-initiator', ['build', '--only', 'a'], { environment }); + await selecting.promise; + await second.sendControlAsync({ + kind: 'requestStart', + payload: createWireEnvelope('queued-for-restart', 'build', fixture.repoRoot, { + argv: ['build', '--only', 'a'], commandOrigin: 'built-in', environment + }) + }); + expect((await second.readControlAsync()).kind).toBe('queuePosition'); + secondResult = second.readTerminalAsync('queued-for-restart'); + release.resolve(); + for (const result of await Promise.all([firstResult, secondResult])) { + expect(result.terminal).toMatchObject({ + kind: 'requestResult', payload: { exitCode: 1, retryAfterRestart: true } + }); + expect(result.frames.every((frame) => frame.kind === DaemonFrameType.controlJson)).toBe(true); + } + expect((await fixture.host.restartCompleted)?.pid).not.toBe(process.pid); + expect(runs(fixture)).toEqual(['a:one:']); + } finally { + release.resolve(); + await firstResult; + await secondResult; + await second?.closeAsync(); + await fixture.host.restartCompleted; + await stopSuccessorAsync(fixture.host.paths); + await fixture[Symbol.asyncDispose](); + } + }); + + it('restarts a hard environment change in a new process without replaying the triggering request', async () => { + const fixture: IFixture = await createFixtureAsync(false, 'direct', { + getSuccessorLaunchAsync: getInstalledWorkspaceSuccessorLaunchAsync + }); + let successor: DaemonRequestWireClient | undefined; + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const oldGraph: IOperationGraph = fixture.session.operationGraph!; + const environment: Record = { + ...requestEnvironment(), + RUSH_PARALLELISM: process.env.RUSH_PARALLELISM === '1' ? '2' : '1' + }; + expect( + (await runAsync(fixture, 'hard', ['build', '--only', 'a'], { environment })).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { + exitCode: 1, + errorMessage: expect.stringContaining('No operation was scheduled or executed') + } + }); + const restarted: IWorkspaceProcessRestartResult | undefined = await fixture.host.restartCompleted; + expect(restarted?.pid).not.toBe(process.pid); + expect(restarted?.pid).toBe(readDaemonLockfile(fixture.host.paths.lockfilePath)?.pid); + expect(oldGraph.abortController.signal.aborted).toBe(true); + expect(runs(fixture)).toEqual(['a:one:']); + successor = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await successor.handshakeAsync(); + expect( + ( + await runAsync({ ...fixture, client: successor }, 'explicit-next', ['build', '--only', 'a'], { + environment + }) + ).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + } finally { + await successor?.closeAsync(); + await stopSuccessorAsync(fixture.host.paths); + await fixture[Symbol.asyncDispose](); + } + }); + + it('refuses an unavailable selected Rush version without executing the current engine as that version', async () => { + const fixture: IFixture = await createFixtureAsync(false, 'direct', { + getSuccessorLaunchAsync: getInstalledWorkspaceSuccessorLaunchAsync + }); + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const filename: string = path.join(fixture.repoRoot, 'rush.json'); + const json: { rushVersion: string } = JSON.parse(fs.readFileSync(filename, 'utf8')); + json.rushVersion = '5.999.0'; + fs.writeFileSync(filename, JSON.stringify(json)); + expect( + (await runAsync(fixture, 'unsupported-version', ['build', '--only', 'a'])).terminal + ).toMatchObject({ + kind: 'requestRejected', + payload: { message: expect.stringContaining('Cannot launch selected Rush 5.999.0') } + }); + expect(runs(fixture)).toEqual(['a:one:']); + expect(readDaemonLockfile(fixture.host.paths.lockfilePath)?.pid).toBe(process.pid); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + for (const commandName of ['install', 'update']) { + it(`drains the real ${commandName} worker result before cleanup and successor startup, including partial failure`, async () => { + const resultReceived: IDeferred = createDeferred(); + const disposalEntered: IDeferred = createDeferred(); + const allowDisposal: IDeferred = createDeferred(); + const fixture: IFixture = await createFixtureAsync(false, 'direct', { + getSuccessorLaunchAsync: getInstalledWorkspaceSuccessorLaunchAsync, + onSessionCreated: (session) => { + const dispose: () => Promise = session[Symbol.asyncDispose].bind(session); + jest.spyOn(session, Symbol.asyncDispose).mockImplementation(async () => { + if (session.operationGraph) { + disposalEntered.resolve(); + await resultReceived.promise; + await allowDisposal.promise; + } + await dispose(); + }); + } + }); + let successor: DaemonRequestWireClient | undefined; + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + await StandardScriptUpdater.updateAsync( + InternalRushConfiguration.loadFromConfigurationFile(path.join(fixture.repoRoot, 'rush.json')) + ); + const oldGraph: IOperationGraph = fixture.session.operationGraph!; + const scriptPath: string = path.join(fixture.repoRoot, 'common/temp/mutate.cjs'); + fs.writeFileSync( + scriptPath, + ` +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '../..'); +const file = path.join(root, 'projects/a/package.json'); +const json = JSON.parse(fs.readFileSync(file, 'utf8')); +json.scripts['_phase:compile'] = 'node build.cjs --post-mutation'; +fs.writeFileSync(file, JSON.stringify(json)); +fs.appendFileSync(path.join(root, 'common/temp/mutation-count.txt'), 'once\\n'); +console.log('native-mutation-applied'); +process.exit(23); +` + ); + const rushJsonPath: string = path.join(fixture.repoRoot, 'rush.json'); + const json: { eventHooks?: object } = JSON.parse(fs.readFileSync(rushJsonPath, 'utf8')); + json.eventHooks = { preRushInstall: ['node common/temp/mutate.cjs'] }; + fs.writeFileSync(rushJsonPath, JSON.stringify(json)); + const result: ITerminalExchange = await runAsync(fixture, 'mutation', [ + commandName, + '--bypass-policy', + '--max-install-attempts', + '0' + ]); + expect(result.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 1, outcome: 'failure', aborted: false } + }); + resultReceived.resolve(); + await disposalEntered.promise; + expect(oldGraph.abortController.signal.aborted).toBe(false); + expect(readDaemonLockfile(fixture.host.paths.lockfilePath)?.pid).toBe(process.pid); + allowDisposal.resolve(); + const restarted: IWorkspaceProcessRestartResult | undefined = await fixture.host.restartCompleted; + expect(restarted?.pid).not.toBe(process.pid); + expect(oldGraph.abortController.signal.aborted).toBe(true); + successor = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await successor.handshakeAsync(); + expect( + (await runAsync({ ...fixture, client: successor }, 'post-mutation', ['build', '--only', 'a'])) + .terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(runs(fixture)).toEqual(['a:one:', 'a:one:--post-mutation']); + expect(fs.readFileSync(path.join(fixture.repoRoot, 'common/temp/mutation-count.txt'), 'utf8')).toBe( + 'once\n' + ); + } finally { + resultReceived.resolve(); + allowDisposal.resolve(); + await successor?.closeAsync(); + await stopSuccessorAsync(fixture.host.paths); + await fixture[Symbol.asyncDispose](); + jest.restoreAllMocks(); + } + }); + } + + it('releases the lock before reporting an idle result so a real native Rush action can run', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + expect((await runAsync(fixture, 'warm', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + + const graph: IOperationGraph | undefined = fixture.session.operationGraph; + const native: INativeCommandResult = await runNativeCommandAsync(fixture.repoRoot, [ + 'rebuild', + '--only', + 'a', + '--parallelism', + '3', + '--verbose' + ]); + expect(native).toMatchObject({ exitCode: 0 }); + expect(native.stdout).toContain('built-a-one'); + expect(runs(fixture)).toEqual(['a:one:', 'a:one:']); + expect((await runAsync(fixture, 'after-native', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session.operationGraph).toBe(graph); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('refuses native lock contention at preparation and at iteration time, and accepts a later explicit retry', async () => { + const fixture: IFixture = await createFixtureAsync(); + const gate: INativeScriptGate = await createNativeScriptGateAsync(fixture.repoRoot, 'a'); + let native: Promise | undefined; + try { + native = runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'a', '--parallelism', '3']); + await Promise.race([ + gate.entered, + native.then((result) => { + throw new Error(`Native action did not enter its script gate: ${JSON.stringify(result)}`); + }) + ]); + expect( + (await runAsync(fixture, 'busy-initialization', ['build', '--only', 'b'])).terminal + ).toMatchObject({ + kind: 'requestRejected', + payload: { message: expect.stringContaining('Another Rush command') } + }); + expect(fixture.session.operationGraph).toBeUndefined(); + await gate.releaseAsync(); + expect(await native).toMatchObject({ exitCode: 0 }); + expect((await runAsync(fixture, 'retry', ['build', '--only', 'b'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + const secondGate: INativeScriptGate = await createNativeScriptGateAsync(fixture.repoRoot, 'a'); + try { + native = runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'a', '--parallelism', '3']); + await Promise.race([ + secondGate.entered, + native.then((result) => { + throw new Error(`Native action did not enter its script gate: ${JSON.stringify(result)}`); + }) + ]); + expect((await runAsync(fixture, 'busy-iteration', ['build', '--only', 'b'])).terminal).toMatchObject({ + kind: 'requestRejected', + payload: { message: expect.stringContaining('Another Rush command') } + }); + } finally { + await secondGate.releaseAsync(); + await native; + } + expect((await runAsync(fixture, 'retry-iteration', ['build', '--only', 'b'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + } finally { + await gate.releaseAsync(); + await native; + await fixture[Symbol.asyncDispose](); + } + }); + + it('uses one native lease for a merged batch and excludes native actions throughout reconciliation and execution', async () => { + const fixture: IFixture = await createFixtureAsync(); + const reconcileEntered: IDeferred = createDeferred(); + const releaseReconciliation: IDeferred = createDeferred(); + const secondSelection: IDeferred = createDeferred(); + let secondClient: DaemonRequestWireClient | undefined; + let gate: INativeScriptGate | undefined; + let first: Promise | undefined; + let second: Promise | undefined; + try { + await runAsync(fixture, 'initialize', ['build', '--only', 'c']); + // Count the batch's lease independently of optional idle-maintenance leases. + await fixture.session.quiesceWarmSetAsync(); + const graph: IOperationGraph = fixture.session.operationGraph!; + const scheduleSpy: jest.SpyInstance = jest.spyOn(graph, 'scheduleIterationAsync'); + const leaseSpy: jest.SpyInstance = jest.spyOn(fixture.session, 'acquireExecutionLeaseAsync'); + const reconcileAsync: WorkspaceSession['reconcileInvalidationsAsync'] = + fixture.session.reconcileInvalidationsAsync.bind(fixture.session); + jest.spyOn(fixture.session, 'reconcileInvalidationsAsync').mockImplementationOnce(async () => { + reconcileEntered.resolve(); + await releaseReconciliation.promise; + return await reconcileAsync(); + }); + const selectAsync: PhasedCommandEngine['selectOperationsAsync'] = + PhasedCommandEngine.prototype.selectOperationsAsync; + let selections: number = 0; + jest.spyOn(PhasedCommandEngine.prototype, 'selectOperationsAsync').mockImplementation(async function ( + this: PhasedCommandEngine, + selectedGraph: IOperationGraph + ) { + const selection: ReadonlyMap = await selectAsync.call( + this, + selectedGraph + ); + if (++selections === 2) secondSelection.resolve(); + return selection; + }); + gate = await createNativeScriptGateAsync(fixture.repoRoot, 'a'); + secondClient = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await secondClient.handshakeAsync(); + first = runAsync(fixture, 'dependency', ['build', '--to', 'a']); + await reconcileEntered.promise; + second = runAsync({ ...fixture, client: secondClient }, 'consumer', ['build', '--to', 'b']); + await secondSelection.promise; + await new Promise((resolve) => setImmediate(resolve)); + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining('Another Rush command') + }); + releaseReconciliation.resolve(); + await Promise.race([ + gate.entered, + first.then((result) => { + throw new Error( + `Daemon iteration did not enter its script gate: ${JSON.stringify(result.terminal)}` + ); + }) + ]); + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining('Another Rush command') + }); + await gate.releaseAsync(); + const results: ITerminalExchange[] = await Promise.all([first, second]); + for (const result of results) + expect(result.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(leaseSpy).toHaveBeenCalledTimes(1); + expect(scheduleSpy).toHaveBeenCalledTimes(1); + expect(runs(fixture)).toEqual(['c:one:', 'a:one:', 'b:one:']); + } finally { + releaseReconciliation.resolve(); + await gate?.releaseAsync(); + await first; + await second; + jest.restoreAllMocks(); + await secondClient?.closeAsync(); + await fixture[Symbol.asyncDispose](); + } + }); + + it('holds the native lease until operation output has drained, before publishing the terminal result', async () => { + const fixture: IFixture = await createFixtureAsync(); + const outputStarted: IDeferred = createDeferred(); + const releaseOutput: IDeferred = createDeferred(); + const iterationFinished: IDeferred = createDeferred(); + let request: Promise | undefined; + try { + await runAsync(fixture, 'initialize', ['build', '--only', 'c']); + fixture.session.operationGraph!.hooks.afterExecuteIterationAsync.tap( + { name: 'test iteration completed', stage: Infinity }, + (status) => { + iterationFinished.resolve(); + return status; + } + ); + const client: TestPhasedRequestClient = new TestPhasedRequestClient(); + client.onWriteAsync = async (write) => { + if (write.operationId && write.text) { + outputStarted.resolve(); + await releaseOutput.promise; + } + }; + request = new PhasedRequestRouter(fixture.session).executeAsync( + { + commandName: 'build', + commandOrigin: 'built-in', + requestId: 'output-drain', + environment: {}, + engineShape: fixture.session.engineShape!, + operationSelection: [{ operationId: 'a (compile)', enabledState: true }] + }, + client, + true + ); + await outputStarted.promise; + await iterationFinished.promise; + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 1 + }); + releaseOutput.resolve(); + expect(await request).toMatchObject({ exitCode: 0 }); + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 0 + }); + } finally { + releaseOutput.resolve(); + await request; + await fixture[Symbol.asyncDispose](); + } + }); + + it('awaits an outstanding execution lease before disposing the engine', async () => { + const fixture: IFixture = await createFixtureAsync(); + let lease: AsyncDisposable | undefined; + let disposal: Promise | undefined; + try { + await runAsync(fixture, 'initialize', ['build', '--only', 'a']); + lease = await fixture.session.acquireExecutionLeaseAsync(); + let disposed: boolean = false; + disposal = fixture.session[Symbol.asyncDispose]().then(() => { + disposed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(disposed).toBe(false); + await expect(fixture.session.acquireExecutionLeaseAsync()).rejects.toThrow('disposed'); + await lease![Symbol.asyncDispose](); + await disposal; + expect(disposed).toBe(true); + expect(fixture.session.operationGraph!.abortController.signal.aborted).toBe(true); + } finally { + await lease?.[Symbol.asyncDispose](); + await disposal; + await fixture[Symbol.asyncDispose](); + } + }); + + for (const kind of ['rig', 'inherited'] as const) { + it(`uses real ${kind} configuration and native cache, and reloads unwatched configuration changes`, async () => { + const fixture: IFixture = await createFixtureAsync(true, kind); + try { + const initial: ITerminalExchange = await runAsync(fixture, 'initial', ['build', '--only', 'a']); + expect(initial.terminal).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + expect(logText(initial)).toContain('Successfully set cache entry'); + expect((await runAsync(fixture, 'warm', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false } + }); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'two'); + await runAsync(fixture, 'changed', ['build', '--only', 'a']); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'one'); + expect((await runAsync(fixture, 'cached', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, operationResults: [{ status: 'FROM CACHE' }] } + }); + expect(runs(fixture)).toEqual(['a:one:', 'a:two:']); + const configurationFile: string = + kind === 'rig' + ? path.join( + fixture.repoRoot, + 'projects/a/node_modules/fixture-rig/profiles/default/config/rush-project.json' + ) + : path.join(fixture.repoRoot, 'common/temp/inherited-rush-project.json'); + fs.writeFileSync( + configurationFile, + JSON.stringify({ + operationSettings: [ + { + operationName: '_phase:compile', + outputFolderNames: ['lib'], + disableBuildCacheForOperation: true + } + ] + }) + ); + const oldGraph: IOperationGraph | undefined = fixture.session.operationGraph; + expect( + (await runAsync(fixture, 'configuration-changed', ['build', '--only', 'a'])).terminal + ).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session.operationGraph).not.toBe(oldGraph); + expect(runs(fixture)).toHaveLength(3); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + } + + it('constructs the engine from fresh rig data without clearing or modifying native configuration caches', async () => { + const fixture: IFixture = await createFixtureAsync(true, 'rig'); + try { + const project: RushConfigurationProject = fixture.session.rushConfiguration.projectsByName.get('a')!; + const terminal: Terminal = new Terminal(new NoOpTerminalProvider()); + const cached: RushProjectConfiguration | undefined = + await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal); + fs.writeFileSync( + path.join( + fixture.repoRoot, + 'projects/a/node_modules/fixture-rig/profiles/default/config/rush-project.json' + ), + JSON.stringify({ + operationSettings: [ + { + operationName: '_phase:compile', + outputFolderNames: ['lib'], + disableBuildCacheForOperation: true + } + ] + }) + ); + expect((await runAsync(fixture, 'fresh-engine', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + const operation: Operation | undefined = Array.from(fixture.session.operationGraph!.operations).find( + (candidate) => candidate.associatedProject.packageName === project.packageName + ); + expect(operation!.settings!.disableBuildCacheForOperation).toBe(true); + expect(await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal)).toBe(cached); + expect( + cached!.operationSettingsByOperationName.get('_phase:compile')!.disableBuildCacheForOperation + ).toBeUndefined(); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('uses fresh inherited ignore globs for native git selectors without changing shared caches', async () => { + const fixture: IFixture = await createFixtureAsync(false, 'rig'); + try { + const project: RushConfigurationProject = fixture.session.rushConfiguration.projectsByName.get('a')!; + const configurationFile: string = path.join( + fixture.repoRoot, + 'projects/a/node_modules/fixture-rig/profiles/default/config/rush-project.json' + ); + const settings: object = { + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }; + fs.writeFileSync( + configurationFile, + JSON.stringify({ ...settings, incrementalBuildIgnoredGlobs: ['input.txt'] }) + ); + const terminal: Terminal = new Terminal(new NoOpTerminalProvider()); + const cached: RushProjectConfiguration | undefined = + await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal); + fs.writeFileSync(configurationFile, JSON.stringify(settings)); + fs.writeFileSync(path.join(project.projectFolder, 'input.txt'), 'git-selection'); + execFileSync('git', ['add', 'projects/a/input.txt'], { cwd: fixture.repoRoot }); + const selected: ITerminalExchange = await runAsync(fixture, 'git-selection', [ + 'build', + '--only', + 'git:HEAD' + ]); + expect(selected.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, operationResults: [{ operationId: 'a (compile)' }] } + }); + expect(runs(fixture)).toEqual(['a:git-selection:']); + expect(await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal)).toBe(cached); + expect(Array.from(cached!.incrementalBuildIgnoredGlobs)).toEqual(['input.txt']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('executes real selected scripts, reuses one all-project graph, refreshes inputs and closes it', async () => { + const fixture: IFixture = await createFixtureAsync(); + const originalEnvironment: NodeJS.ProcessEnv = { ...process.env }; + const originalCwd: string = process.cwd(); + let graph: IOperationGraph | undefined; + try { + const first: ITerminalExchange = await runAsync(fixture, 'initial', [ + 'build', + '--to', + 'b', + '--parallelism', + '3' + ]); + expect(first.terminal).toMatchObject({ + kind: 'requestResult', + payload: { + exitCode: 0, + scheduled: true, + operationResults: [ + { operationId: 'a (compile)', status: 'SUCCESS' }, + { operationId: 'b (compile)', status: 'SUCCESS' } + ] + } + }); + expect(logText(first)).toContain('built-a-one'); + expect(runs(fixture)).toEqual(['a:one:', 'b:one:']); + graph = fixture.session.operationGraph; + expect(graph?.operations.size).toBe(3); + + const warm: ITerminalExchange = await runAsync(fixture, 'warm', [ + 'build', + '--to', + 'b', + '--parallelism', + '3' + ]); + expect(warm.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false } + }); + expect(fixture.session.operationGraph).toBe(graph); + expect(runs(fixture)).toHaveLength(2); + + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'two'); + const changed: ITerminalExchange = await runAsync(fixture, 'changed', [ + 'build', + '--to', + 'b', + '--parallelism', + '3' + ]); + expect(changed.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: true } + }); + expect(runs(fixture)).toEqual(['a:one:', 'b:one:', 'a:two:', 'b:one:']); + + const selected: ITerminalExchange = await runAsync( + fixture, + 'cwd-selection', + ['build', '--only', '.', '--parallelism', '3'], + { + cwd: path.join(fixture.repoRoot, 'projects/c') + } + ); + expect(selected.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, operationResults: [{ operationId: 'c (compile)' }] } + }); + expect(runs(fixture).at(-1)).toBe('c:one:'); + expect(fixture.session.operationGraph).toBe(graph); + expect(process.cwd()).toBe(originalCwd); + expect(process.env).toEqual(originalEnvironment); + } finally { + await fixture[Symbol.asyncDispose](); + } + expect(graph?.abortController.signal.aborted).toBe(true); + await expect(fixture.session.reconcileInvalidationsAsync()).rejects.toThrow('disposed'); + }); + + it('does not expand unsafe --only selection and reloads changed parameters while rejecting ambiguous rushx', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + const first: ITerminalExchange = await runAsync(fixture, 'only', [ + 'build', + '--only', + 'b', + '--production' + ]); + expect(first.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, operationResults: [{ operationId: 'b (compile)' }] } + }); + expect(runs(fixture)).toEqual(['b:one:--production']); + const originalGraph: IOperationGraph | undefined = fixture.session.operationGraph; + expect((await runAsync(fixture, 'parameters', ['build', '--only', 'b'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + expect(fixture.session.operationGraph).not.toBe(originalGraph); + expect( + ( + await runAsync(fixture, 'environment', ['build', '--only', 'b', '--production'], { + environment: {} + }) + ).terminal + ).toMatchObject({ kind: 'requestRejected' }); + expect( + (await runAsync(fixture, 'rushx', ['build'], { commandOrigin: 'custom' })).terminal + ).toMatchObject({ kind: 'requestRejected' }); + expect(runs(fixture)).toEqual(['b:one:--production', 'b:one:']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('preserves warnings-as-errors and failed script results', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'warning'); + const warning: ITerminalExchange = await runAsync(fixture, 'warning', ['build', '--only', 'a']); + expect(warning.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 1, outcome: 'success-with-warning' } + }); + expect(logText(warning)).toContain('warning-a'); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'failure'); + const failed: ITerminalExchange = await runAsync(fixture, 'failed', ['build', '--only', 'a']); + expect(failed.terminal).toMatchObject({ + kind: 'requestResult', + payload: { + exitCode: 1, + outcome: 'failure', + operationResults: [{ operationId: 'a (compile)', status: 'FAILURE' }] + } + }); + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 0 + }); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('rechecks installation state under the execution lease and releases the lock when reconciliation fails', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + await runAsync(fixture, 'initialize', ['build', '--only', 'a']); + const flagPath: string = path.join(fixture.repoRoot, 'common/temp/last-link.flag'); + fs.rmSync(flagPath); + expect((await runAsync(fixture, 'unlinked', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestRejected', + payload: { message: expect.stringContaining('Link flag invalid') } + }); + expect(runs(fixture)).toEqual(['a:one:']); + fs.writeFileSync(flagPath, '{}'); + expect(await runNativeCommandAsync(fixture.repoRoot, ['rebuild', '--only', 'c'])).toMatchObject({ + exitCode: 0 + }); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('runs rebuild scripts on every request and reloads changed graph definitions', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + for (const id of ['rebuild-one', 'rebuild-two']) { + expect((await runAsync(fixture, id, ['rebuild', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: true } + }); + } + expect(runs(fixture)).toEqual(['a:one:', 'a:one:']); + fs.writeFileSync( + path.join(fixture.repoRoot, 'projects/a/package.json'), + '{"name":"a","version":"1.0.0","scripts":{"_phase:compile":"echo wrong"}}' + ); + const oldGraph: IOperationGraph | undefined = fixture.session.operationGraph; + expect( + (await runAsync(fixture, 'changed-definition', ['rebuild', '--only', 'a'])).terminal + ).toMatchObject({ kind: 'requestResult', payload: { exitCode: 0 } }); + expect(fixture.session.operationGraph).not.toBe(oldGraph); + expect(runs(fixture)).toHaveLength(2); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('uses the native local build cache instead of executing a previously cached input again', async () => { + const fixture: IFixture = await createFixtureAsync(true); + try { + expect((await runAsync(fixture, 'cache-one', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'two'); + expect((await runAsync(fixture, 'cache-two', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'one'); + const cached: ITerminalExchange = await runAsync(fixture, 'cache-restore', ['build', '--only', 'a']); + expect(cached.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, operationResults: [{ operationId: 'a (compile)', status: 'FROM CACHE' }] } + }); + expect(runs(fixture)).toEqual(['a:one:', 'a:two:']); + expect(fs.readFileSync(path.join(fixture.repoRoot, 'projects/a/lib/output.txt'), 'utf8')).toBe('one'); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('reconciles changes made without a connected client and preserves an empty native selection', async () => { + const fixture: IFixture = await createFixtureAsync(); + let reconnected: DaemonRequestWireClient | undefined; + try { + expect((await runAsync(fixture, 'initial', ['build', '--to', 'b'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + const graph: IOperationGraph | undefined = fixture.session.operationGraph; + await fixture.client.closeAsync(); + fs.writeFileSync(path.join(fixture.repoRoot, 'projects/a/input.txt'), 'offline-change'); + reconnected = await DaemonRequestWireClient.connectAsync(fixture.host.paths.socketPath); + await reconnected.handshakeAsync(); + const next: IFixture = { ...fixture, client: reconnected }; + expect((await runAsync(next, 'reconnected', ['build', '--to', 'b'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: true } + }); + expect(fixture.session.operationGraph).toBe(graph); + expect(runs(fixture)).toEqual(['a:one:', 'b:one:', 'a:offline-change:', 'b:one:']); + expect((await runAsync(next, 'empty', ['build', '--to-except', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false, operationResults: [] } + }); + expect(runs(fixture)).toHaveLength(4); + } finally { + await reconnected?.closeAsync(); + await fixture[Symbol.asyncDispose](); + } + }); + + it('awaits runner cleanup before shutdown completes and does not close twice on abort', async () => { + const fixture: IFixture = await createFixtureAsync(); + const releaseCleanup: IDeferred = createDeferred(); + const cleanupStarted: IDeferred = createDeferred(); + let shutdown: Promise | undefined; + try { + expect((await runAsync(fixture, 'initial', ['build', '--only', 'a'])).terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0 } + }); + const graph: IOperationGraph = fixture.session.operationGraph!; + const closeRunnersAsync: () => Promise = graph.closeRunnersAsync.bind(graph); + const closeSpy: jest.SpyInstance, [operations?: Iterable]> = jest + .spyOn(graph, 'closeRunnersAsync') + .mockImplementation(async () => { + cleanupStarted.resolve(); + await releaseCleanup.promise; + await closeRunnersAsync(); + }); + let closed: boolean = false; + shutdown = fixture.host.closeAsync().then(() => { + closed = true; + }); + await cleanupStarted.promise; + expect(closed).toBe(false); + releaseCleanup.resolve(); + await shutdown; + expect(closed).toBe(true); + expect(graph.abortController.signal.aborted).toBe(true); + expect(closeSpy).toHaveBeenCalledTimes(1); + } finally { + releaseCleanup.resolve(); + await shutdown; + await fixture[Symbol.asyncDispose](); + } + }); + + it('rejects invalid inherited project configuration before creating a graph or running scripts', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + fs.writeFileSync( + path.join(fixture.repoRoot, 'projects/a/config/rush-project.json'), + '{"extends":"./unowned.json"}' + ); + expect((await runAsync(fixture, 'unsupported', ['build'])).terminal).toMatchObject({ + kind: 'requestRejected' + }); + expect(fixture.session.operationGraph).toBeUndefined(); + expect(runs(fixture)).toEqual([]); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('delivers buffered engine diagnostics even when the warm request schedules no work', async () => { + const fixture: IFixture = await createFixtureAsync(); + try { + await runAsync(fixture, 'initial', ['build', '--only', 'a']); + const terminal: EngineTerminalProvider = new EngineTerminalProvider(); + terminal.attach(fixture.session.operationGraph!); + terminal.write('snapshot-diagnostic', TerminalProviderSeverity.warning); + const warm: ITerminalExchange = await runAsync(fixture, 'warm', ['build', '--only', 'a']); + expect(warm.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 0, scheduled: false } + }); + const events: string = warm.frames + .filter((frame) => frame.kind === DaemonFrameType.event) + .map((frame) => JSON.stringify(decodeDaemonEventFrame(frame.payload))) + .join('\n'); + expect(events).toContain('snapshot-diagnostic'); + expect(runs(fixture)).toEqual(['a:one:']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/RequestScheduler.test.ts b/libraries/rush-daemon/src/test/RequestScheduler.test.ts index 4fc9430fc8..e927b1f9a3 100644 --- a/libraries/rush-daemon/src/test/RequestScheduler.test.ts +++ b/libraries/rush-daemon/src/test/RequestScheduler.test.ts @@ -8,6 +8,34 @@ import { RequestSchedulerErrorCode } from '../RequestScheduler'; +describe('exclusive generation handoff', () => { + it('atomically downgrades without admitting a queued writer before the initiating reader finishes', async () => { + const scheduler: RequestScheduler = new RequestScheduler(); + const owner: IRequestLease = await scheduler.acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + let writerAdmitted: boolean = false; + const writer: Promise = scheduler + .acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }) + .then((lease) => { + writerAdmitted = true; + return lease; + }); + scheduler.downgradeExclusiveLease(owner, RequestExclusivityClass.SharedBuild); + expect(owner.exclusivityClass).toBe(RequestExclusivityClass.SharedBuild); + await Promise.resolve(); + expect(writerAdmitted).toBe(false); + owner.release(); + const next: IRequestLease = await writer; + expect(writerAdmitted).toBe(true); + next.release(); + expect(scheduler.activeRequestCount).toBe(0); + expect(() => scheduler.downgradeExclusiveLease(owner, RequestExclusivityClass.SharedBuild)).toThrow(); + }); +}); + describe(RequestScheduler.name, () => { it('admits requests from the same shared class concurrently', async () => { const scheduler: RequestScheduler = new RequestScheduler(); diff --git a/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts b/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts index e8c8306839..4fab1f44a4 100644 --- a/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts +++ b/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts @@ -1,14 +1,21 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +jest.mock('../serveRushDaemon', () => ({ serveRushDaemonAsync: jest.fn() })); + import * as path from 'node:path'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { Rush } from '@microsoft/rush-lib'; + import { + launchRushDaemonAsync, resolveRushDaemonWorkspace, type IRushDaemonWorkspace } from '../RushDaemonCommandLine'; +import { serveRushDaemonAsync } from '../serveRushDaemon'; +import { RushDaemonRequestResolver } from '../RushDaemonRequestResolver'; describe(resolveRushDaemonWorkspace.name, () => { let tempFolder: string; @@ -48,4 +55,19 @@ describe(resolveRushDaemonWorkspace.name, () => { it('reports when no rush.json exists', () => { expect(() => resolveRushDaemonWorkspace(tempFolder)).toThrow(/Unable to find rush\.json/); }); + + it('forwards validated idle configuration to the WS3 host option', async () => { + await writeFile( + path.join(tempFolder, 'rush.json'), + JSON.stringify({ rushVersion: Rush.version, daemon: { idleTimeoutSeconds: 42 } }) + ); + await launchRushDaemonAsync(tempFolder); + expect(serveRushDaemonAsync).toHaveBeenCalledWith( + expect.objectContaining({ + repoRoot: tempFolder, + idleTimeoutSeconds: 42, + requestResolver: expect.any(RushDaemonRequestResolver) + }) + ); + }); }); diff --git a/libraries/rush-daemon/src/test/RushDaemonHost.test.ts b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts index c7f4db778e..4aa26f748b 100644 --- a/libraries/rush-daemon/src/test/RushDaemonHost.test.ts +++ b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts @@ -468,7 +468,7 @@ describe(RushDaemonHost.name, () => { it('removes transport artifacts when workspace initialization fails', async () => { const repoRoot: string = createTestRepoRoot(); const workspaceKey: string = computeDaemonWorkspaceKey({ - canonicalRepoRoot: fs.realpathSync(repoRoot), + canonicalRepoRoot: fs.realpathSync.native(repoRoot), rushVersion: RUSH_VERSION }); const paths: IDaemonPaths = resolveDaemonPathsFromProcess(workspaceKey); diff --git a/libraries/rush-daemon/src/test/SuccessfulMutationFixture.ts b/libraries/rush-daemon/src/test/SuccessfulMutationFixture.ts new file mode 100644 index 0000000000..2a11d43eef --- /dev/null +++ b/libraries/rush-daemon/src/test/SuccessfulMutationFixture.ts @@ -0,0 +1,505 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; + +import { Rush, RushConfiguration } from '@microsoft/rush-lib'; +import { LastInstallFlag } from '@microsoft/rush-lib/lib/api/LastInstallFlag'; +import { SubprocessTerminator } from '@rushstack/node-core-library'; +import { + DaemonClient, + captureDaemonRequest, + executeWithDaemonRestartAsync, + type DaemonClientOutcome +} from '@rushstack/rush-client-core'; +import { + readDaemonLockfile, + type IDaemonLockfile, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import { createDeferred, type IDeferred } from './DaemonRequestWireTestUtilities'; +import { stopSuccessorAsync } from './WorkspaceLifecycleTestProcess'; +import { removeTestFolderAsync, waitForTestProcessExitAsync } from './TestProcessExit'; + +export interface ISuccessfulMutationOutput { + readonly exitCode: number | undefined; + readonly stdout: string; + readonly stderr: string; +} + +export interface IMutationRequestOutput extends ISuccessfulMutationOutput { + readonly outcome: DaemonClientOutcome; +} + +interface ITrackedProcess { + readonly child: ChildProcess; + readonly completion: Promise; +} + +export class MutationGate implements AsyncDisposable { + readonly #sockets: Set = new Set(); + readonly #entered: IDeferred = createDeferred(); + readonly #server: net.Server; + #release: Promise | undefined; + public readonly port: number; + public readonly entered: Promise = this.#entered.promise; + + private constructor(server: net.Server, port: number) { + this.#server = server; + this.port = port; + server.on('connection', (socket: net.Socket) => { + this.#sockets.add(socket); + socket.once('close', () => this.#sockets.delete(socket)); + this.#entered.resolve(); + }); + } + + public static async createAsync(): Promise { + const server: net.Server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address: net.AddressInfo | string | null = server.address(); + if (!address || typeof address === 'string') throw new Error('The mutation gate did not bind.'); + return new MutationGate(server, address.port); + } + + public [Symbol.asyncDispose](): Promise { + this.#release ??= new Promise((resolve, reject) => { + for (const socket of this.#sockets) socket.end('continue\n'); + this.#server.close((error) => (error ? reject(error) : resolve())); + }); + return this.#release; + } +} + +/** Real offline PNPM installation plus standalone native daemon processes, owned entirely by one test. */ +export class SuccessfulMutationFixture implements AsyncDisposable { + public readonly folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-successful-mutation-')); + public readonly repoRoot: string = path.join(this.folder, 'repo'); + public readonly controlFolder: string = path.join(this.folder, 'control'); + public readonly environment: Readonly>; + public readonly pnpmVersion: string; + public paths: IDaemonPaths | undefined; + public initialPid: number | undefined; + readonly #processes: Set = new Set(); + #requestSequence: number = 0; + #initialProcess: ITrackedProcess | undefined; + + private constructor(pnpmVersion: string) { + this.pnpmVersion = pnpmVersion; + const home: string = path.join(this.folder, 'home'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(this.controlFolder, { recursive: true }); + fs.mkdirSync(path.join(this.folder, 'runtime'), { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(home, '.npmrc'), ''); + fs.writeFileSync(path.join(home, 'global.npmrc'), ''); + const environment: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_RUNTIME_DIR: path.join(this.folder, 'runtime'), + RUSH_GLOBAL_FOLDER: path.join(this.folder, 'rush-global'), + RUSH_PNPM_STORE_PATH: path.join(this.folder, 'store'), + RUSH_TEMP_FOLDER: undefined, + RUSH_PREVIEW_VERSION: undefined, + RUSH_VARIANT: undefined, + RUSH_BUILD_CACHE_ENABLED: '0', + RUSHD_MUTATION_TEST_CONTROL: this.controlFolder, + NPM_CONFIG_USERCONFIG: path.join(home, '.npmrc'), + NPM_CONFIG_GLOBALCONFIG: path.join(home, 'global.npmrc'), + NPM_CONFIG_CACHE: path.join(this.folder, 'npm-cache'), + NPM_CONFIG_AUDIT: 'false', + NPM_CONFIG_FUND: 'false', + CI: 'true', + FORCE_COLOR: '0' + }; + this.environment = Object.freeze( + Object.fromEntries( + Object.entries(environment).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + ); + } + + public static async createAsync(): Promise { + const sourceRushJson: string | undefined = RushConfiguration.tryFindRushJsonLocation({ + startingFolder: __dirname, + showVerbose: false + }); + if (!sourceRushJson) throw new Error('Cannot locate the installed workspace package manager.'); + const sourceTool: string = path.join(path.dirname(sourceRushJson), 'common/temp/pnpm-local'); + const packageFile: string = path.join(sourceTool, 'node_modules/pnpm/package.json'); + if (!fs.existsSync(packageFile)) + throw new Error('The declared PNPM tool is missing; restore workspace dependencies.'); + const packageJson: { name: string; version: string } = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + if (packageJson.name !== 'pnpm') throw new Error('The workspace package manager is not PNPM.'); + const fixture: SuccessfulMutationFixture = new SuccessfulMutationFixture(packageJson.version); + try { + const tool: string = path.join( + fixture.environment.RUSH_GLOBAL_FOLDER, + `node-${process.version}`, + `pnpm-${packageJson.version}` + ); + fs.mkdirSync(tool, { recursive: true }); + // Reuse the installed distribution, not a shim. No user npmrc, registry credentials, or tool cache is modified. + fs.cpSync(path.join(sourceTool, 'node_modules'), path.join(tool, 'node_modules'), { + recursive: true, + verbatimSymlinks: true + }); + const actualVersion: string = execFileSync( + process.execPath, + [path.join(tool, 'node_modules/pnpm/bin/pnpm.cjs'), '--version'], + { env: fixture.environment, encoding: 'utf8' } + ).trim(); + if (actualVersion !== packageJson.version) + throw new Error('The copied PNPM executable has an unexpected version.'); + await new LastInstallFlag(tool, { node: process.versions.node }).createAsync(); + fixture.#writeRepository(); + return fixture; + } catch (error) { + await fixture[Symbol.asyncDispose](); + throw error; + } + } + + #writeRepository(): void { + const write: (filename: string, value: string) => void = (filename, value) => { + const absolute: string = path.join(this.repoRoot, filename); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, value); + }; + write( + 'rush.json', + JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: this.pnpmVersion, + projectFolderMinDepth: 2, + projectFolderMaxDepth: 2, + daemon: { idleTimeoutSeconds: 120 }, + eventHooks: { postRushInstall: ['node common/scripts/mutation-postinstall.cjs'] }, + projects: ['provider-one', 'provider-two', 'app'].map((name) => ({ + packageName: `@mutation/${name}`, + projectFolder: `projects/${name}` + })) + }) + ); + write( + 'common/config/rush/pnpm-config.json', + JSON.stringify({ + useWorkspaces: true, + pnpmStore: 'local', + autoInstallPeers: false + }) + ); + write('common/config/rush/experiments.json', JSON.stringify({ printEventHooksOutputToConsole: true })); + write( + 'common/config/rush/.npmrc', + 'registry=http://127.0.0.1:9/\naudit=false\nfund=false\nupdate-notifier=false\n' + ); + write( + '.gitignore', + 'common/temp/\n**/node_modules/\n**/.rush/\n**/rush-logs/\n**/lib/\nbuild-runs.txt\n' + ); + write( + 'fixtures/install-proof/package.json', + JSON.stringify({ + name: '@mutation/install-proof', + version: '1.0.0', + main: 'index.js' + }) + ); + write('fixtures/install-proof/index.js', "module.exports = 'installed';\n"); + for (const provider of ['provider-one', 'provider-two']) { + write( + `projects/${provider}/package.json`, + JSON.stringify({ + name: `@mutation/${provider}`, + version: '1.0.0', + main: 'lib/index.js', + dependencies: { '@mutation/install-proof': 'file:../../fixtures/install-proof' }, + scripts: { build: 'node build.cjs' } + }) + ); + write(`projects/${provider}/value.txt`, provider); + write( + `projects/${provider}/build.cjs`, + ` +const fs = require('node:fs'); +const value = fs.readFileSync('value.txt', 'utf8') + '-' + require('@mutation/install-proof'); +fs.mkdirSync('lib', {recursive:true}); +fs.writeFileSync('lib/index.js', 'module.exports = ' + JSON.stringify(value) + ';\\n'); +fs.appendFileSync('../../build-runs.txt', value + '\\n'); +console.log('BUILT:' + value); +` + ); + } + write( + 'projects/app/package.json', + JSON.stringify({ + name: '@mutation/app', + version: '1.0.0', + dependencies: { '@mutation/provider-one': 'workspace:*' }, + scripts: { build: 'node build.cjs' } + }) + ); + write('projects/app/state.txt', 'before'); + write( + 'projects/app/build.cjs', + ` +const fs = require('node:fs'); +const name = Object.keys(require('./package.json').dependencies)[0]; +const value = require(name) + ':' + fs.readFileSync('state.txt', 'utf8'); +fs.mkdirSync('lib', {recursive:true}); +fs.writeFileSync('lib/value.txt', value); +fs.appendFileSync('../../build-runs.txt', 'app:' + value + '\\n'); +console.log('APP_RESULT:' + value); +` + ); + write( + 'common/scripts/mutation-postinstall.cjs', + ` +const fs = require('node:fs'); +const path = require('node:path'); +const net = require('node:net'); +const controlFolder = process.env.RUSHD_MUTATION_TEST_CONTROL; +const controlFile = path.join(controlFolder, 'mutation.json'); +if (fs.existsSync(controlFile)) { + const control = JSON.parse(fs.readFileSync(controlFile, 'utf8')); + fs.writeFileSync(path.join(__dirname, '../../projects/app/state.txt'), 'after-' + control.commandName); + fs.appendFileSync(path.join(controlFolder, 'events.txt'), 'postinstall:' + control.commandName + '\\n'); + const socket = net.connect(control.postInstallPort, '127.0.0.1'); + socket.once('error', (error) => { console.error(error); process.exitCode = 1; }); + socket.once('data', () => { + socket.end(); + process.stdout.write('FINAL_POST_INSTALL:' + control.commandName + '\\n'); + }); +} +` + ); + execFileSync('git', ['init', '--quiet'], { cwd: this.repoRoot }); + execFileSync('git', ['config', '--local', 'core.autocrlf', 'false'], { cwd: this.repoRoot }); + execFileSync('git', ['add', '.'], { cwd: this.repoRoot }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Mutation Test', + '-c', + 'user.email=mutation@example.invalid', + 'commit', + '--quiet', + '-m', + 'isolated fixture' + ], + { cwd: this.repoRoot } + ); + } + + public runWorkerAsync(commandName: 'install' | 'update'): Promise { + return this.#spawn([ + path.resolve(__dirname, '../NativeMutationWorker.js'), + Rush.version, + this.repoRoot, + commandName, + '--bypass-policy', + '--offline' + ]).completion; + } + + public async startAsync(): Promise { + this.#initialProcess = this.#spawn( + [path.join(__dirname, 'fixtures/SuccessfulMutationDaemon.js'), this.repoRoot, this.controlFolder], + path.join(this.controlFolder, 'daemon.log') + ); + const readyFile: string = path.join(this.controlFolder, 'ready.json'); + await Promise.race([ + waitForFileAsync(readyFile), + this.#initialProcess.completion.then((result) => { + throw new Error(`The standalone daemon exited before readiness: ${JSON.stringify(result)}`); + }) + ]); + const ready: { paths: IDaemonPaths; pid: number } = JSON.parse(fs.readFileSync(readyFile, 'utf8')); + this.paths = ready.paths; + this.initialPid = ready.pid; + } + + public async requestAsync(args: ReadonlyArray): Promise { + const paths: IDaemonPaths | undefined = this.paths; + if (!paths) throw new Error('The standalone daemon has not started.'); + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: paths.socketPath }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + const outcome: DaemonClientOutcome = await executeWithDaemonRestartAsync( + client, + { paths }, + { + request: captureDaemonRequest({ + requestId: `successful-mutation-${++this.#requestSequence}`, + argv: args, + commandName: args[0], + commandOrigin: 'built-in', + invocationKind: 'rush', + cwd: this.repoRoot, + environment: this.environment, + terminal: { isTTY: false, supportsColor: false, acceptsStdin: false } + }), + onStdoutAsync: async (chunk) => { + stdout.push(Buffer.from(chunk)); + }, + onStderrAsync: async (chunk) => { + stderr.push(Buffer.from(chunk)); + } + } + ); + return { + outcome, + exitCode: outcome.kind === 'result' ? outcome.result.exitCode : undefined, + stdout: Buffer.concat(stdout).toString(), + stderr: Buffer.concat(stderr).toString() + }; + } + + public async waitForSuccessorAsync(): Promise { + if (!this.paths || !this.initialPid) throw new Error('There is no original daemon ownership.'); + const { paths, initialPid } = this; + return await waitForOwnershipAsync(paths, initialPid); + } + + public async waitForInitialExitAsync(): Promise { + if (!this.#initialProcess) throw new Error('There is no original daemon process.'); + return await this.#initialProcess.completion; + } + + public readAppOutput(): string { + return fs.readFileSync(path.join(this.repoRoot, 'projects/app/lib/value.txt'), 'utf8'); + } + + public removeInstalledAppDependency(): void { + fs.rmSync(path.join(this.repoRoot, 'projects/app/node_modules'), { recursive: true, force: true }); + fs.rmSync(path.join(this.repoRoot, 'common/temp/last-install.flag'), { force: true }); + fs.rmSync(path.join(this.repoRoot, 'common/temp/last-link.flag'), { force: true }); + } + + public selectSecondProvider(): void { + const filename: string = path.join(this.repoRoot, 'projects/app/package.json'); + const packageJson: { dependencies: Record } = JSON.parse( + fs.readFileSync(filename, 'utf8') + ); + packageJson.dependencies = { '@mutation/provider-two': 'workspace:*' }; + fs.writeFileSync(filename, JSON.stringify(packageJson)); + } + + #spawn(args: string[], logFile?: string): ITrackedProcess { + const log: number | undefined = logFile ? fs.openSync(logFile, 'a', 0o600) : undefined; + const child: ChildProcess = spawn(process.execPath, args, { + cwd: this.repoRoot, + env: this.environment, + detached: SubprocessTerminator.RECOMMENDED_OPTIONS.detached, + stdio: log === undefined ? ['ignore', 'pipe', 'pipe'] : ['ignore', log, log] + }); + if (log !== undefined) fs.closeSync(log); + SubprocessTerminator.killProcessTreeOnExit(child, SubprocessTerminator.RECOMMENDED_OPTIONS); + let stdout: string = ''; + let stderr: string = ''; + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const completion: Promise = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (exitCode) => + resolve({ + exitCode: exitCode ?? undefined, + stdout, + stderr: logFile ? fs.readFileSync(logFile, 'utf8') : stderr + }) + ); + }); + const tracked: ITrackedProcess = { child, completion }; + this.#processes.add(tracked); + return tracked; + } + + public async [Symbol.asyncDispose](): Promise { + const errors: unknown[] = []; + try { + if (this.paths) { + try { + await stopSuccessorAsync(this.paths); + } catch (error) { + errors.push(error); + } + } + for (const { child, completion } of this.#processes) { + try { + if (child.exitCode === null && child.signalCode === null) { + SubprocessTerminator.killProcessTree(child, SubprocessTerminator.RECOMMENDED_OPTIONS); + } + await completion; + if (child.pid !== undefined) await waitForTestProcessExitAsync(child.pid); + } catch (error) { + errors.push(error); + } + } + } finally { + if (errors.length === 0) await removeTestFolderAsync(this.folder, true); + } + if (errors.length > 0) { + throw new AggregateError(errors, `Failed to clean up native mutation fixtures; retained ${this.folder}.`); + } + } +} + +function waitForFileAsync(filename: string): Promise { + return new Promise((resolve, reject) => { + const timeout: NodeJS.Timeout = setTimeout( + () => finish(new Error(`Timed out waiting for ${filename}`)), + 20_000 + ); + const watcher: fs.FSWatcher = fs.watch(fs.realpathSync.native(path.dirname(filename)), inspect); + function finish(error?: Error): void { + clearTimeout(timeout); + watcher.close(); + if (error) reject(error); + else resolve(); + } + function inspect(): void { + if (fs.existsSync(filename)) finish(); + } + watcher.once('error', finish); + inspect(); + }); +} + +function waitForOwnershipAsync(paths: IDaemonPaths, oldPid: number): Promise { + return new Promise((resolve, reject) => { + const timeout: NodeJS.Timeout = setTimeout( + () => finish(undefined, new Error('No successor acquired ownership.')), + 20_000 + ); + const watcher: fs.FSWatcher = fs.watch( + fs.realpathSync.native(path.dirname(paths.lockfilePath)), + inspect + ); + function finish(owner?: IDaemonLockfile, error?: Error): void { + clearTimeout(timeout); + watcher.close(); + if (error) reject(error); + else if (owner) resolve(owner); + } + function inspect(): void { + const owner: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); + if (owner && owner.pid !== oldPid) finish(owner); + } + watcher.once('error', (error) => finish(undefined, error)); + inspect(); + }); +} diff --git a/libraries/rush-daemon/src/test/SuccessfulNativeMutation.test.ts b/libraries/rush-daemon/src/test/SuccessfulNativeMutation.test.ts new file mode 100644 index 0000000000..9bd6cd151a --- /dev/null +++ b/libraries/rush-daemon/src/test/SuccessfulNativeMutation.test.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DaemonClient } from '@rushstack/rush-client-core'; +import { readDaemonLockfile, type IDaemonLockfile } from '@rushstack/rush-daemon-transport'; + +import { + MutationGate, + SuccessfulMutationFixture, + type IMutationRequestOutput, + type ISuccessfulMutationOutput +} from './SuccessfulMutationFixture'; + +jest.setTimeout(120_000); + +describe('successful native install/update', () => { + it('runs the real update and install workers offline and installs actual workspace dependencies', async () => { + const fixture: SuccessfulMutationFixture = await SuccessfulMutationFixture.createAsync(); + try { + const updated: ISuccessfulMutationOutput = await fixture.runWorkerAsync('update'); + expect(updated).toMatchObject({ exitCode: 0 }); + const lockfile: string = path.join(fixture.repoRoot, 'common/config/rush/pnpm-lock.yaml'); + const committed: string = fs.readFileSync(lockfile, 'utf8'); + expect(committed).toContain('@mutation/provider-one'); + expect(committed).toContain('snapshots:'); + const installed: string = path.join( + fixture.repoRoot, + 'projects/app/node_modules/@mutation/provider-one/package.json' + ); + expect(fs.existsSync(installed)).toBe(true); + fixture.removeInstalledAppDependency(); + expect(fs.existsSync(installed)).toBe(false); + const result: ISuccessfulMutationOutput = await fixture.runWorkerAsync('install'); + expect(result).toMatchObject({ exitCode: 0 }); + expect(fs.existsSync(installed)).toBe(true); + expect(fs.readFileSync(lockfile, 'utf8')).toBe(committed); + expect(fs.existsSync(path.join(fixture.repoRoot, 'common/temp/last-install.flag'))).toBe(true); + expect(fs.existsSync(path.join(fixture.repoRoot, 'common/temp/last-link.flag'))).toBe(true); + expect(`${updated.stdout}\n${result.stdout}`).toContain(`Found pnpm version ${fixture.pnpmVersion}`); + expect(`${updated.stdout}\n${result.stdout}`).not.toContain( + `Installing pnpm version ${fixture.pnpmVersion}` + ); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + for (const commandName of ['install', 'update'] as const) { + it(`drains successful ${commandName} output/result before old cleanup and successor, then rebuilds postmutation state`, async () => { + const fixture: SuccessfulMutationFixture = await SuccessfulMutationFixture.createAsync(); + const postInstall: MutationGate = await MutationGate.createAsync(); + const disposal: MutationGate = await MutationGate.createAsync(); + let request: Promise | undefined; + try { + expect(await fixture.runWorkerAsync('update')).toMatchObject({ exitCode: 0 }); + const lockfile: string = path.join(fixture.repoRoot, 'common/config/rush/pnpm-lock.yaml'); + const previousLockfile: string = fs.readFileSync(lockfile, 'utf8'); + await fixture.startAsync(); + const initialPid: number = fixture.initialPid!; + expect(initialPid).not.toBe(process.pid); + expect( + await fixture.requestAsync(['build', '--to', '@mutation/app', '--parallelism', '3']) + ).toMatchObject({ exitCode: 0, outcome: { kind: 'result', result: { aborted: false } } }); + expect(fixture.readAppOutput()).toBe('provider-one-installed:before'); + + if (commandName === 'update') fixture.selectSecondProvider(); + else fixture.removeInstalledAppDependency(); + const provider: string = commandName === 'update' ? 'provider-two' : 'provider-one'; + const dependency: string = path.join( + fixture.repoRoot, + `projects/app/node_modules/@mutation/${provider}/package.json` + ); + expect(fs.existsSync(dependency)).toBe(false); + fs.writeFileSync( + path.join(fixture.controlFolder, 'mutation.json'), + JSON.stringify({ + commandName, + postInstallPort: postInstall.port, + disposalPort: disposal.port + }) + ); + + let resultReceived: boolean = false; + request = fixture.requestAsync([commandName, '--bypass-policy', '--offline']).then((result) => { + resultReceived = true; + fs.appendFileSync(path.join(fixture.controlFolder, 'events.txt'), 'result-received\n'); + return result; + }); + await Promise.race([ + postInstall.entered, + request.then((result) => { + throw new Error( + `Mutation ended before its successful post-install hook: ${JSON.stringify(result)}` + ); + }) + ]); + // The real package manager has finished, but the native command is still inside its final hook. + expect(fs.existsSync(dependency)).toBe(true); + expect(resultReceived).toBe(false); + expect(readDaemonLockfile(fixture.paths!.lockfilePath)?.pid).toBe(initialPid); + expect(fs.readFileSync(path.join(fixture.controlFolder, 'events.txt'), 'utf8')).toBe( + `postinstall:${commandName}\n` + ); + await postInstall[Symbol.asyncDispose](); + + const result: IMutationRequestOutput = await request; + expect(result).toMatchObject({ + exitCode: 0, + outcome: { kind: 'result', result: { exitCode: 0, outcome: 'success', aborted: false } } + }); + expect(result.outcome).not.toHaveProperty('result.retryAfterRestart'); + expect(result.stdout).toContain(`FINAL_POST_INSTALL:${commandName}`); + expect(result.stdout).toContain(`Found pnpm version ${fixture.pnpmVersion}`); + await disposal.entered; + expect(readDaemonLockfile(fixture.paths!.lockfilePath)?.pid).toBe(initialPid); + expect(fs.readFileSync(path.join(fixture.controlFolder, 'events.txt'), 'utf8')).toBe( + `postinstall:${commandName}\nresult-received\n` + ); + await disposal[Symbol.asyncDispose](); + + const successor: IDaemonLockfile = await fixture.waitForSuccessorAsync(); + expect(successor.pid).not.toBe(initialPid); + expect(successor.pid).not.toBe(process.pid); + const ready: DaemonClient = await DaemonClient.connectAsync({ + socketPath: fixture.paths!.socketPath + }); + try { + expect((await ready.status).pid).toBe(successor.pid); + } finally { + await ready.closeAsync(); + } + expect(await fixture.waitForInitialExitAsync()).toMatchObject({ exitCode: 0 }); + expect(fs.readFileSync(path.join(fixture.controlFolder, 'events.txt'), 'utf8')).toBe( + `postinstall:${commandName}\nresult-received\nold-resources-disposed\nsuccessor-process-started\n` + ); + expect( + await fixture.requestAsync(['rebuild', '--to', '@mutation/app', '--parallelism', '3']) + ).toMatchObject({ exitCode: 0 }); + expect(fixture.readAppOutput()).toBe(`${provider}-installed:after-${commandName}`); + const currentLockfile: string = fs.readFileSync(lockfile, 'utf8'); + if (commandName === 'update') expect(currentLockfile).not.toBe(previousLockfile); + else expect(currentLockfile).toBe(previousLockfile); + // Starting a successor or rebuilding must not replay the administrative action. + expect(fs.readFileSync(path.join(fixture.controlFolder, 'events.txt'), 'utf8')).toBe( + `postinstall:${commandName}\nresult-received\nold-resources-disposed\nsuccessor-process-started\n` + ); + } finally { + fs.rmSync(path.join(fixture.controlFolder, 'mutation.json'), { force: true }); + await postInstall[Symbol.asyncDispose](); + await disposal[Symbol.asyncDispose](); + try { + await request; + } finally { + await fixture[Symbol.asyncDispose](); + } + } + }); + } +}); diff --git a/libraries/rush-daemon/src/test/TestDaemonListener.ts b/libraries/rush-daemon/src/test/TestDaemonListener.ts new file mode 100644 index 0000000000..becc4a2a33 --- /dev/null +++ b/libraries/rush-daemon/src/test/TestDaemonListener.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonFrameListener } from '@rushstack/rush-daemon-transport'; + +/** Allows tests with injected cleanup failures to release their listener after joining real resources. */ +export async function captureTestDaemonListenerAsync( + createAsync: () => Promise +): Promise<{ value: T; listener: DaemonFrameListener }> { + const listenAsync: typeof DaemonFrameListener.listenAsync = DaemonFrameListener.listenAsync; + let listener: DaemonFrameListener | undefined; + const capture = jest.spyOn(DaemonFrameListener, 'listenAsync').mockImplementation(async (...args) => { + listener = await listenAsync(...args); + return listener; + }); + try { + const value: T = await createAsync(); + if (!listener) throw new Error('The test did not create a daemon listener.'); + return { value, listener }; + } finally { + capture.mockRestore(); + } +} diff --git a/libraries/rush-daemon/src/test/TestProcessExit.test.ts b/libraries/rush-daemon/src/test/TestProcessExit.test.ts new file mode 100644 index 0000000000..18790b7ac3 --- /dev/null +++ b/libraries/rush-daemon/src/test/TestProcessExit.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import fs from 'node:fs'; + +import { + captureTestProcessIdentity, + isTestProcessRunning, + waitForTestProcessExitAsync +} from './TestProcessExit'; + +describe('owned fixture process exit inspection', () => { + const pid: number = process.pid + 1; + const platformDescriptor: PropertyDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + let state: string; + let startTime: string; + let fdSize: string; + + beforeEach(() => { + state = 'S'; + startTime = '100'; + fdSize = '64'; + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + jest.spyOn(process, 'kill').mockReturnValue(true); + jest.spyOn(fs, 'readFileSync').mockImplementation((filename) => { + if (String(filename) === `/proc/${pid}/status`) return `State:\t${state}\nFDSize:\t${fdSize}\n`; + const fields: string[] = Array.from({ length: 20 }, () => '0'); + fields[0] = state; + fields[19] = startTime; + return `${pid} (owned writer) ${fields.join(' ')}\n`; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', platformDescriptor); + }); + + it('captures start time and rejects a still-executable writer even when output has closed', async () => { + const identity = captureTestProcessIdentity(pid); + expect(identity).toEqual({ pid, linuxStartTime: '100' }); + expect(isTestProcessRunning(identity)).toBe(true); + await expect(waitForTestProcessExitAsync(identity, 0)).rejects.toThrow('did not exit'); + }); + + it('accepts the same unreaped zombie only after its descriptor table is gone', async () => { + const identity = captureTestProcessIdentity(pid); + state = 'Z'; + expect(isTestProcessRunning(identity)).toBe(true); + fdSize = '0'; + expect(process.kill(pid, 0)).toBe(true); + await waitForTestProcessExitAsync(identity, 0); + }); + + it('does not wait for or signal a new process that reused the captured PID', async () => { + const identity = captureTestProcessIdentity(pid); + startTime = '200'; + expect(isTestProcessRunning(identity)).toBe(false); + await waitForTestProcessExitAsync(identity, 0); + expect(jest.mocked(process.kill).mock.calls.every(([, signal]) => signal === 0)).toBe(true); + }); + + it('fails closed on malformed process or descriptor information', () => { + const identity = captureTestProcessIdentity(pid); + startTime = 'invalid'; + expect(() => isTestProcessRunning(identity)).toThrow('Cannot inspect'); + startTime = '100'; + state = 'Z'; + fdSize = 'invalid'; + expect(() => isTestProcessRunning(identity)).toThrow('descriptors'); + }); + + it('rejects an invalid caller budget without extending the default', async () => { + await expect(waitForTestProcessExitAsync(pid, -1)).rejects.toThrow('nonnegative'); + await expect(waitForTestProcessExitAsync(pid, Infinity)).rejects.toThrow('nonnegative'); + }); +}); diff --git a/libraries/rush-daemon/src/test/TestProcessExit.ts b/libraries/rush-daemon/src/test/TestProcessExit.ts new file mode 100644 index 0000000000..2609f32589 --- /dev/null +++ b/libraries/rush-daemon/src/test/TestProcessExit.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +/** Call after resource joins. Windows sharing retries wait at most 1.5 seconds in total; other errors fail. */ +export async function removeTestFolderAsync(folder: string, force: boolean = false): Promise { + for (let attempt: number = 0; ; attempt++) { + try { + await fs.promises.rm(folder, { recursive: true, force }); + return; + } catch (error) { + if ( + process.platform !== 'win32' || + attempt >= 5 || + typeof error !== 'object' || + error === null || + !('code' in error) || + (error.code !== 'EPERM' && error.code !== 'EBUSY' && error.code !== 'ENOTEMPTY') + ) + throw error; + } + await delayAsync((attempt + 1) * 100); + } +} + +export interface ITestProcessIdentity { + readonly pid: number; + readonly linuxStartTime?: string; +} + +/** Captures kernel identity before an owned Linux process can exit and its PID can be reused. */ +export function captureTestProcessIdentity(pid: number): ITestProcessIdentity { + validatePid(pid); + return Object.freeze({ + pid, + linuxStartTime: process.platform === 'linux' ? readLinuxStat(pid).startTime : undefined + }); +} + +/** Only for captured fixture processes, never for daemon ownership reclamation. */ +export async function waitForTestProcessExitAsync( + processIdentity: number | ITestProcessIdentity, + timeoutMs: number = 5000 +): Promise { + const pid: number = typeof processIdentity === 'number' ? processIdentity : processIdentity.pid; + validatePid(pid); + if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 0x7fffffff) { + throw new RangeError('Expected a bounded nonnegative fixture exit timeout.'); + } + const deadline: number = Date.now() + timeoutMs; + while (isTestProcessRunning(processIdentity)) { + const remaining: number = deadline - Date.now(); + if (remaining <= 0) throw new Error(`Fixture process ${pid} did not exit before cleanup.`); + await delayAsync(Math.min(10, remaining)); + } +} + +function validatePid(pid: number): void { + if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) { + throw new Error(`Invalid fixture process PID: ${pid}`); + } +} + +/** A replaced PID or a zombie with no descriptor table no longer owns the fixture's executable work. */ +export function isTestProcessRunning(processIdentity: number | ITestProcessIdentity): boolean { + const identity: ITestProcessIdentity = + typeof processIdentity === 'number' ? { pid: processIdentity } : processIdentity; + const { pid, linuxStartTime } = identity; + validatePid(pid); + try { + process.kill(pid, 0); + if (process.platform === 'linux') { + const stat = readLinuxStat(pid); + if (linuxStartTime !== undefined && stat.startTime !== linuxStartTime) return false; + if (stat.state !== 'Z') return true; + const status: string = fs.readFileSync(`/proc/${pid}/status`, 'utf8'); + const fdSize: RegExpExecArray | null = /^FDSize:\s*(\d+)\s*$/m.exec(status); + if (!fdSize) throw new Error(`Cannot inspect fixture process ${pid} descriptors.`); + return fdSize[1] !== '0'; + } + return true; + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error.code === 'ESRCH' || (process.platform === 'linux' && error.code === 'ENOENT')) + ) + return false; + throw error; + } +} + +function readLinuxStat(pid: number): { state: string; startTime: string } { + const stat: string = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const separator: number = stat.lastIndexOf(') '); + const fields: string[] = stat + .slice(separator + 2) + .trim() + .split(/\s+/); + if (separator < 0 || !/^\d+$/.test(fields[19] ?? '')) { + throw new Error(`Cannot inspect fixture process ${pid}.`); + } + return { state: fields[0], startTime: fields[19] }; +} diff --git a/libraries/rush-daemon/src/test/VersionSelectedDaemonLauncher.test.ts b/libraries/rush-daemon/src/test/VersionSelectedDaemonLauncher.test.ts new file mode 100644 index 0000000000..5c298f238f --- /dev/null +++ b/libraries/rush-daemon/src/test/VersionSelectedDaemonLauncher.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { Utilities } from '@microsoft/rush-lib/lib/utilities/Utilities'; +import { + connectOrStartDaemonAsync, + requestDaemonShutdownAsync, + type DaemonClient +} from '@rushstack/rush-client-core'; +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import { + computeDaemonWorkspaceKey, + resolveDaemonPaths, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import { + selectDaemonLauncherAsync, + getSelectedDaemonStartCommand, + DaemonLauncherUnavailableError, + type IDaemonLauncherContext +} from '../VersionSelectedDaemonLauncher'; +import { removeTestFolderAsync, waitForTestProcessExitAsync } from './TestProcessExit'; + +describe('version-selected daemon launcher', () => { + let repoRoot: string; + let context: IDaemonLauncherContext; + let preserveFixture: boolean; + + beforeEach(() => { + preserveFixture = false; + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-selection-')); + fs.mkdirSync(path.join(repoRoot, 'runtime')); + fs.writeFileSync( + path.join(repoRoot, 'rush.json'), + JSON.stringify({ + rushVersion: Rush.version, + pnpmVersion: '10.27.0', + projects: [] + }) + ); + context = { + repoRoot, + rushVersion: Rush.version, + environment: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !/^npm_config_(cache|registry|userconfig)$/i.test(name) + ) + ), + RUSH_GLOBAL_FOLDER: path.join(repoRoot, 'global'), + RUSH_PREVIEW_VERSION: undefined, + NPM_CONFIG_CACHE: path.join(repoRoot, 'npm-cache'), + XDG_RUNTIME_DIR: path.join(repoRoot, 'runtime'), + TMPDIR: path.join(repoRoot, 'runtime'), + TMP: path.join(repoRoot, 'runtime'), + TEMP: path.join(repoRoot, 'runtime') + } + }; + }); + + afterEach(async () => { + if (!preserveFixture) await removeTestFolderAsync(repoRoot); + }); + + async function stopDaemonAsync(client: DaemonClient, paths: IDaemonPaths): Promise { + try { + const owner = await requestDaemonShutdownAsync(client, paths).finally(() => client.closeAsync()); + await waitForTestProcessExitAsync(owner.pid); + } catch (error) { + preserveFixture = true; + throw error; + } + } + + it('attests the actual bundled engine and protocol without changing the caller environment', async () => { + const originalRushLibPath: string | undefined = process.env._RUSH_LIB_PATH; + const selected = await selectDaemonLauncherAsync(context, { allowInstall: false }); + expect(selected.rushVersion).toBe(Rush.version); + expect(selected.protocolVersion).toEqual(DAEMON_PROTOCOL_VERSION); + expect(fs.realpathSync.native(selected.rushLibEntryPoint)).toBe( + fs.realpathSync.native(require.resolve('@microsoft/rush-lib')) + ); + expect(selected.startCommand.command).toBe(process.execPath); + expect(process.env._RUSH_LIB_PATH).toBe(originalRushLibPath); + }); + + it('never relabels the bundled engine as a different requested version', async () => { + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: '5.178.1' })); + await expect( + selectDaemonLauncherAsync( + { + ...context, + rushVersion: '5.178.1' + }, + { allowInstall: false } + ) + ).rejects.toThrow('Cannot launch selected Rush 5.178.1'); + }); + + it('requires the requested version to match rush.json before installing anything', async () => { + await expect( + selectDaemonLauncherAsync({ + ...context, + rushVersion: '5.178.1' + }) + ).rejects.toThrow('does not match rush.json'); + expect(fs.existsSync(path.join(repoRoot, 'global'))).toBe(false); + }); + + it('honors an explicit native preview selection without rewriting repository or package metadata', async () => { + const rushJson: string = JSON.stringify({ rushVersion: '5.178.1' }); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), rushJson); + const selected = await selectDaemonLauncherAsync( + { + ...context, + environment: { ...context.environment, RUSH_PREVIEW_VERSION: Rush.version } + }, + { allowInstall: false } + ); + expect(selected.rushVersion).toBe(Rush.version); + expect(fs.readFileSync(path.join(repoRoot, 'rush.json'), 'utf8')).toBe(rushJson); + }); + + it.each([false, true])( + 'starts the attested engine through its real default APIs (preview: %s)', + async (preview) => { + const startupContext: IDaemonLauncherContext = preview + ? { + ...context, + environment: { ...context.environment, RUSH_PREVIEW_VERSION: Rush.version } + } + : context; + if (preview) { + fs.writeFileSync( + path.join(repoRoot, 'rush.json'), + JSON.stringify({ + rushVersion: '5.178.1', + pnpmVersion: '10.27.0', + projects: [] + }) + ); + } + const selected = await selectDaemonLauncherAsync(startupContext, { allowInstall: false }); + const paths: IDaemonPaths = resolveDaemonPaths( + { + platform: process.platform, + env: context.environment, + tmpdir: path.join(repoRoot, 'runtime'), + uid: process.getuid?.() + }, + computeDaemonWorkspaceKey({ canonicalRepoRoot: fs.realpathSync.native(repoRoot), rushVersion: Rush.version }) + ); + const client = await connectOrStartDaemonAsync({ + paths, + expectedDaemonVersion: selected.daemonVersion, + startCommand: selected.startCommand + }).catch((cause: unknown) => { + preserveFixture = true; + const logPath: string = `${paths.lockfilePath}.log`; + const log: string = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '(log not created)'; + throw new Error(`Selected daemon startup failed. Launcher log (${logPath}):\n${log}`, { cause }); + }); + try { + expect(await client.status).toMatchObject({ daemonVersion: selected.daemonVersion }); + expect(fs.readFileSync(`${paths.lockfilePath}.log`, 'utf8')).toContain(`Rush ${Rush.version}`); + } finally { + await stopDaemonAsync(client, paths); + } + }, + 30000 + ); + + it('refuses a wrong requested engine at startup without spoofing installed metadata', async () => { + const selected = await selectDaemonLauncherAsync(context, { allowInstall: false }); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: '5.178.1' })); + const command = getSelectedDaemonStartCommand(selected.daemonPackageJsonPath, { + ...context, + rushVersion: '5.178.1' + }); + const result = await Utilities.executeCommandAndCaptureOutputAsync({ + command: command.command, + args: [...command.args], + workingDirectory: repoRoot, + environment: { ...command.environment }, + captureExitCodeAndSignal: true + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`actual engine is ${Rush.version}`); + expect(result.stdout).not.toContain('rushd ready'); + }, 15000); + + it('rechecks the real engine before binding and refuses a changed repository selection', async () => { + const selected = await selectDaemonLauncherAsync(context, { allowInstall: false }); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: '5.178.1' })); + const result = await Utilities.executeCommandAndCaptureOutputAsync({ + command: selected.startCommand.command, + args: [...selected.startCommand.args], + workingDirectory: repoRoot, + environment: { ...selected.startCommand.environment }, + captureExitCodeAndSignal: true + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('does not match rush.json'); + expect(result.stdout).not.toContain('rushd ready'); + }, 15000); + + (process.env.RUSHD_VERSION_SELECTION_REGISTRY_TEST === '1' ? it : it.skip)( + 'installs the real published 5.178.1 engine and reports its actual daemon compatibility gate', + async () => { + fs.mkdirSync(path.join(repoRoot, 'common/config/rush'), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, 'common/config/rush/.npmrc'), + 'registry=https://registry.npmjs.org\naudit=false\nfund=false\n' + ); + const npmrc: string = path.join(repoRoot, 'user.npmrc'); + fs.writeFileSync(npmrc, ''); + fs.writeFileSync(path.join(repoRoot, 'rush.json'), JSON.stringify({ rushVersion: '5.178.1' })); + const requested: IDaemonLauncherContext = { + ...context, + rushVersion: '5.178.1', + environment: { + ...context.environment, + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org', + NPM_CONFIG_USERCONFIG: npmrc + } + }; + let failure: unknown; + try { + await selectDaemonLauncherAsync(requested); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(DaemonLauncherUnavailableError); + if (!(failure instanceof DaemonLauncherUnavailableError)) + throw new Error('Expected compatibility refusal.'); + expect(failure.installation).toMatchObject({ daemonVersion: '0.4.1', rushVersion: '5.178.1' }); + expect(failure.installation!.rushVersion).not.toBe(Rush.version); + expect(failure.installation!.protocolVersion.minor).toBeLessThan(DAEMON_PROTOCOL_VERSION.minor); + expect(failure.installation!.rushLibEntryPoint.startsWith(path.join(repoRoot, 'global'))).toBe(true); + await expect(selectDaemonLauncherAsync(requested, { allowInstall: false })).rejects.toMatchObject({ + installation: { rushVersion: '5.178.1', daemonVersion: '0.4.1' } + }); + }, + 240000 + ); + + it.each(['latest', '../escape', '^5.178.1'])('rejects non-exact Rush specifier %s', async (rushVersion) => { + await expect(selectDaemonLauncherAsync({ ...context, rushVersion })).rejects.toThrow('exact'); + }); +}); diff --git a/libraries/rush-daemon/src/test/WarmGenerationTestUtilities.ts b/libraries/rush-daemon/src/test/WarmGenerationTestUtilities.ts new file mode 100644 index 0000000000..df8609b0b6 --- /dev/null +++ b/libraries/rush-daemon/src/test/WarmGenerationTestUtilities.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { PhasedCommandEngine, type IPhasedCommandEngine, type RushConfiguration } from '@microsoft/rush-lib'; +import type { IDaemonPongMessage } from '@rushstack/rush-daemon-protocol'; +import { NoOpTerminalProvider } from '@rushstack/terminal'; + +import { WorkspaceWarmSet, type WorkspaceWarmSetConfiguration } from '../WorkspaceWarmSet'; +import { WorkspaceSession, type IWorkspaceSessionOptions } from '../WorkspaceSession'; +import { WorkspaceSessionFileWatcher } from '../WorkspaceSessionFileWatcher'; +import type { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; + +export const GENERATION_POLICY: WorkspaceWarmSetConfiguration = { + watch: true, + warmIdleTimeoutSeconds: 300, + warmMemoryBudgetMB: 100_000, + warmSetMaxProjects: 20, + autoWarmByTelemetry: false +}; + +export function setDaemonPolicy( + fixture: DaemonGraphTestFixture, + configuration: WorkspaceWarmSetConfiguration +): void { + const json: Record = JSON.parse( + fs.readFileSync(path.join(fixture.folder, 'rush.json'), 'utf8') + ); + fixture.write('rush.json', JSON.stringify({ ...json, daemon: { ...GENERATION_POLICY, ...configuration } })); +} + +export function getWarmSet(fixture: DaemonGraphTestFixture): WorkspaceWarmSet { + const graph = fixture.session.operationGraph; + const warm: WorkspaceWarmSet | undefined = graph && WorkspaceWarmSet.getAttached(graph); + if (!warm) throw new Error('Expected the session-owned controller on the real native graph.'); + return warm; +} + +export async function pongAsync(fixture: DaemonGraphTestFixture): Promise { + const client = await fixture.connectAsync(); + try { + await client.sendControlAsync({ kind: 'ping', payload: {} }); + const message = await client.readControlAsync(); + if (message.kind !== 'pong') throw new Error('Expected a real daemon pong.'); + return message.payload; + } finally { + await client.closeAsync(); + } +} + +export async function createNativeEngineAsync( + configuration: RushConfiguration +): Promise { + const command: PhasedCommandEngine = await PhasedCommandEngine.parseAsync({ + argv: ['build', '--parallelism', '3'], + cwd: configuration.rushJsonFolder, + rushConfiguration: configuration, + terminalProvider: new NoOpTerminalProvider() + }); + return await command.createEngineAsync(); +} + +export async function createObservedSessionAsync( + options: IWorkspaceSessionOptions +): Promise<{ session: WorkspaceSession; watcher: WorkspaceSessionFileWatcher }> { + let watcher: WorkspaceSessionFileWatcher | undefined; + const start = WorkspaceSessionFileWatcher.prototype.startAsync; + const capture = jest + .spyOn(WorkspaceSessionFileWatcher.prototype, 'startAsync') + .mockImplementation(function ( + this: WorkspaceSessionFileWatcher, + onInvalidation: (changedPath?: string) => void + ): Promise { + watcher = this; + return start.call(this, onInvalidation); + }); + try { + const session: WorkspaceSession = await WorkspaceSession.createAsync(options); + if (!watcher) throw new Error('Expected the real workspace watcher.'); + return { session, watcher }; + } finally { + capture.mockRestore(); + } +} + +export async function eventuallyAsync(assertion: () => void): Promise { + const deadline: number = performance.now() + 5000; + for (;;) { + try { + assertion(); + return; + } catch (error) { + if (performance.now() >= deadline) throw error; + await delayAsync(10); + } + } +} diff --git a/libraries/rush-daemon/src/test/WarmSetRanking.test.ts b/libraries/rush-daemon/src/test/WarmSetRanking.test.ts new file mode 100644 index 0000000000..b400bd1770 --- /dev/null +++ b/libraries/rush-daemon/src/test/WarmSetRanking.test.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { compareWarmSetRanks, getWarmSetScore, type IWarmSetRank } from '../WarmSetRanking'; + +function entry(key: string, overrides: Partial = {}): IWarmSetRank { + return { key, lastUsed: 0, frequency: 2, timeSavedMs: 100, residentMemoryBytes: 50, ...overrides }; +} + +describe('warm retention and eviction ranking', () => { + it('uses the exact timeSaved * frequency / residentMemory score', () => { + expect(getWarmSetScore(entry('a'))).toBe(4); + expect(getWarmSetScore(entry('a', { timeSavedMs: 0 }))).toBe(0); + expect(getWarmSetScore(entry('a', { frequency: 3, residentMemoryBytes: 20 }))).toBe(15); + }); + + it.each([ + { timeSavedMs: undefined }, + { residentMemoryBytes: undefined }, + { frequency: 0 }, + { residentMemoryBytes: 0 }, + { timeSavedMs: -1 }, + { timeSavedMs: NaN }, + { residentMemoryBytes: Infinity }, + { frequency: Infinity } + ])('does not synthesize a score from missing or invalid telemetry: %p', (overrides) => { + expect(getWarmSetScore(entry('a', overrides))).toBeUndefined(); + }); + + it('uses score, recency, then ordinal key ties; disabling telemetry is pure LRU', () => { + const entries: IWarmSetRank[] = [ + entry('z', { lastUsed: 2 }), + entry('A', { lastUsed: 2 }), + entry('a', { lastUsed: 2 }), + entry('valuable', { timeSavedMs: 200 }), + entry('missing-new', { lastUsed: 100, timeSavedMs: undefined }), + entry('missing-old', { lastUsed: 1, residentMemoryBytes: undefined }) + ]; + const rank = (telemetry: boolean): string[] => + [...entries].sort((a, b) => compareWarmSetRanks(a, b, telemetry)).map((item) => item.key); + expect(rank(true)).toEqual(['valuable', 'A', 'a', 'z', 'missing-new', 'missing-old']); + expect(rank(false)).toEqual(['missing-new', 'A', 'a', 'z', 'missing-old', 'valuable']); + expect(compareWarmSetRanks(entries[0], entries[0], true)).toBe(0); + expect( + [...entries] + .reverse() + .sort((a, b) => compareWarmSetRanks(a, b, true)) + .map((item) => item.key) + ).toEqual(rank(true)); + }); + + it('has a transitive missing-data order rather than a pair-dependent score/LRU comparison', () => { + const entries = [ + entry('high-old', { timeSavedMs: 400, lastUsed: 1 }), + entry('missing-middle', { residentMemoryBytes: undefined, lastUsed: 2 }), + entry('low-new', { timeSavedMs: 1, lastUsed: 3 }) + ]; + for (const a of entries) + for (const b of entries) + for (const c of entries) { + if (compareWarmSetRanks(a, b, true) <= 0 && compareWarmSetRanks(b, c, true) <= 0) { + expect(compareWarmSetRanks(a, c, true)).toBeLessThanOrEqual(0); + } + } + }); +}); diff --git a/libraries/rush-daemon/src/test/WarmSetTestFixture.ts b/libraries/rush-daemon/src/test/WarmSetTestFixture.ts new file mode 100644 index 0000000000..1f1fa09560 --- /dev/null +++ b/libraries/rush-daemon/src/test/WarmSetTestFixture.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { + RushUserConfiguration, + type IOperationGraph, + type IOperationRunner, + type Operation, + type RushConfigurationProject +} from '@microsoft/rush-lib'; +import type { IIPCOperationRunnerOptions } from '@microsoft/rush-lib/lib/logic/operations/IPCOperationRunner'; + +import { WorkspaceSessionFileWatcher } from '../WorkspaceSessionFileWatcher'; +import { WorkspaceWarmSet, type WorkspaceWarmSetConfiguration } from '../WorkspaceWarmSet'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; + +// The runtime class is identical; deep .d.ts imports and the public rollup have separate private type identities. +const { + IPCOperationRunner +}: { + IPCOperationRunner: new ( + options: Omit & { project: RushConfigurationProject } + ) => IOperationRunner; +} = require('@microsoft/rush-lib/lib/logic/operations/IPCOperationRunner'); + +export interface IWarmFixtureOptions { + readonly ipc?: boolean; + readonly cache?: boolean; + readonly configurationKind?: 'direct' | 'rig' | 'inherited'; +} + +export const GENEROUS_WARM_CONFIGURATION: WorkspaceWarmSetConfiguration = { + watch: true, + warmIdleTimeoutSeconds: 300, + warmMemoryBudgetMB: 100_000, + warmSetMaxProjects: 20, + autoWarmByTelemetry: false +}; + +export function captureWarmRankingDurations(graph: IOperationGraph): ReadonlyMap { + return new Map([...graph.resultByOperation].map(([operation, result]) => [ + operation, result.stopwatch.duration + ])); +} + +/** Independent expected scores for the one-operation-per-project native fixtures. */ +export function getMeasuredFixtureRetentionOrder( + graph: IOperationGraph, + coldDurations: ReadonlyMap, + requestedProjects: ReadonlyArray +): string[] { + const measuredRanks = [...coldDurations].map(([operation, coldDuration]) => { + const name: string = operation.associatedProject.packageName; + const memory: number | undefined = operation.runner?.residentMemoryBytes; + const result = graph.resultByOperation.get(operation); + const frequency: number = requestedProjects.filter((project) => project === name).length; + if (!result || memory === undefined || !Number.isFinite(memory) || memory <= 0 || frequency === 0) { + throw new Error(`Expected native timing, request history, and IPC RSS for ${name}.`); + } + const savedMs: number = Math.max(0, (coldDuration - result.stopwatch.duration) * 1000); + return { name, score: savedMs * frequency / memory, lastUsed: requestedProjects.lastIndexOf(name) }; + }); + measuredRanks.sort((a, b) => b.score - a.score || b.lastUsed - a.lastUsed || + (a.name === b.name ? 0 : a.name < b.name ? -1 : 1)); + return measuredRanks.map(({ name }) => name); +} + +/** Attaches the production controller at the same generation-owned component boundary used by the host. */ +export class WarmSetTestFixture implements AsyncDisposable { + public fixture!: DaemonGraphTestFixture; + public watcher!: WorkspaceSessionFileWatcher; + public warm!: WorkspaceWarmSet; + public readonly diagnostics: Error[] = []; + public readonly protectedOperations: Set = new Set(); + public configuration: WorkspaceWarmSetConfiguration = GENEROUS_WARM_CONFIGURATION; + private _initializationSpy: jest.SpyInstance | undefined; + private _cacheFolder: string | undefined; + private readonly _options: IWarmFixtureOptions; + + private constructor(options: IWarmFixtureOptions) { + this._options = options; + } + + public static async createAsync(options: IWarmFixtureOptions = {}): Promise { + const result: WarmSetTestFixture = new WarmSetTestFixture(options); + const userConfiguration: RushUserConfiguration = await RushUserConfiguration.initializeAsync(); + await result._captureWatcherAsync(async () => { + result.fixture = await DaemonGraphTestFixture.createAsync((fixture) => { + fixture.write( + '.gitignore', + 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\n**/node_modules/\nruns.txt\n' + ); + if (options.cache) { + const namespace: string = path.basename(fixture.folder); + fixture.write( + 'common/config/rush/build-cache.json', + JSON.stringify({ + buildCacheEnabled: true, + cacheProvider: 'local-only', + cacheEntryNamePattern: `${namespace}/[hash]` + }) + ); + result._cacheFolder = path.join( + userConfiguration.buildCacheFolder ?? path.join(fixture.folder, 'common/temp/build-cache'), + namespace + ); + } + for (const name of ['a', 'b', 'c']) { + fixture.write( + `${name}/config/rush-project.json`, + JSON.stringify({ + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }) + ); + fixture.write(`${name}/build.cjs`, createScript(name, options.ipc ?? false)); + } + if (options.configurationKind === 'rig') { + fs.unlinkSync(path.join(fixture.folder, 'a/config/rush-project.json')); + fixture.write('a/config/rig.json', '{"rigPackageName":"fixture-rig"}'); + fixture.write( + 'a/node_modules/fixture-rig/package.json', + '{"name":"fixture-rig","version":"1.0.0"}' + ); + fixture.write( + 'a/node_modules/fixture-rig/profiles/default/config/rush-project.json', + '{"operationSettings":[{"operationName":"_phase:compile","outputFolderNames":["lib"]}]}' + ); + } else if (options.configurationKind === 'inherited') { + fixture.write( + 'common/temp/inherited.json', + '{"operationSettings":[{"operationName":"_phase:compile","outputFolderNames":["lib"]}]}' + ); + fixture.write('a/config/rush-project.json', '{"extends":"../../common/temp/inherited.json"}'); + } + }, false); + }); + result._attachOnInitialization(); + return result; + } + + public get graph(): IOperationGraph { + return this.fixture.session.operationGraph!; + } + + public operation(name: string): Operation { + const operation: Operation | undefined = [...this.graph.operations].find( + (candidate) => candidate.associatedProject.packageName === name + ); + if (!operation) throw new Error(`Unknown fixture operation: ${name}`); + return operation; + } + + public update(configuration: WorkspaceWarmSetConfiguration): void { + this.configuration = { ...GENEROUS_WARM_CONFIGURATION, ...configuration }; + this.warm.updateConfiguration(this.configuration); + } + + public async restartAsync(): Promise { + await this.warm[Symbol.asyncDispose](); + this._initializationSpy?.mockRestore(); + await this._captureWatcherAsync(() => this.fixture.restartAsync()); + this._attachOnInitialization(); + } + + public async [Symbol.asyncDispose](): Promise { + await this.warm?.[Symbol.asyncDispose](); + this._initializationSpy?.mockRestore(); + try { + await this.fixture?.[Symbol.asyncDispose](); + } finally { + if (this._cacheFolder) fs.rmSync(this._cacheFolder, { recursive: true, force: true }); + } + } + + private async _captureWatcherAsync(createAsync: () => Promise): Promise { + const owner: WarmSetTestFixture = this; + const original = WorkspaceSessionFileWatcher.prototype.startAsync; + const capture = jest + .spyOn(WorkspaceSessionFileWatcher.prototype, 'startAsync') + .mockImplementation(function ( + this: WorkspaceSessionFileWatcher, + onInvalidation: (changedPath?: string) => void + ): Promise { + owner.watcher = this; + return original.call(this, onInvalidation); + }); + try { + await createAsync(); + } finally { + capture.mockRestore(); + } + } + + private _attachOnInitialization(): void { + const { session } = this.fixture; + const initialize = session.initializeEngineAsync.bind(session); + this._initializationSpy = jest.spyOn(session, 'initializeEngineAsync').mockImplementation((factory) => + initialize(async (options) => { + const components = await factory(options); + const graph: IOperationGraph = components.operationGraph!; + if (this._options.ipc) { + useNativeIpcRunners(graph); + } + this.warm = WorkspaceWarmSet.attach({ + operationGraph: graph, + configuration: this.configuration, + scheduler: getWorkspaceRequestScheduler(session), + acquireExecutionLeaseAsync: () => session.acquireExecutionLeaseAsync(), + watcher: this.watcher, + getProtectedOperations: () => this.protectedOperations, + onDiagnostic: (error) => this.diagnostics.push(error) + }); + return components; + }) + ); + } +} + +/** Test engines may supply real IPC runners; the production non-watch resolver still selects shells. */ +export function useNativeIpcRunners(graph: IOperationGraph): void { + for (const operation of graph.operations) { + operation.runner = new IPCOperationRunner({ + name: operation.name, + phase: operation.associatedPhase, + project: operation.associatedProject, + initialCommand: 'node build.cjs', + incrementalCommand: 'node build.cjs', + commandForHash: 'node build.cjs', + ignoredParameterValues: [], + // Windows cmd.exe cannot forward Node's IPC descriptor to its grandchild (the first send fails EPIPE). + // Keep the real runner and native lifecycle options, but make the known fixture Node script the IPC child. + spawn: (command, args, options) => spawn(process.execPath, ['build.cjs'], { ...options, shell: false }) + }); + } +} + +export function createScript(name: string, ipc: boolean): string { + const operationGraphPath: string = require.resolve('@rushstack/operation-graph', { + paths: [path.dirname(require.resolve('@microsoft/rush-lib/package.json'))] + }); + const execute: string = ` + const fs = require('node:fs'); + const name = ${JSON.stringify(name)}; + let runs = 0; + async function build() { + const gate = '../common/temp/gate-' + name + '.json'; + if (fs.existsSync(gate)) { + const { port } = JSON.parse(fs.readFileSync(gate, 'utf8')); + await new Promise((resolve, reject) => { + const socket = require('node:net').connect(port, '127.0.0.1'); + socket.once('error', reject); + socket.once('data', () => { socket.end(); resolve(); }); + }); + } + if (${ipc} && name === 'a' && runs === 0) await new Promise(resolve => setTimeout(resolve, 500)); + runs++; + const input = fs.readFileSync('input.txt', 'utf8'); + fs.appendFileSync('../runs.txt', name + '\\n'); + fs.mkdirSync('lib', { recursive: true }); + fs.writeFileSync('lib/output.txt', input); + console.log('built-' + name + '-' + input); + if (input === 'warning') console.error('warning-' + name); + return input === 'failure' ? 'FAILURE' : 'SUCCESS'; + } + `; + return ( + execute + + (ipc + ? ` + const { WatchLoop } = require(${JSON.stringify(operationGraphPath)}); + const loop = new WatchLoop({ + executeAsync: build, onBeforeExecute() {}, onRequestRun() {}, onAbort() {} + }); + loop.runIPCAsync().then(async () => { + const delayFile = '../common/temp/close-delay-' + name; + if (fs.existsSync(delayFile)) await new Promise(resolve => setTimeout(resolve, 100)); + fs.writeFileSync('../common/temp/closed-' + name, String(process.pid)); + }).catch(error => { console.error(error); process.exitCode = 1; }) + .finally(() => process.disconnect()); + ` + : ` + build().then(status => { if (status === 'FAILURE') process.exitCode = 7; }) + .catch(error => { console.error(error); process.exitCode = 1; }); + `) + ); +} diff --git a/libraries/rush-daemon/src/test/WorkspaceEngineComponentFactory.test.ts b/libraries/rush-daemon/src/test/WorkspaceEngineComponentFactory.test.ts index 0ed08d6a33..aeeb2c2c71 100644 --- a/libraries/rush-daemon/src/test/WorkspaceEngineComponentFactory.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceEngineComponentFactory.test.ts @@ -96,6 +96,12 @@ class TestOperationGraph implements IOperationGraph { return Promise.resolve(false); } + public discardScheduledIteration(): boolean { + const hadScheduled: boolean = this.hasScheduledIteration; + this.hasScheduledIteration = false; + return hadScheduled; + } + public invalidateOperations(): void {} public removeTerminalDestination(): boolean { diff --git a/libraries/rush-daemon/src/test/WorkspaceLifecycleTestProcess.ts b/libraries/rush-daemon/src/test/WorkspaceLifecycleTestProcess.ts new file mode 100644 index 0000000000..e10813f890 --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceLifecycleTestProcess.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DaemonClient, requestDaemonShutdownAsync } from '@rushstack/rush-client-core'; +import { + readDaemonLockfile, + type IDaemonLockfile, + type IDaemonPaths +} from '@rushstack/rush-daemon-transport'; +import { waitForTestProcessExitAsync } from './TestProcessExit'; + +/** Waits on attested ownership removal, not an arbitrary sleep or mere connection close. */ +export async function stopSuccessorAsync(paths: IDaemonPaths): Promise { + const owner: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); + if (!owner || owner.pid === process.pid) return; + const { pid: ownerPid, startedAt: ownerStartedAt } = owner; + const client: DaemonClient = await DaemonClient.connectAsync({ socketPath: paths.socketPath }); + const removed: Promise = new Promise((resolve, reject) => { + const timeout: NodeJS.Timeout = setTimeout( + () => finish(new Error('Successor did not release ownership.')), + 15_000 + ); + const watcher: fs.FSWatcher = fs.watch( + fs.realpathSync.native(path.dirname(paths.lockfilePath)), + () => inspect() + ); + function finish(error?: Error): void { + clearTimeout(timeout); + watcher.close(); + if (error) reject(error); + else resolve(); + } + function inspect(): void { + const current: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); + if (!current || current.pid !== ownerPid || current.startedAt !== ownerStartedAt) finish(); + } + watcher.once('error', finish); + inspect(); + }); + try { + await requestDaemonShutdownAsync(client, paths); + await removed; + await waitForTestProcessExitAsync(ownerPid); + } finally { + await client.closeAsync(); + } +} diff --git a/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts b/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts new file mode 100644 index 0000000000..28f099e3f8 --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceReloadTierStatus.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WorkspaceInputChangeTier } from '@microsoft/rush-lib'; + +import { getInstalledWorkspaceSuccessorLaunchAsync } from '../WorkspaceProcessRestart'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { pongAsync, setDaemonPolicy } from './WarmGenerationTestUtilities'; +import { stopSuccessorAsync } from './WorkspaceLifecycleTestProcess'; + +jest.setTimeout(30_000); + +it('reports actual lifecycle tiers through both status surfaces without status reads causing work', async () => { + const fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {})); + try { + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse); + expect((await pongAsync(fixture)).workspace).toMatchObject({ + lastReloadTier: WorkspaceInputChangeTier.Reuse, + graphInitialized: false + }); + expect(fixture.runs()).toEqual([]); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reload); + const generation: number = fixture.host.workspaceGeneration; + const graph = fixture.session.operationGraph; + await fixture.graphAsync('status'); + expect((await pongAsync(fixture)).workspace?.lastReloadTier).toBe(WorkspaceInputChangeTier.Reload); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reload); + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(fixture.session.operationGraph).toBe(graph); + expect(fixture.runs()).toEqual(['a', 'b']); + + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect((await pongAsync(fixture)).workspace?.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reuse); + setDaemonPolicy(fixture, { warmSetMaxProjects: 19 }); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect(fixture.host.workspaceGeneration).toBeGreaterThan(generation); + expect((await pongAsync(fixture)).workspace?.lastReloadTier).toBe(WorkspaceInputChangeTier.Reload); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Reload); + } finally { + await fixture[Symbol.asyncDispose](); + } +}); + +it('uses zero when a host has no native workspace lifecycle, without inventing a reload', async () => { + const fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {}), false); + try { + expect((await pongAsync(fixture)).workspace).toMatchObject({ + lastReloadTier: 0, + graphInitialized: false + }); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(0); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + expect((await pongAsync(fixture)).workspace?.lastReloadTier).toBe(0); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(0); + } finally { + await fixture[Symbol.asyncDispose](); + } +}); + +it('retains the requested restart tier on the old host while a real successor starts cold', async () => { + const fixture = await DaemonGraphTestFixture.createAsync((created) => { + setDaemonPolicy(created, {}); + created.getSuccessorLaunchAsync = getInstalledWorkspaceSuccessorLaunchAsync; + }); + try { + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 0 } }); + const before = await pongAsync(fixture); + const result = await fixture.runAsync(['build', '--to', 'b', '--parallelism', '3'], { + environment: { ...fixture.environment, RUSHD_RELOAD_TIER_TEST: 'changed' } + }); + expect(result.terminal).toMatchObject({ + kind: 'requestResult', + payload: { exitCode: 1, retryAfterRestart: true } + }); + const restarted = await fixture.host.restartCompleted; + expect(restarted?.pid).not.toBe(before.pid); + expect(fixture.host.workspaceStatus.lastReloadTier).toBe(WorkspaceInputChangeTier.Restart); + expect(fixture.host.workspaceStatus.graphInitialized).toBe(false); + const successor = await pongAsync(fixture); + expect(successor.pid).toBe(restarted?.pid); + expect(successor.workspace).toMatchObject({ lastReloadTier: 0, graphInitialized: false }); + expect(fixture.runs()).toEqual(['a', 'b']); + } finally { + try { + await fixture.host.closeAsync(); + await fixture.host.restartCompleted; + } finally { + await stopSuccessorAsync(fixture.host.paths); + await fixture[Symbol.asyncDispose](); + } + } +}); diff --git a/libraries/rush-daemon/src/test/WorkspaceSession.test.ts b/libraries/rush-daemon/src/test/WorkspaceSession.test.ts index cf51deccfe..67c42c92d3 100644 --- a/libraries/rush-daemon/src/test/WorkspaceSession.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceSession.test.ts @@ -216,11 +216,14 @@ describe(WorkspaceSession.name, () => { it('rejects reconciliation as soon as disposal starts', async () => { let finishWatcherDisposal: (() => void) | undefined; + let watcherDisposalStarted: () => void = () => undefined; + const watcherDisposal: Promise = new Promise((resolve) => { watcherDisposalStarted = resolve; }); const watcher: IWorkspaceInvalidationWatcher = { startAsync: () => Promise.resolve(), [Symbol.asyncDispose]: () => new Promise((resolve: () => void) => { finishWatcherDisposal = resolve; + watcherDisposalStarted(); }) }; const reconcileInvalidationsAsync: jest.Mock = jest.fn(() => @@ -242,6 +245,7 @@ describe(WorkspaceSession.name, () => { 'workspace session is being disposed' ); expect(reconcileInvalidationsAsync).not.toHaveBeenCalled(); + await watcherDisposal; finishWatcherDisposal?.(); await disposalPromise; }); diff --git a/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts b/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts index 1d8a364dc8..7596e49d0d 100644 --- a/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts @@ -38,10 +38,65 @@ describe(WorkspaceSessionFileWatcher.name, () => { subspace.getSubspaceConfigFolderPath() ); expect(subspaceConfigFolders.length).toBeGreaterThan(0); - expect(watchedPaths).toEqual( - expect.arrayContaining(subspaceConfigFolders) - ); + expect(watchedPaths).toEqual(expect.arrayContaining(subspaceConfigFolders)); + + await watcher[Symbol.asyncDispose](); + }); + + it('keeps permanent observation, awaits project close and reattaches the removed watcher', async () => { + const created: Map = new Map(); + const project = TEST_RUSH_CONFIGURATION.projects[0]; + const watcher: WorkspaceSessionFileWatcher = new WorkspaceSessionFileWatcher({ + rushConfiguration: TEST_RUSH_CONFIGURATION, + projectNames: [project.packageName], + watchFactory: (folder) => { + const instance: TestFsWatcher = new TestFsWatcher(); + const instances: TestFsWatcher[] = created.get(folder) ?? []; + instances.push(instance); + created.set(folder, instances); + return instance as fs.FSWatcher; + } + }); + await watcher.startAsync(() => {}); + const projectWatcher: TestFsWatcher = created.get(project.projectFolder)![0]; + const close = jest.spyOn(projectWatcher, 'close').mockImplementation(() => {}); + const closing: Promise = watcher.unwatchProjectsAsync([project.packageName]); + expect(watcher.watchedProjectNames.has(project.packageName)).toBe(true); + expect(() => watcher.watchProjects([project.packageName])).toThrow('still closing'); + projectWatcher.emit('close'); + await closing; + close.mockRestore(); + expect(watcher.watchedProjectNames.size).toBe(0); + watcher.watchProjects([project.packageName]); + expect(created.get(project.projectFolder)).toHaveLength(2); + expect(created.get(TEST_RUSH_CONFIGURATION.rushJsonFolder)).toHaveLength(1); + expect(created.get(TEST_RUSH_CONFIGURATION.commonRushConfigFolder)).toHaveLength(1); + await watcher[Symbol.asyncDispose](); + }); + it('retains failed watcher accounting, removes temporary listeners, and supports retry', async () => { + const created: Map = new Map(); + const project = TEST_RUSH_CONFIGURATION.projects[0]; + const watcher: WorkspaceSessionFileWatcher = new WorkspaceSessionFileWatcher({ + rushConfiguration: TEST_RUSH_CONFIGURATION, + watchFactory: (folder) => { + const instance: TestFsWatcher = new TestFsWatcher(); + created.set(folder, instance); + return instance as fs.FSWatcher; + } + }); + await watcher.startAsync(() => {}); + const projectWatcher: TestFsWatcher = created.get(project.projectFolder)!; + const listeners: number = projectWatcher.listenerCount('close'); + const close = jest.spyOn(projectWatcher, 'close').mockImplementation(() => { + throw new Error('close-failed'); + }); + await expect(watcher.unwatchProjectsAsync([project.packageName])).rejects.toThrow('Failed to close'); + expect(watcher.watchedProjectNames.has(project.packageName)).toBe(true); + expect(projectWatcher.listenerCount('close')).toBe(listeners); + close.mockRestore(); + await watcher.unwatchProjectsAsync([project.packageName]); + expect(watcher.watchedProjectNames.has(project.packageName)).toBe(false); await watcher[Symbol.asyncDispose](); }); }); diff --git a/libraries/rush-daemon/src/test/WorkspaceSessionProvider.test.ts b/libraries/rush-daemon/src/test/WorkspaceSessionProvider.test.ts index a5328edaac..6133f516d3 100644 --- a/libraries/rush-daemon/src/test/WorkspaceSessionProvider.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceSessionProvider.test.ts @@ -4,6 +4,10 @@ import type { IWorkspaceSession, IWorkspaceSessionOptions } from '../WorkspaceSession'; import { WorkspaceSessionProvider } from '../WorkspaceSessionProvider'; import { TestWorkspaceSession } from './TestWorkspaceSession'; +import { + recordWorkspaceRequestCleanupFailure, + WorkspaceRequestResourceCleanupError +} from '../WorkspaceRequestResources'; const OPTIONS: IWorkspaceSessionOptions = { repoRoot: 'repo', @@ -11,6 +15,68 @@ const OPTIONS: IWorkspaceSessionOptions = { }; describe(WorkspaceSessionProvider.name, () => { + it('retains failed request ownership across reload and disposal of an injected session', async () => { + const dispose = jest.fn(); + const session = new TestWorkspaceSession(OPTIONS.repoRoot, dispose); + const create = jest.fn(async () => session); + const provider = new WorkspaceSessionProvider(create, OPTIONS); + await provider.getSessionAsync(); + const generation = provider.generation; + const token = provider.currentGenerationToken; + const failure = recordWorkspaceRequestCleanupFailure(session, 'request-owner', new Error('unjoined')); + expect(failure).toBeInstanceOf(WorkspaceRequestResourceCleanupError); + await expect(provider.reloadAsync()).rejects.toBe(failure); + expect(create).toHaveBeenCalledTimes(1); + expect(provider.generation).toBe(generation); + expect(provider.currentGenerationToken).toBe(token); + await expect(provider[Symbol.asyncDispose]()).rejects.toBe(failure); + await expect(provider[Symbol.asyncDispose]()).rejects.toBe(failure); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('waits for complete cleanup before publishing a replacement generation', async () => { + let finishDisposal: (() => void) | undefined; + const first: IWorkspaceSession = new TestWorkspaceSession( + OPTIONS.repoRoot, + () => + new Promise((resolve) => { + finishDisposal = resolve; + }) + ); + const second: IWorkspaceSession = new TestWorkspaceSession(OPTIONS.repoRoot); + let calls: number = 0; + const provider: WorkspaceSessionProvider = new WorkspaceSessionProvider( + async () => (++calls === 1 ? first : second), + OPTIONS + ); + await provider.getSessionAsync(); + const reload: Promise = provider.reloadAsync(); + const pendingRead: Promise = provider.getSessionAsync(); + expect(calls).toBe(1); + expect(provider.generation).toBe(1); + finishDisposal?.(); + expect(await reload).toBe(second); + expect(await pendingRead).toBe(second); + expect(provider.generation).toBe(2); + await provider[Symbol.asyncDispose](); + }); + + it('never constructs a new generation after old cleanup fails', async () => { + const session: IWorkspaceSession = new TestWorkspaceSession(OPTIONS.repoRoot, async () => { + throw new Error('old cleanup failed'); + }); + let calls: number = 0; + const provider: WorkspaceSessionProvider = new WorkspaceSessionProvider(async () => { + calls++; + return session; + }, OPTIONS); + await provider.getSessionAsync(); + await expect(provider.reloadAsync()).rejects.toThrow('old cleanup failed'); + await expect(provider.getSessionAsync()).rejects.toThrow('old cleanup failed'); + expect(calls).toBe(1); + await expect(provider[Symbol.asyncDispose]()).rejects.toThrow('old cleanup failed'); + }); + it('shares concurrent initialization and reuses the result', async () => { const session: IWorkspaceSession = new TestWorkspaceSession(OPTIONS.repoRoot); let resolveFactory: ((value: IWorkspaceSession) => void) | undefined; @@ -107,12 +173,9 @@ describe(WorkspaceSessionProvider.name, () => { const initialization: Promise = provider.getSessionAsync(); const disposal: Promise = provider[Symbol.asyncDispose](); - const initializationExpectation: Promise = expect(initialization).rejects.toThrow( - 'session cleanup failed' - ); - const disposalExpectation: Promise = expect(disposal).rejects.toThrow( - 'session cleanup failed' - ); + const initializationExpectation: Promise = + expect(initialization).rejects.toThrow('session cleanup failed'); + const disposalExpectation: Promise = expect(disposal).rejects.toThrow('session cleanup failed'); await Promise.resolve(); resolveFactory?.(session); diff --git a/libraries/rush-daemon/src/test/WorkspaceStatus.test.ts b/libraries/rush-daemon/src/test/WorkspaceStatus.test.ts new file mode 100644 index 0000000000..3cc320361c --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceStatus.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WorkspaceInputChangeTier } from '@microsoft/rush-lib'; + +import { WorkspaceSessionProvider } from '../WorkspaceSessionProvider'; +import { getWorkspaceStatus } from '../WorkspaceStatus'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import { TestWorkspaceSession } from './TestWorkspaceSession'; + +it.each([WorkspaceInputChangeTier.Reuse, WorkspaceInputChangeTier.Reload, WorkspaceInputChangeTier.Restart])( + 'projects the supplied lifecycle tier %s without constructing a session', + async (tier) => { + const factory = jest.fn(async () => new TestWorkspaceSession('repo')); + const provider = new WorkspaceSessionProvider(factory, { repoRoot: 'repo', rushVersion: '5.178.0' }); + expect(getWorkspaceStatus(provider, tier)).toMatchObject({ + lastReloadTier: tier, + graphInitialized: false + }); + expect(factory).not.toHaveBeenCalled(); + await provider[Symbol.asyncDispose](); + } +); + +it('reads provider generation/token without starting or awaiting cold initialization', async () => { + const ready = createDeferred(); + const factory = jest.fn(() => ready.promise); + const provider = new WorkspaceSessionProvider(factory, { repoRoot: 'repo', rushVersion: '5.178.0' }); + expect(getWorkspaceStatus(provider)).toEqual({ + generation: 1, + lastReloadTier: undefined, + generationToken: undefined, + graphInitialized: false, + warmSet: undefined + }); + expect(factory).not.toHaveBeenCalled(); + const initializing = provider.getSessionAsync(); + expect(getWorkspaceStatus(provider).generationToken).toBeUndefined(); + ready.resolve(new TestWorkspaceSession('repo')); + await initializing; + const status = getWorkspaceStatus(provider); + expect(status.generationToken).toEqual(expect.any(String)); + expect(getWorkspaceStatus(provider)).toEqual(status); + expect(factory).toHaveBeenCalledTimes(1); + await provider[Symbol.asyncDispose](); + expect(getWorkspaceStatus(provider).generationToken).toBeUndefined(); + expect(factory).toHaveBeenCalledTimes(1); +}); + +it('reports the old installed token during cleanup and no token while the replacement is being constructed', async () => { + const closed = createDeferred(); + const replacement = createDeferred(); + const factory = jest + .fn() + .mockResolvedValueOnce(new TestWorkspaceSession('repo', () => closed.promise)) + .mockImplementationOnce(() => replacement.promise); + const provider = new WorkspaceSessionProvider(factory, { repoRoot: 'repo', rushVersion: '5.178.0' }); + await provider.getSessionAsync(); + const oldToken: string | undefined = provider.currentGenerationToken; + const reload = provider.reloadAsync(); + expect(provider.currentGenerationToken).toBe(oldToken); + closed.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(getWorkspaceStatus(provider)).toMatchObject({ generationToken: undefined, graphInitialized: false }); + replacement.resolve(new TestWorkspaceSession('repo')); + await reload; + expect(provider.currentGenerationToken).not.toBe(oldToken); + expect(provider.generation).toBe(2); + await provider[Symbol.asyncDispose](); +}); diff --git a/libraries/rush-daemon/src/test/WorkspaceWarmGeneration.test.ts b/libraries/rush-daemon/src/test/WorkspaceWarmGeneration.test.ts new file mode 100644 index 0000000000..39f1034a7c --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceWarmGeneration.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { PhasedCommandEngine, type IPhasedCommandEngine } from '@microsoft/rush-lib'; +import { LockFile } from '@rushstack/node-core-library'; + +import { getWorkspaceGenerationToken } from '../WorkspaceGeneration'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { DaemonGraphTestFixture, responseSnapshot } from './DaemonGraphTestFixture'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import { + eventuallyAsync, + GENERATION_POLICY, + getWarmSet, + pongAsync, + setDaemonPolicy +} from './WarmGenerationTestUtilities'; +import { + captureWarmRankingDurations, createScript, getMeasuredFixtureRetentionOrder, useNativeIpcRunners +} from './WarmSetTestFixture'; + +jest.setTimeout(30_000); + +describe('automatic warm generation ownership and pong accounting', () => { + let fixture: DaemonGraphTestFixture; + beforeEach(async () => { + fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {})); + }); + afterEach(async () => { + await fixture[Symbol.asyncDispose](); + }); + + it('reports a cold generation without initializing a graph, and automatically applies configured limits', async () => { + const cold = await pongAsync(fixture); + expect(cold.workspace).toMatchObject({ + generation: fixture.host.workspaceGeneration, + generationToken: getWorkspaceGenerationToken(fixture.session), + graphInitialized: false + }); + expect(cold.workspace?.warmSet).toBeUndefined(); + expect(fixture.session.operationGraph).toBeUndefined(); + expect(fixture.runs()).toEqual([]); + setDaemonPolicy(fixture, { warmSetMaxProjects: 1, autoWarmByTelemetry: true }); + await fixture.buildSuccessfullyAsync(); + const warm = getWarmSet(fixture); + await eventuallyAsync(() => expect(warm.getStatus().retainedProjectNames).toHaveLength(1)); + const status = await pongAsync(fixture); + expect(status.pid).toBe(cold.pid); + expect(status.workspace?.generation).toBeGreaterThan(cold.workspace!.generation); + expect(status.workspace?.warmSet).toMatchObject({ + configuration: { ...GENERATION_POLICY, warmSetMaxProjects: 1, autoWarmByTelemetry: true }, + maintenanceState: 'running', + measuredRunnerMemoryBytes: 0, + unmeasuredRunnerCount: 0, + overProjectLimit: false + }); + expect(status.workspace?.warmSet?.watchedProjectNames).toEqual( + status.workspace?.warmSet?.retainedProjectNames + ); + expect(fixture.runs()).toEqual(['a', 'b']); + expect(fixture.host.workspaceStatus.generationToken).toBe(status.workspace?.generationToken); + }); + + it('applies idle and memory knobs in the default host while reporting irreducible pressure honestly', async () => { + setDaemonPolicy(fixture, { warmIdleTimeoutSeconds: 0.05 }); + await fixture.buildSuccessfullyAsync(); + const generation: number = fixture.host.workspaceGeneration; + await eventuallyAsync(() => expect(fixture.session.warmSetStatus?.retainedProjectNames).toEqual([])); + expect((await pongAsync(fixture)).workspace?.warmSet?.configuration.warmIdleTimeoutSeconds).toBe(0.05); + const observed: string[][] = []; + fixture.session.operationGraph!.hooks.beforeExecuteIterationAsync.tap( + 'restored-project-observation', + () => { + observed.push([...(fixture.session.warmSetStatus?.watchedProjectNames ?? [])]); + } + ); + fixture.write('a/input.txt', 'cold-source-change'); + await fixture.buildSuccessfullyAsync(); + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(observed).toContainEqual(['a', 'b']); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b']); + const warning = jest.spyOn(process, 'emitWarning').mockImplementation(() => {}); + try { + setDaemonPolicy(fixture, { warmMemoryBudgetMB: 0.01 }); + await fixture.buildSuccessfullyAsync(); + await eventuallyAsync(() => expect(fixture.session.warmSetStatus?.retainedProjectNames).toEqual([])); + const status = await pongAsync(fixture); + expect(status.workspace?.warmSet).toMatchObject({ + configuration: { warmMemoryBudgetMB: 0.01 }, + daemonResidentMemoryBytes: expect.any(Number), + measuredRunnerMemoryBytes: 0, + overMemoryBudget: true, + maintenanceState: 'running' + }); + expect(status.workspace!.warmSet!.daemonResidentMemoryBytes).toBeGreaterThan(0.01 * 1024 * 1024); + expect(warning).toHaveBeenCalled(); + } finally { + warning.mockRestore(); + } + }); + + it('does not certify evicted graph results from historical observer state', async () => { + await fixture.buildSuccessfullyAsync(); + responseSnapshot(await fixture.graphAsync('status')); + const warning = jest.spyOn(process, 'emitWarning').mockImplementation(() => {}); + try { + const warm = getWarmSet(fixture); + warm.updateConfiguration({ ...GENERATION_POLICY, warmMemoryBudgetMB: 0.01 }); + await warm.maintainAsync(); + expect(fixture.session.operationGraph!.resultByOperation.size).toBe(0); + const snapshot = responseSnapshot(await fixture.graphAsync('invalidate', '--project', 'a')); + if (!snapshot.initialized) throw new Error('Expected the retained graph definition.'); + expect(snapshot.operations.find((operation) => operation.projectName === 'a')?.status).toBe('READY'); + expect(fixture.runs()).toEqual(['a', 'b']); + } finally { + warning.mockRestore(); + } + }); + + it.each([false, true])( + 'applies automatic telemetry retention from configuration (enabled: %s)', + async (telemetry) => { + setDaemonPolicy(fixture, { warmSetMaxProjects: 2, autoWarmByTelemetry: telemetry }); + fixture.write( + '.gitignore', + 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\n**/node_modules/\nruns.txt\n' + ); + for (const name of ['a', 'b', 'c']) fixture.write(`${name}/build.cjs`, createScript(name, true)); + const create = PhasedCommandEngine.prototype.createEngineAsync; + const engines = jest + .spyOn(PhasedCommandEngine.prototype, 'createEngineAsync') + .mockImplementation(async function ( + this: PhasedCommandEngine, + lock?: LockFile + ): Promise { + const engine: IPhasedCommandEngine = await create.call(this, lock); + useNativeIpcRunners(engine.operationGraph); + return engine; + }); + try { + await fixture.buildSuccessfullyAsync(); + const graph = fixture.session.operationGraph!; + const coldDurations = captureWarmRankingDurations(graph); + fixture.write('a/input.txt', 'two'); + fixture.write('b/input.txt', 'two'); + await fixture.buildSuccessfullyAsync(); + await fixture.runAsync(['build', '--only', 'b', '--parallelism', '3']); + const expected: string[] = telemetry + ? getMeasuredFixtureRetentionOrder(graph, coldDurations, ['a', 'b', 'a', 'b', 'b']) + : ['c', 'b']; + expect( + (await fixture.runAsync(['build', '--only', 'c', '--parallelism', '3'])).terminal + ).toMatchObject({ payload: { exitCode: 0 } }); + await eventuallyAsync(() => + expect(fixture.session.warmSetStatus?.retainedProjectNames).toEqual(expected) + ); + const status = await pongAsync(fixture); + expect(status.workspace?.warmSet).toMatchObject({ + configuration: { autoWarmByTelemetry: telemetry }, + retainedProjectNames: expected, + measuredRunnerMemoryBytes: expect.any(Number), + unmeasuredRunnerCount: 0 + }); + expect(status.workspace!.warmSet!.measuredRunnerMemoryBytes).toBeGreaterThan(0); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b', 'c']); + } finally { + engines.mockRestore(); + } + } + ); + + it('quiesces the old controller before workspace/native locks and replaces it on a same-PID soft reload', async () => { + await fixture.buildSuccessfullyAsync(); + const oldSession = fixture.session; + const oldGraph = oldSession.operationGraph!; + const oldWarm = getWarmSet(fixture); + const oldStatus = await pongAsync(fixture); + const entered = createDeferred(); + const release = createDeferred(); + const quiescing = createDeferred(); + const close = oldGraph.closeRunnersAsync.bind(oldGraph); + const closeSpy = jest.spyOn(oldGraph, 'closeRunnersAsync').mockImplementation(async (operations) => { + entered.resolve(); + await release.promise; + await close(operations); + }); + oldWarm.updateConfiguration({ ...GENERATION_POLICY, warmSetMaxProjects: 1 }); + const maintenance = oldWarm.maintainAsync(); + await entered.promise; + const scheduler = getWorkspaceRequestScheduler(oldSession); + const admission = jest.spyOn(scheduler, 'acquireAsync'); + const native = jest.spyOn(LockFile, 'tryAcquire'); + const quiesce = oldSession.quiesceWarmSetAsync.bind(oldSession); + const quiesceSpy = jest.spyOn(oldSession, 'quiesceWarmSetAsync').mockImplementation(() => { + const promise = quiesce(); + quiescing.resolve(); + return promise; + }); + const manifestPath: string = path.join(fixture.folder, 'a/package.json'); + const manifest: { scripts: Record } = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.scripts['_phase:compile'] = 'node build.cjs --soft-reload'; + fixture.write('a/package.json', JSON.stringify(manifest)); + const build = fixture.buildSuccessfullyAsync(); + try { + await quiescing.promise; + expect(admission).not.toHaveBeenCalled(); + expect(native).not.toHaveBeenCalled(); + expect(fixture.session).toBe(oldSession); + const pending = await pongAsync(fixture); + expect(pending.workspace?.generationToken).toBe(oldStatus.workspace?.generationToken); + expect(pending.workspace?.warmSet).toMatchObject({ + maintenanceState: 'quiescing', + retainedProjectNames: expect.arrayContaining(['a', 'b']) + }); + } finally { + release.resolve(); + await maintenance; + closeSpy.mockRestore(); + quiesceSpy.mockRestore(); + admission.mockRestore(); + native.mockRestore(); + } + expect((await build).terminal).toMatchObject({ payload: { exitCode: 0 } }); + const current = await pongAsync(fixture); + expect(current.pid).toBe(oldStatus.pid); + expect(current.workspace?.generationToken).not.toBe(oldStatus.workspace?.generationToken); + expect(current.workspace!.generation).toBeGreaterThan(oldStatus.workspace!.generation); + expect(fixture.session).not.toBe(oldSession); + expect(getWarmSet(fixture)).not.toBe(oldWarm); + expect(current.workspace?.warmSet?.maintenanceState).toBe('running'); + expect(oldWarm.getStatus()).toMatchObject({ maintenanceState: 'stopped', watchedProjectNames: [] }); + expect(oldGraph.abortController.signal.aborted).toBe(true); + }); +}); diff --git a/libraries/rush-daemon/src/test/WorkspaceWarmQuiescence.test.ts b/libraries/rush-daemon/src/test/WorkspaceWarmQuiescence.test.ts new file mode 100644 index 0000000000..ef688c54dd --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceWarmQuiescence.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Rush, type IPhasedCommandEngine } from '@microsoft/rush-lib'; +import { LockFile } from '@rushstack/node-core-library'; + +import { RequestExclusivityClass } from '../RequestScheduler'; +import { WorkspaceSession } from '../WorkspaceSession'; +import { WorkspaceSessionFileWatcher } from '../WorkspaceSessionFileWatcher'; +import { WorkspaceSessionProvider } from '../WorkspaceSessionProvider'; +import { WorkspaceWarmSet } from '../WorkspaceWarmSet'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { getWorkspaceStatus } from '../WorkspaceStatus'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import { + createNativeEngineAsync, + createObservedSessionAsync, + GENERATION_POLICY, + setDaemonPolicy +} from './WarmGenerationTestUtilities'; + +jest.setTimeout(30_000); + +describe('warm-set generation barriers', () => { + let fixture: DaemonGraphTestFixture; + beforeEach(async () => { + fixture = await DaemonGraphTestFixture.createAsync((created) => setDaemonPolicy(created, {})); + }); + afterEach(async () => { + await fixture[Symbol.asyncDispose](); + }); + + it('fences later initialization after cold quiescence without invoking a factory', async () => { + const { session } = await createObservedSessionAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version + }); + try { + await session.quiesceWarmSetAsync(); + const factory = jest.fn(async () => createNativeEngineAsync(session.rushConfiguration)); + await expect(session.initializeEngineAsync(factory)).rejects.toThrow('quiescing'); + expect(factory).not.toHaveBeenCalled(); + expect(session.operationGraph).toBeUndefined(); + expect(session.warmSetStatus).toBeUndefined(); + } finally { + await session[Symbol.asyncDispose](); + } + }); + + it('publishes pending initialization before construction can reenter the quiescence barrier', async () => { + const { session } = await createObservedSessionAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version + }); + const engine: IPhasedCommandEngine = await createNativeEngineAsync(session.rushConfiguration); + const entered = createDeferred(); + const release = createDeferred(); + let stopped: boolean = false; + let stopping: Promise | undefined; + const initialization = session.initializeEngineAsync(async () => { + stopping = session.quiesceWarmSetAsync().then(() => { + stopped = true; + }); + entered.resolve(); + await release.promise; + return engine; + }); + const rejected = expect(initialization).rejects.toThrow('quiescence/disposal'); + try { + await entered.promise; + await Promise.resolve(); + await Promise.resolve(); + expect(stopped).toBe(false); + release.resolve(); + await rejected; + await stopping; + expect(stopped).toBe(true); + expect(engine.operationGraph.abortController.signal.aborted).toBe(true); + } finally { + release.resolve(); + await session[Symbol.asyncDispose](); + } + }); + + it.each(['quiesce', 'dispose'] as const)( + 'awaits pending initialization and stops an integration-supplied late controller during %s', + async (mode) => { + const { session, watcher } = await createObservedSessionAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version + }); + const engine: IPhasedCommandEngine = await createNativeEngineAsync(session.rushConfiguration); + const release = createDeferred(); + const acquire = jest.fn(async () => await engine.acquireExecutionLeaseAsync?.()); + let warm: WorkspaceWarmSet | undefined; + const initialization = session.initializeEngineAsync(async () => { + await release.promise; + warm = WorkspaceWarmSet.attach({ + operationGraph: engine.operationGraph, + configuration: GENERATION_POLICY, + watcher, + scheduler: getWorkspaceRequestScheduler(session), + acquireExecutionLeaseAsync: acquire + }); + return engine; + }); + const rejected = expect(initialization).rejects.toThrow('quiescence/disposal'); + let stopped: boolean = false; + const stopping = ( + mode === 'quiesce' ? session.quiesceWarmSetAsync() : session[Symbol.asyncDispose]() + ).then(() => { + stopped = true; + }); + try { + await Promise.resolve(); + expect(stopped).toBe(false); + release.resolve(); + await Promise.all([rejected, stopping]); + expect(acquire).not.toHaveBeenCalled(); + expect(warm?.getStatus().maintenanceState).toBe('stopped'); + expect(engine.operationGraph.abortController.signal.aborted).toBe(true); + expect(session.operationGraph).toBeUndefined(); + } finally { + release.resolve(); + await session[Symbol.asyncDispose](); + } + } + ); + + it.each([false, true])( + 'automatically owns an eagerly supplied real graph (injected watcher: %s)', + async (injected) => { + const session: WorkspaceSession = await WorkspaceSession.createAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version, + createComponentsAsync: async ({ rushConfiguration }) => { + const engine: IPhasedCommandEngine = await createNativeEngineAsync(rushConfiguration); + if (!injected) return engine; + const watcher: WorkspaceSessionFileWatcher = new WorkspaceSessionFileWatcher({ + rushConfiguration, + projectNames: [] + }); + return { + ...engine, + projectWatcher: watcher, + [Symbol.asyncDispose]: async () => { + await watcher[Symbol.asyncDispose](); + await engine[Symbol.asyncDispose](); + } + }; + } + }); + try { + expect(session.warmSetStatus).toMatchObject({ + maintenanceState: 'running', + watchedProjectNames: [], + configuration: GENERATION_POLICY + }); + await session.quiesceWarmSetAsync(); + expect(session.warmSetStatus?.maintenanceState).toBe('stopped'); + expect(fixture.runs()).toEqual([]); + } finally { + await session[Symbol.asyncDispose](); + } + } + ); + + it('never publishes a replacement or token when real generation resource disposal fails', async () => { + let creations: number = 0; + let engine: IPhasedCommandEngine | undefined; + const provider: WorkspaceSessionProvider = new WorkspaceSessionProvider( + async (options) => { + creations++; + return await WorkspaceSession.createAsync({ + ...options, + createComponentsAsync: async ({ rushConfiguration }) => { + const created = await createNativeEngineAsync(rushConfiguration); + engine = created; + return { + ...created, + [Symbol.asyncDispose]: async () => { + await created[Symbol.asyncDispose](); + throw new Error('generation-resource-disposal-failed'); + } + }; + } + }); + }, + { repoRoot: fixture.folder, rushVersion: Rush.version } + ); + const session = await provider.getSessionAsync(); + const before = getWorkspaceStatus(provider); + await session.quiesceWarmSetAsync?.(); + const admission = await getWorkspaceRequestScheduler(session).acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + const native = LockFile.tryAcquire(session.rushConfiguration.commonTempFolder, 'rush'); + expect(native).toBeDefined(); + try { + await expect(provider.reloadAsync()).rejects.toThrow('generation-resource-disposal-failed'); + expect(creations).toBe(1); + expect(provider.currentGenerationToken).toBe(before.generationToken); + expect(provider.generation).toBe(before.generation); + expect(engine?.operationGraph.abortController.signal.aborted).toBe(true); + expect(getWorkspaceStatus(provider).warmSet?.maintenanceState).toBe('stopped'); + await expect(provider.getSessionAsync()).rejects.toThrow('generation-resource-disposal-failed'); + } finally { + native?.release(); + admission.release(); + await expect(provider[Symbol.asyncDispose]()).rejects.toThrow('generation-resource-disposal-failed'); + } + }); + + it('does not forget cleanup failure from rejected late components when quiescing', async () => { + const { session, watcher } = await createObservedSessionAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version + }); + const engine: IPhasedCommandEngine = await createNativeEngineAsync(session.rushConfiguration); + const release = createDeferred(); + const initialization = session.initializeEngineAsync(async () => { + await release.promise; + return { + ...engine, + [Symbol.asyncDispose]: async () => { + await engine[Symbol.asyncDispose](); + throw new Error('late-cleanup-failed'); + } + }; + }); + const rejected = expect(initialization).rejects.toThrow('late-cleanup-failed'); + const quiescence = expect(session.quiesceWarmSetAsync()).rejects.toThrow('Failed to quiesce'); + release.resolve(); + try { + await Promise.all([rejected, quiescence]); + await expect(session[Symbol.asyncDispose]()).rejects.toThrow('Failed to quiesce'); + expect(engine.operationGraph.abortController.signal.aborted).toBe(true); + } finally { + // The deliberately failed ownership barrier keeps the watcher owned. The test knows the real + // native engine is closed and explicitly releases this remaining test-owned resource. + await watcher[Symbol.asyncDispose](); + } + }); + + it('makes native lease-release failure sticky at quiescence without failing optional maintenance', async () => { + const { session, watcher } = await createObservedSessionAsync({ + repoRoot: fixture.folder, + rushVersion: Rush.version + }); + const engine: IPhasedCommandEngine = await createNativeEngineAsync(session.rushConfiguration); + const diagnostics: Error[] = []; + const acquire = jest.fn(async () => { + const native = await engine.acquireExecutionLeaseAsync?.(); + return { + [Symbol.asyncDispose]: async () => { + await native?.[Symbol.asyncDispose](); + throw new Error('native-release-failed'); + } + }; + }); + const warm = WorkspaceWarmSet.attach({ + operationGraph: engine.operationGraph, + configuration: GENERATION_POLICY, + watcher, + scheduler: getWorkspaceRequestScheduler(session), + acquireExecutionLeaseAsync: acquire, + onDiagnostic: (error) => diagnostics.push(error) + }); + await session.initializeEngineAsync(async () => engine); + try { + await warm.maintainAsync(); + expect(warm.getStatus()).toMatchObject({ + maintenanceState: 'failed', + maintenanceFailure: expect.any(String) + }); + expect(diagnostics).toHaveLength(1); + await warm.maintainAsync(); + expect(acquire).toHaveBeenCalledTimes(1); + await expect(session.quiesceWarmSetAsync()).rejects.toThrow('Failed to quiesce'); + await expect(session[Symbol.asyncDispose]()).rejects.toThrow('Failed to quiesce'); + } finally { + await watcher[Symbol.asyncDispose](); + await engine[Symbol.asyncDispose](); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/WorkspaceWarmSet.test.ts b/libraries/rush-daemon/src/test/WorkspaceWarmSet.test.ts new file mode 100644 index 0000000000..b69f6a4dc2 --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceWarmSet.test.ts @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout as delayAsync } from 'node:timers/promises'; +import { inspect } from 'node:util'; + +import { OperationStatus, type IOperationExecutionResult } from '@microsoft/rush-lib'; +import { OperationExecutionRecord } from '@microsoft/rush-lib/lib/logic/operations/OperationExecutionRecord'; + +import { RequestExclusivityClass } from '../RequestScheduler'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { WorkspaceWarmSet } from '../WorkspaceWarmSet'; +import { createDeferred } from './DaemonRequestWireTestUtilities'; +import { createNativeScriptGateAsync, runNativeCommandAsync } from './NativeEngineTestCommands'; +import { + captureWarmRankingDurations, getMeasuredFixtureRetentionOrder, + WarmSetTestFixture, type IWarmFixtureOptions +} from './WarmSetTestFixture'; + +jest.setTimeout(30_000); + +describe('warm policies attached to native graphs and real filesystem watchers', () => { + let test: WarmSetTestFixture | undefined; + afterEach(async () => { + const closing: WarmSetTestFixture | undefined = test; + test = undefined; + await closing?.[Symbol.asyncDispose](); + }); + + async function startAsync(options: IWarmFixtureOptions = {}): Promise { + test = await WarmSetTestFixture.createAsync(options); + await test.fixture.buildSuccessfullyAsync(); + await test.warm.maintainAsync(); + return test; + } + + it('drops unrequested project observation, expires idle resources automatically and restores them on demand', async () => { + const { fixture, watcher, warm, graph } = await startAsync({ ipc: true }); + expect(fixture.runs()).toEqual(['a', 'b']); + expect([...watcher.watchedProjectNames].sort()).toEqual(['a', 'b']); + expect(test!.operation('a').runner?.isActive).toBe(true); + expect(() => graph.deleteResults!([test!.operation('a')])).toThrow('active runner'); + expect(warm.getStatus().measuredRunnerMemoryBytes).toBeGreaterThan(0); + expect(warm.getStatus().unmeasuredRunnerCount).toBe(0); + test!.update({ warmIdleTimeoutSeconds: 0.06 }); + await eventuallyAsync(() => expect(graph.resultByOperation.size).toBe(0)); + expect(watcher.watchedProjectNames.size).toBe(0); + expect(test!.operation('a').runner?.isActive).toBe(false); + expect(test!.operation('b').runner?.isActive).toBe(false); + expect(fs.existsSync(path.join(fixture.folder, 'common/temp/closed-a'))).toBe(true); + expect(warm.getStatus().measuredRunnerMemoryBytes).toBe(0); + expect(fixture.runs()).toEqual(['a', 'b']); + + test!.update({ warmIdleTimeoutSeconds: 300 }); + fixture.write('a/input.txt', 'changed-while-cold'); + await fixture.buildSuccessfullyAsync(); + expect([...watcher.watchedProjectNames].sort()).toEqual(['a', 'b']); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe('changed-while-cold'); + expect(test!.operation('a').runner?.isActive).toBe(true); + const before: number = fixture.session.invalidations.getSnapshot().sequence; + fixture.write('a/input.txt', 'observed-after-rewarming'); + await eventuallyAsync(() => + expect(fixture.session.invalidations.getSnapshot().sequence).toBeGreaterThan(before) + ); + expect(fixture.runs()).not.toContain('c'); + }); + + it('enforces max-project LRU, including unchanged requests, without changing selections or survivor hashes', async () => { + const { fixture, warm, graph, watcher } = await startAsync(); + const b = test!.operation('b'); + const record: IOperationExecutionResult = graph.resultByOperation.get(b)!; + const hash: string = record.getStateHash(); + expect((await fixture.runAsync(['build', '--only', 'b', '--parallelism', '3'])).terminal).toMatchObject({ + payload: { exitCode: 0 } + }); + const runs: string[] = fixture.runs(); + test!.update({ warmSetMaxProjects: 1 }); + expect((await warm.maintainAsync()).retainedProjectNames).toEqual(['b']); + expect([...watcher.watchedProjectNames]).toEqual(['b']); + expect(graph.resultByOperation.get(b)).toBe(record); + expect(record.getStateHash()).toBe(hash); + expect(record.status).toBe(OperationStatus.Success); + expect(test!.operation('a').enabled).toBe(false); + expect(b.enabled).toBe(true); + expect(fixture.runs()).toEqual(runs); + expect((await fixture.runAsync(['build', '--only', 'b', '--parallelism', '3'])).terminal).toMatchObject({ + payload: { exitCode: 0 } + }); + expect(fixture.runs()).toEqual(runs); + }); + + it('lets autoWarmByTelemetry change actual retention using real cold/reused durations and IPC RSS', async () => { + const { fixture, warm, graph } = await startAsync({ ipc: true }); + const coldDurations = captureWarmRankingDurations(graph); + fixture.write('a/input.txt', 'two'); + fixture.write('b/input.txt', 'two'); + await fixture.buildSuccessfullyAsync(); + await fixture.runAsync(['build', '--only', 'b', '--parallelism', '3']); + // Actual startup timing/RSS can reverse the projects' value on shared runners. Ties use b's recency. + const [retained, evicted] = getMeasuredFixtureRetentionOrder( + graph, coldDurations, ['a', 'b', 'a', 'b', 'b'] + ); + test!.update({ autoWarmByTelemetry: true }); + expect(warm.getStatus().retainedProjectNames).toEqual([retained, evicted]); + test!.update({ autoWarmByTelemetry: false }); + expect(warm.getStatus().retainedProjectNames).toEqual(['b', 'a']); + test!.update({ autoWarmByTelemetry: true, warmSetMaxProjects: 1 }); + await warm.maintainAsync(); + expect(warm.getStatus().retainedProjectNames).toEqual([retained]); + expect(graph.resultByOperation.has(test!.operation(evicted))).toBe(false); + expect(test!.operation(evicted).runner?.isActive).toBe(false); + + // Requested rewarming, not a speculative script launch, then pure LRU keeps the most recent request. + test!.update({ autoWarmByTelemetry: false, warmSetMaxProjects: 2 }); + await fixture.runAsync(['build', '--only', evicted, '--parallelism', '3']); + test!.update({ autoWarmByTelemetry: true }); + expect(warm.getStatus().retainedProjectNames).toEqual([retained, evicted]); + test!.update({ autoWarmByTelemetry: false, warmSetMaxProjects: 1 }); + await warm.maintainAsync(); + expect(warm.getStatus().retainedProjectNames).toEqual([evicted]); + expect(test!.operation(retained).runner?.isActive).toBe(false); + expect(fixture.runs()).not.toContain('c'); + }); + + it('reports a deferred project cap in status without warning before idle cleanup can run', async () => { + const { fixture, warm, diagnostics } = await startAsync(); + const lease = await getWorkspaceRequestScheduler(fixture.session).acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + try { + test!.update({ warmSetMaxProjects: 1 }); + expect(await warm.maintainAsync()).toMatchObject({ + deferredReason: 'workspace-busy', + overProjectLimit: true, + overMemoryBudget: false + }); + expect(diagnostics).toEqual([]); + } finally { + lease.release(); + } + expect((await warm.maintainAsync()).retainedProjectNames).toHaveLength(1); + expect(diagnostics).toEqual([]); + }); + + it('evicts under measured memory pressure and reports a budget below remaining daemon RSS honestly', async () => { + const { warm, fixture, graph } = await startAsync({ ipc: true }); + const initial = warm.getStatus(); + expect(initial.overMemoryBudget).toBe(false); + expect(initial.measuredRunnerMemoryBytes).toBeGreaterThan(0); + test!.update({ + warmMemoryBudgetMB: + (initial.daemonResidentMemoryBytes + initial.measuredRunnerMemoryBytes * 0.75) / (1024 * 1024) + }); + const underPressure = await warm.maintainAsync(); + expect(underPressure.retainedProjectNames.length).toBeLessThan(2); + expect(underPressure.measuredRunnerMemoryBytes).toBeLessThan(initial.measuredRunnerMemoryBytes); + test!.update({ warmMemoryBudgetMB: 0.01 }); + const belowBase = await warm.maintainAsync(); + expect(belowBase.retainedProjectNames).toEqual([]); + expect(belowBase.overMemoryBudget).toBe(true); + expect(belowBase.daemonResidentMemoryBytes).toBeGreaterThan(0.01 * 1024 * 1024); + expect(belowBase.measuredRunnerMemoryBytes).toBe(0); + expect(graph.resultByOperation.size).toBe(0); + expect(test!.diagnostics.some((error) => error.message.includes('remaining daemon memory'))).toBe(true); + expect(fixture.runs()).toEqual(['a', 'b']); + }); + + it('reports missing child measurements and uses conservative LRU instead of inventing memory scores', async () => { + const { fixture, warm } = await startAsync({ ipc: true }); + const missing = ['a', 'b'].map((name) => test!.operation(name).runner!); + for (const runner of missing) { + Object.defineProperty(runner, 'residentMemoryBytes', { configurable: true, get: () => undefined }); + } + try { + fixture.write('a/input.txt', 'two'); + await fixture.buildSuccessfullyAsync(); + await fixture.runAsync(['build', '--only', 'b', '--parallelism', '3']); + test!.update({ autoWarmByTelemetry: true }); + expect(warm.getStatus().unmeasuredRunnerCount).toBe(2); + expect(warm.getStatus().retainedProjectNames).toEqual(['b', 'a']); + test!.update({ autoWarmByTelemetry: true, warmSetMaxProjects: 1 }); + expect((await warm.maintainAsync()).retainedProjectNames).toEqual(['b']); + expect(warm.getStatus().unmeasuredRunnerCount).toBe(1); + } finally { + for (const runner of missing) Reflect.deleteProperty(runner, 'residentMemoryBytes'); + } + }); + + it('does not mistake a resource-owning custom NoOp runner for a native resource-free node', async () => { + const { warm } = await startAsync(); + const operation = test!.operation('a'); + let active: boolean = true; + operation.runner = { + name: 'custom-noop-with-resource', + isNoOp: true, + cacheable: false, + reportTiming: false, + silent: false, + warningsAreAllowed: false, + get isActive() { return active; }, + residentMemoryBytes: 1024, + getConfigHash: () => '', + executeAsync: async () => OperationStatus.NoOp, + closeAsync: async () => { active = false; } + }; + const rank = warm.getStatus().projectRanks?.find((project) => project.projectName === 'a'); + expect(rank?.measuredRunnerMemoryBytes).toBe(1024); + expect(rank?.timeSavedMs).toBeUndefined(); + expect(warm.getStatus().measuredRunnerMemoryBytes).toBe(1024); + }); + + it('defers maintenance during a real native build and keeps protected resources despite impossible limits', async () => { + const { fixture, warm, graph } = await startAsync({ ipc: true }); + const before: IOperationExecutionResult = graph.resultByOperation.get(test!.operation('a'))!; + const gate = await createNativeScriptGateAsync(fixture.folder, 'a'); + fixture.write('a/input.txt', 'gated'); + const build = fixture.buildSuccessfullyAsync(); + try { + await gate.entered; + test!.update({ warmMemoryBudgetMB: 0.01, warmIdleTimeoutSeconds: 0.01, warmSetMaxProjects: 1 }); + const status = await warm.maintainAsync(); + expect(status.deferredReason).toBe('workspace-busy'); + expect(status.overMemoryBudget).toBe(true); + expect(test!.operation('a').runner?.isActive).toBe(true); + expect(graph.resultByOperation.get(test!.operation('a'))).toBe(before); + expect(() => graph.deleteResults!([test!.operation('a')])).toThrow('executing or prepared'); + // Register protection while the real build still holds admission, not in a race with its idle timer. + test!.protectedOperations.add(test!.operation('a')); + test!.protectedOperations.add(test!.operation('b')); + } finally { + await gate.releaseAsync(); + } + expect((await build).terminal).toMatchObject({ payload: { exitCode: 0 } }); + const protectedStatus = await warm.maintainAsync(); + expect([...protectedStatus.protectedProjectNames].sort()).toEqual(['a', 'b']); + expect(protectedStatus.overMemoryBudget).toBe(true); + expect(protectedStatus.overProjectLimit).toBe(true); + expect(graph.resultByOperation.size).toBe(2); + }); + + it('takes the real repository execution lease and leaves records/watchers intact during native CLI contention', async () => { + const { fixture, warm, graph, watcher } = await startAsync(); + const gate = await createNativeScriptGateAsync(fixture.folder, 'c'); + const native = runNativeCommandAsync(fixture.folder, ['build', '--only', 'c', '--parallelism', '3']); + try { + await gate.entered; + test!.update({ warmMemoryBudgetMB: 0.01 }); + const status = await warm.maintainAsync(); + if (status.deferredReason !== 'native-busy') { + throw new Error( + `Native contention was not reported:\n${inspect({ status, diagnostics: test!.diagnostics }, { depth: null })}` + ); + } + expect(status.deferredReason).toBe('native-busy'); + expect(graph.resultByOperation.size).toBe(2); + expect([...watcher.watchedProjectNames].sort()).toEqual(['a', 'b']); + } finally { + await gate.releaseAsync(); + await native; + } + expect((await native).exitCode).toBe(0); + await warm.maintainAsync(); + expect(graph.resultByOperation.size).toBe(0); + }); + + it('does not touch paused prepared records or native ownership until a plan has been discarded', async () => { + const { fixture, warm, graph } = await startAsync(); + const admission = await getWorkspaceRequestScheduler(fixture.session).acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + const native = await fixture.session.acquireExecutionLeaseAsync(); + try { + graph.pauseNextIteration = true; + graph.invalidateOperations(); + await graph.scheduleIterationAsync({ inputsSnapshot: fixture.session.inputsSnapshot }); + } finally { + await native?.[Symbol.asyncDispose](); + admission.release(); + } + test!.update({ warmMemoryBudgetMB: 0.01 }); + const acquire = jest.spyOn(fixture.session, 'acquireExecutionLeaseAsync'); + try { + expect((await warm.maintainAsync()).deferredReason).toBe('graph-busy'); + expect(acquire).not.toHaveBeenCalled(); + expect(graph.resultByOperation.size).toBe(2); + expect(() => graph.deleteResults!([test!.operation('a')])).toThrow('prepared'); + expect(graph.hasScheduledIteration).toBe(true); + graph.discardScheduledIteration(); + await warm.maintainAsync(); + expect(graph.resultByOperation.size).toBe(0); + expect(fixture.runs()).toEqual(['a', 'b']); + } finally { + acquire.mockRestore(); + } + }); + + it('awaits real runner closure and watcher teardown while excluding graph mutation and new builds', async () => { + const { fixture, warm, graph, watcher } = await startAsync({ ipc: true }); + fixture.write('common/temp/close-delay-b', 'yes'); + const closing = createDeferred(); + const release = createDeferred(); + const close = graph.closeRunnersAsync.bind(graph); + const closeSpy = jest.spyOn(graph, 'closeRunnersAsync').mockImplementation(async (operations) => { + closing.resolve(); + await release.promise; + await close(operations); + }); + test!.update({ warmMemoryBudgetMB: 0.01 }); + const maintenance = warm.maintainAsync(); + try { + await closing.promise; + expect(graph.resultByOperation.size).toBe(2); + expect(watcher.watchedProjectNames.size).toBe(2); + expect( + (await fixture.runAsync(['daemon', 'graph', 'pause'], { admission: { noWait: true } })).terminal + ).toMatchObject({ payload: { admissionErrorCode: 'no-wait' } }); + expect( + ( + await fixture.runAsync(['build', '--to', 'b', '--parallelism', '3'], { + admission: { noWait: true } + }) + ).terminal + ).toMatchObject({ payload: { admissionErrorCode: 'no-wait' } }); + } finally { + release.resolve(); + await maintenance; + closeSpy.mockRestore(); + } + expect(fs.existsSync(path.join(fixture.folder, 'common/temp/closed-b'))).toBe(true); + expect(graph.resultByOperation.size).toBe(0); + expect(watcher.watchedProjectNames.size).toBe(0); + }); + + it.each(['runner', 'watcher'] as const)( + 'diagnoses %s cleanup failure without false record deletion or build failure', + async (kind) => { + const { fixture, warm, graph, watcher } = await startAsync({ ipc: true }); + const operation = test!.operation('a'); + test!.protectedOperations.add(test!.operation('b')); + const record: IOperationExecutionResult = graph.resultByOperation.get(operation)!; + const close = + kind === 'runner' + ? jest.spyOn(operation.runner!, 'closeAsync').mockRejectedValue(new Error('runner-close-failed')) + : jest.spyOn(watcher, 'unwatchProjectsAsync').mockRejectedValue(new Error('watcher-close-failed')); + try { + test!.update({ warmMemoryBudgetMB: 0.01 }); + const status = await warm.maintainAsync(); + expect(status.cleanupFailures).toHaveLength(1); + expect(graph.resultByOperation.get(operation)).toBe(record); + expect(record.status).toBe(OperationStatus.Success); + expect(watcher.watchedProjectNames.has('a')).toBe(true); + expect(operation.runner?.isActive).toBe(kind === 'runner'); + expect(test!.diagnostics.some((error) => error.message.includes('Could not evict'))).toBe(true); + await fixture.buildSuccessfullyAsync(); + } finally { + close.mockRestore(); + } + await warm.maintainAsync(); + expect(graph.resultByOperation.has(operation)).toBe(false); + expect(warm.getStatus().cleanupFailures).toEqual([]); + } + ); + + it('does not drop records when a resolved close still leaves a native child resident', async () => { + const { warm, graph, watcher } = await startAsync({ ipc: true }); + const operation = test!.operation('a'); + test!.protectedOperations.add(test!.operation('b')); + const retained: IOperationExecutionResult = graph.resultByOperation.get(operation)!; + const close = jest.spyOn(operation.runner!, 'closeAsync').mockResolvedValue(undefined); + try { + test!.update({ warmMemoryBudgetMB: 0.01 }); + const status = await warm.maintainAsync(); + expect(status.cleanupFailures[0]).toContain('still reports active'); + expect(graph.resultByOperation.get(operation)).toBe(retained); + expect(watcher.watchedProjectNames.has('a')).toBe(true); + expect(status.measuredRunnerMemoryBytes).toBeGreaterThan(0); + } finally { + close.mockRestore(); + } + }); + + it('preserves real cache restoration, warning and failed-build semantics after eviction', async () => { + const { fixture, warm, graph } = await startAsync({ cache: true }); + test!.update({ warmMemoryBudgetMB: 0.01 }); + await warm.maintainAsync(); + fs.rmSync(path.join(fixture.folder, 'a/lib'), { recursive: true }); + test!.update({}); + await fixture.buildSuccessfullyAsync(); + expect(fixture.runs()).toEqual(['a', 'b']); + expect(graph.resultByOperation.get(test!.operation('a'))?.status).toBe(OperationStatus.FromCache); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe('one'); + for (const input of ['warning', 'failure']) { + test!.update({ warmMemoryBudgetMB: 0.01 }); + await warm.maintainAsync(); + test!.update({}); + fixture.write('a/input.txt', input); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 1 } }); + expect(graph.resultByOperation.get(test!.operation('a'))?.status).toBe( + input === 'warning' ? OperationStatus.SuccessWithWarning : OperationStatus.Failure + ); + } + }); + + it('detaches surviving record/context links and cache scratch state without losing warning results', async () => { + const { fixture, warm, graph } = await startAsync({ cache: true }); + fixture.write('b/input.txt', 'warning'); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 1 } }); + const operation = test!.operation('b'); + const record: IOperationExecutionResult = graph.resultByOperation.get(operation)!; + const nativeRecord: unknown = record; + if (!(nativeRecord instanceof OperationExecutionRecord)) throw new Error('Expected a native execution record.'); + const hash: string = record.getStateHash(); + const warnings: string = record.stdioSummarizer.getReport(); + const duration: number = record.stopwatch.duration; + expect(record.getStateHashComponents().dependencies).toHaveLength(1); + expect(nativeRecord.eventSink).toBeDefined(); + expect(nativeRecord.environment).toBeDefined(); + expect( + graph.hooks.beforeDeleteResults.taps.some((tap) => tap.name === 'CacheablePhasedOperationPlugin') + ).toBe(true); + test!.protectedOperations.add(operation); + test!.update({ warmMemoryBudgetMB: 0.01 }); + await warm.maintainAsync(); + expect(graph.resultByOperation.get(operation)).toBe(record); + expect(graph.resultByOperation.has(test!.operation('a'))).toBe(false); + expect(nativeRecord.dependencies).toEqual(new Set()); + expect(nativeRecord.consumers).toEqual(new Set()); + expect(nativeRecord.eventSink).toBeUndefined(); + expect(nativeRecord.environment).toBeUndefined(); + expect(nativeRecord.createChildProcessReporter()).toBeUndefined(); + expect(() => nativeRecord.collatedWriter).toThrow('Cannot reopen the output of a detached execution record.'); + expect(record.getStateHash()).toBe(hash); + expect(record.stopwatch.duration).toBe(duration); + expect(record.stdioSummarizer.getReport()).toBe(warnings); + expect(warnings).toContain('warning-b'); + expect(record.status).toBe(OperationStatus.SuccessWithWarning); + expect(operation.dependencies.has(test!.operation('a'))).toBe(true); + test!.update({}); + fixture.write('a/input.txt', 'after-cache-state-release'); + expect((await fixture.buildAsync()).terminal).toMatchObject({ payload: { exitCode: 1 } }); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe( + 'after-cache-state-release' + ); + }); + + it('surfaces a native plugin resource-release error without removing completed results', async () => { + const { warm, graph, watcher } = await startAsync({ ipc: true }); + const operation = test!.operation('a'); + test!.protectedOperations.add(test!.operation('b')); + const record: IOperationExecutionResult = graph.resultByOperation.get(operation)!; + let fail: boolean = true; + graph.hooks.beforeDeleteResults.tap('release-error-test', () => { + if (fail) throw new Error('plugin-resource-release-failed'); + }); + test!.update({ warmMemoryBudgetMB: 0.01 }); + const status = await warm.maintainAsync(); + expect(status.cleanupFailures[0]).toContain('plugin-resource-release-failed'); + expect(graph.resultByOperation.get(operation)).toBe(record); + expect(record.status).toBe(OperationStatus.Success); + expect(operation.runner?.isActive).toBe(false); + expect(watcher.watchedProjectNames.has('a')).toBe(false); + fail = false; + await warm.maintainAsync(); + expect(graph.resultByOperation.has(operation)).toBe(false); + }); + + it.each(['direct', 'rig', 'inherited'] as const)( + 'does not lose cold %s configuration changes: fails closed, then a fresh generation builds correctly', + async (configurationKind) => { + const { fixture, warm, graph } = await startAsync({ configurationKind }); + test!.update({ warmMemoryBudgetMB: 0.01 }); + await warm.maintainAsync(); + expect(graph.resultByOperation.size).toBe(0); + const configPath: string = + configurationKind === 'direct' + ? 'a/config/rush-project.json' + : configurationKind === 'rig' + ? 'a/node_modules/fixture-rig/profiles/default/config/rush-project.json' + : 'common/temp/inherited.json'; + fixture.write( + configPath, + JSON.stringify({ + operationSettings: [ + { + operationName: '_phase:compile', + outputFolderNames: ['lib'], + disableBuildCacheForOperation: true + } + ] + }) + ); + fixture.write('a/input.txt', 'new-configuration'); + const result = await fixture.buildAsync(); + expect(result.terminal).toMatchObject({ + kind: 'requestRejected', + payload: { code: 'workspaceRecreationRequired' } + }); + expect(fixture.runs()).toEqual(['a', 'b']); + test!.update({}); + await test!.restartAsync(); + await fixture.buildSuccessfullyAsync(); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe( + 'new-configuration' + ); + } + ); + + it('validates updates, rejects duplicate attachment and stops its timer without disposing generation resources', async () => { + const { fixture, warm, graph, watcher } = await startAsync(); + expect(() => warm.updateConfiguration({ warmSetMaxProjects: 0 })).toThrow(); + expect(() => + WorkspaceWarmSet.attach({ + operationGraph: graph, + watcher, + configuration: {}, + scheduler: getWorkspaceRequestScheduler(fixture.session), + acquireExecutionLeaseAsync: () => fixture.session.acquireExecutionLeaseAsync() + }) + ).toThrow('already attached'); + await warm[Symbol.asyncDispose](); + expect((await warm.maintainAsync()).deferredReason).toBe('disposed'); + expect(graph.resultByOperation.size).toBe(2); + expect(watcher.watchedProjectNames.size).toBe(2); + expect(() => warm.updateConfiguration({})).toThrow('disposed'); + }); +}); + +async function eventuallyAsync(assert: () => void): Promise { + const deadline: number = performance.now() + 5000; + for (;;) { + try { + assert(); + return; + } catch (error) { + if (performance.now() >= deadline) throw error; + await delayAsync(10); + } + } +} diff --git a/libraries/rush-daemon/src/test/WorkspaceWatchPolicy.test.ts b/libraries/rush-daemon/src/test/WorkspaceWatchPolicy.test.ts new file mode 100644 index 0000000000..fcef21ab5c --- /dev/null +++ b/libraries/rush-daemon/src/test/WorkspaceWatchPolicy.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { RequestExclusivityClass } from '../RequestScheduler'; +import { getWorkspaceRequestScheduler } from '../WorkspaceRequestAdmission'; +import { DaemonGraphTestFixture } from './DaemonGraphTestFixture'; +import { eventuallyAsync, pongAsync, setDaemonPolicy } from './WarmGenerationTestUtilities'; +import { createScript, WarmSetTestFixture } from './WarmSetTestFixture'; + +jest.setTimeout(30_000); + +async function createFixtureAsync(watch?: boolean): Promise { + return await DaemonGraphTestFixture.createAsync((fixture) => { + setDaemonPolicy(fixture, { watch }); + fixture.write('.gitignore', 'common/temp/\n**/.rush/\n**/rush-logs/\n**/lib/\nruns.txt\n'); + for (const name of ['a', 'b', 'c']) fixture.write(`${name}/build.cjs`, createScript(name, false)); + }); +} + +describe('daemon.watch observation-only policy', () => { + let previousWatch: string | undefined; + beforeEach(() => { + previousWatch = process.env.RUSH_DAEMON_WATCH; + delete process.env.RUSH_DAEMON_WATCH; + }); + afterEach(() => { + if (previousWatch === undefined) delete process.env.RUSH_DAEMON_WATCH; + else process.env.RUSH_DAEMON_WATCH = previousWatch; + }); + + it('defaults to root/config guards only and still rebuilds unwatched source/config changes correctly', async () => { + const fixture = await createFixtureAsync(); + try { + await fixture.buildSuccessfullyAsync(); + const graph = fixture.session.operationGraph!; + const generation: number = fixture.host.workspaceGeneration; + expect((await pongAsync(fixture)).workspace?.warmSet).toMatchObject({ + configuration: { watch: false }, + watchedProjectNames: [], + retainedProjectNames: ['a', 'b'] + }); + fixture.write('a/input.txt', 'changed-unwatched'); + await fixture.buildSuccessfullyAsync(); + expect(fixture.session.operationGraph).toBe(graph); + expect(fixture.host.workspaceGeneration).toBe(generation); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe( + 'changed-unwatched' + ); + + fixture.write( + 'a/config/rush-project.json', + JSON.stringify({ + operationSettings: [{ operationName: '_phase:compile', outputFolderNames: ['lib'] }] + }) + ); + fixture.write('a/input.txt', 'changed-config'); + await fixture.buildSuccessfullyAsync(); + expect(fixture.host.workspaceGeneration).toBeGreaterThan(generation); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe('changed-config'); + expect(fixture.session.warmSetStatus?.watchedProjectNames).toEqual([]); + + fixture.write('common/config/rush/watch-probe.json', '{}'); + await eventuallyAsync(() => + expect(fixture.session.invalidations.getSnapshot().changedPaths).toContain( + path.join(fixture.folder, 'common/config/rush/watch-probe.json') + ) + ); + fixture.write('rush.json', fs.readFileSync(path.join(fixture.folder, 'rush.json'), 'utf8')); + await eventuallyAsync(() => + expect(fixture.session.invalidations.getSnapshot().changedPaths).toContain( + path.join(fixture.folder, 'rush.json') + ) + ); + expect(fixture.runs()).toEqual(['a', 'b', 'a', 'b', 'a', 'b']); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('observes requested warm projects between requests when enabled, without scheduling scripts', async () => { + const fixture = await createFixtureAsync(true); + try { + await fixture.buildSuccessfullyAsync(); + const graph = fixture.session.operationGraph!; + expect(fixture.session.warmSetStatus?.watchedProjectNames).toEqual(['a', 'b']); + fixture.write('a/input.txt', 'observed-idle-change'); + await eventuallyAsync(() => + expect(fixture.session.invalidations.getSnapshot().changedPaths).toContain( + path.join(fixture.folder, 'a/input.txt') + ) + ); + expect(graph.hasScheduledIteration).toBe(false); + expect(fixture.runs()).toEqual(['a', 'b']); + expect((await pongAsync(fixture)).workspace?.warmSet?.configuration.watch).toBe(true); + await fixture.buildSuccessfullyAsync(); + expect(fs.readFileSync(path.join(fixture.folder, 'a/lib/output.txt'), 'utf8')).toBe( + 'observed-idle-change' + ); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it.each([ + { json: false, environment: '1', expected: ['a', 'b'] }, + { json: true, environment: '0', expected: [] } + ])('honors the existing environment override: %p', async ({ json, environment, expected }) => { + process.env.RUSH_DAEMON_WATCH = environment; + const fixture = await createFixtureAsync(json); + try { + await fixture.buildSuccessfullyAsync(); + expect(fixture.session.warmSetStatus?.watchedProjectNames).toEqual(expected); + expect(fixture.session.warmSetStatus?.configuration.watch).toBe(environment === '1'); + } finally { + await fixture[Symbol.asyncDispose](); + } + }); + + it('closes and restores project observation without dropping native results or warm IPC runners', async () => { + const resources = await WarmSetTestFixture.createAsync({ ipc: true }); + try { + await resources.fixture.buildSuccessfullyAsync(); + const records = new Map(resources.graph.resultByOperation); + const closeRunners = jest.spyOn(resources.graph, 'closeRunnersAsync'); + resources.update({ watch: false }); + expect((await resources.warm.maintainAsync()).watchedProjectNames).toEqual([]); + expect(resources.graph.resultByOperation).toEqual(records); + expect(resources.operation('a').runner?.isActive).toBe(true); + expect(resources.operation('b').runner?.isActive).toBe(true); + expect(closeRunners).not.toHaveBeenCalled(); + resources.update({ watch: true }); + expect((await resources.warm.maintainAsync()).watchedProjectNames).toEqual(['a', 'b']); + for (const [operation, record] of records) + expect(resources.graph.resultByOperation.get(operation)).toBe(record); + expect(resources.fixture.runs()).toEqual(['a', 'b']); + closeRunners.mockRestore(); + } finally { + await resources[Symbol.asyncDispose](); + } + }); + + it('defers teardown behind active/protected work and diagnoses failed closes without false accounting', async () => { + const resources = await WarmSetTestFixture.createAsync({ ipc: true }); + try { + await resources.fixture.buildSuccessfullyAsync(); + const scheduler = getWorkspaceRequestScheduler(resources.fixture.session); + const active = await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.SharedBuild }); + resources.update({ watch: false }); + try { + expect((await resources.warm.maintainAsync()).deferredReason).toBe('workspace-busy'); + expect(resources.watcher.watchedProjectNames.size).toBe(2); + } finally { + active.release(); + } + const protection = await scheduler.acquireAsync({ + exclusivityClass: RequestExclusivityClass.Exclusive + }); + resources.protectedOperations.add(resources.operation('a')); + protection.release(); + expect((await resources.warm.maintainAsync()).watchedProjectNames).toEqual(['a']); + const unprotect = await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive }); + resources.protectedOperations.clear(); + unprotect.release(); + const close = jest + .spyOn(resources.watcher, 'unwatchProjectsAsync') + .mockRejectedValueOnce(new Error('policy-close-failed')); + try { + const failed = await resources.warm.maintainAsync(); + expect(failed.watchedProjectNames).toEqual(['a']); + expect(failed.cleanupFailures).toEqual([expect.stringContaining('policy-close-failed')]); + expect(resources.graph.resultByOperation.size).toBe(2); + expect(resources.diagnostics.some((error) => error.message.includes('daemon.watch'))).toBe(true); + const recovered = await resources.warm.maintainAsync(); + expect(recovered.watchedProjectNames).toEqual([]); + expect(recovered.cleanupFailures).toEqual([]); + await resources.fixture.buildSuccessfullyAsync(); + } finally { + close.mockRestore(); + } + } finally { + await resources[Symbol.asyncDispose](); + } + }); + + it('does not alter observation while an unstarted native iteration is prepared', async () => { + const resources = await WarmSetTestFixture.createAsync({ ipc: true }); + try { + await resources.fixture.buildSuccessfullyAsync(); + const session = resources.fixture.session; + const scheduler = getWorkspaceRequestScheduler(session); + const admission = await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive }); + const native = await session.acquireExecutionLeaseAsync(); + try { + resources.graph.pauseNextIteration = true; + resources.graph.invalidateOperations(); + await resources.graph.scheduleIterationAsync({ inputsSnapshot: session.inputsSnapshot }); + } finally { + await native?.[Symbol.asyncDispose](); + admission.release(); + } + resources.update({ watch: false }); + expect((await resources.warm.maintainAsync()).deferredReason).toBe('graph-busy'); + expect(resources.watcher.watchedProjectNames.size).toBe(2); + expect(resources.graph.hasScheduledIteration).toBe(true); + const discard = await scheduler.acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive }); + try { + resources.graph.discardScheduledIteration(); + } finally { + discard.release(); + } + expect((await resources.warm.maintainAsync()).watchedProjectNames).toEqual([]); + expect(resources.fixture.runs()).toEqual(['a', 'b']); + } finally { + await resources[Symbol.asyncDispose](); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationDaemon.ts b/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationDaemon.ts new file mode 100644 index 0000000000..a74d5eea60 --- /dev/null +++ b/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationDaemon.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as path from 'node:path'; + +import { Rush } from '@microsoft/rush-lib'; +import { PackageJsonLookup } from '@rushstack/node-core-library'; + +import { ProductionDaemonRequestResolver } from '../../ProductionDaemonRequestResolver'; +import { RushDaemonRequestResolver } from '../../RushDaemonRequestResolver'; +import { WorkspaceSession } from '../../WorkspaceSession'; +import { serveRushDaemonAsync } from '../../serveRushDaemon'; +import { + getInstalledWorkspaceSuccessorLaunchAsync, + type IWorkspaceSuccessorLaunch +} from '../../WorkspaceProcessRestart'; + +interface IControl { + readonly disposalPort: number; +} + +async function runAsync(): Promise { + const repoRoot: string | undefined = process.argv[2]; + const controlFolder: string | undefined = process.argv[3]; + if (!repoRoot || !controlFolder) + throw new Error('The standalone fixture needs repository and control folders.'); + const controlFile: string = path.join(controlFolder, 'mutation.json'); + await serveRushDaemonAsync({ + repoRoot, + rushVersion: Rush.version, + daemonVersion: PackageJsonLookup.loadOwnPackageJson(__dirname).version, + requestResolver: new RushDaemonRequestResolver(new ProductionDaemonRequestResolver()), + getSuccessorLaunchAsync: async (context) => { + const launch: IWorkspaceSuccessorLaunch = await getInstalledWorkspaceSuccessorLaunchAsync(context); + return { + ...launch, + startCommand: { + ...launch.startCommand, + args: [ + '--require', + path.join(__dirname, 'SuccessfulMutationSuccessorMarker.js'), + ...launch.startCommand.args + ] + } + }; + }, + createWorkspaceSessionAsync: async (options) => { + const session: WorkspaceSession = await WorkspaceSession.createAsync(options); + const disposeAsync: () => Promise = session[Symbol.asyncDispose].bind(session); + let disposal: Promise | undefined; + session[Symbol.asyncDispose] = (): Promise => { + disposal ??= (async () => { + if (session.operationGraph && fs.existsSync(controlFile)) { + const control: IControl = JSON.parse(fs.readFileSync(controlFile, 'utf8')); + await pauseDisposalAsync(control.disposalPort); + await disposeAsync(); + fs.appendFileSync(path.join(controlFolder, 'events.txt'), 'old-resources-disposed\n'); + } else { + await disposeAsync(); + } + })(); + return disposal; + }; + return session; + }, + onReady: (host) => { + const filename: string = path.join(controlFolder, 'ready.json'); + fs.writeFileSync(`${filename}.tmp`, JSON.stringify({ paths: host.paths, pid: process.pid })); + fs.renameSync(`${filename}.tmp`, filename); + }, + onError: (error) => process.stderr.write(`${error.stack ?? error.message}\n`) + }); +} + +function pauseDisposalAsync(port: number): Promise { + return new Promise((resolve, reject) => { + const socket: net.Socket = net.connect(port, '127.0.0.1'); + socket.once('error', reject); + socket.once('data', () => { + socket.end(); + resolve(); + }); + }); +} + +if (require.main === module) { + void runAsync().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationSuccessorMarker.ts b/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationSuccessorMarker.ts new file mode 100644 index 0000000000..256d627a78 --- /dev/null +++ b/libraries/rush-daemon/src/test/fixtures/SuccessfulMutationSuccessorMarker.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// A preload observes actual process startup; the successor still runs the installed rushd entrypoint. +const controlFolder: string | undefined = process.env.RUSHD_MUTATION_TEST_CONTROL; +if (!controlFolder) throw new Error('The successor observation fixture needs its control folder.'); +fs.appendFileSync(path.join(controlFolder, 'events.txt'), 'successor-process-started\n'); diff --git a/libraries/rush-lib/assets/rush-init/rush.json b/libraries/rush-lib/assets/rush-init/rush.json index 4cf8209cc2..33fec40008 100644 --- a/libraries/rush-lib/assets/rush-init/rush.json +++ b/libraries/rush-lib/assets/rush-init/rush.json @@ -329,6 +329,28 @@ }, /*[END "HYPOTHETICAL"]*/ + /** + * The separate rush-client/rushx-client binaries are opt-in. Existing rush/rushx are unchanged. + * Precedence: RUSH_DAEMON* environment variables, then this block, then the defaults shown. + * CI uses in-process execution unless RUSH_DAEMON=1 explicitly opts in. + * Boolean environment overrides accept only 0 or 1; numeric overrides use decimal numbers. + * Warm-set policies require the generation-owned WorkspaceWarmSet host attachment. + * They retain requested work without starting scripts and impose no hard process-RSS ceiling. + * The watch option retains host observation of warm project files between requests, without running builds. + */ + /*[LINE "HYPOTHETICAL"]*/ "daemon": { + /*[LINE "HYPOTHETICAL"]*/ "enabled": false, + /*[LINE "HYPOTHETICAL"]*/ "idleTimeoutSeconds": 900, + /*[LINE "HYPOTHETICAL"]*/ "autoStart": true, + /*[LINE "HYPOTHETICAL"]*/ "watch": false, + /*[LINE "HYPOTHETICAL"]*/ "usePersistentIpcRunners": false, + /*[LINE "HYPOTHETICAL"]*/ "queueTimeoutSeconds": 30, + /*[LINE "HYPOTHETICAL"]*/ "warmIdleTimeoutSeconds": 300, + /*[LINE "HYPOTHETICAL"]*/ "warmMemoryBudgetMB": 512, + /*[LINE "HYPOTHETICAL"]*/ "warmSetMaxProjects": 20, + /*[LINE "HYPOTHETICAL"]*/ "autoWarmByTelemetry": false + /*[LINE "HYPOTHETICAL"]*/ }, + /** * Allows creation of hotfix changes. This feature is experimental so it is disabled by default. * If this is set, 'rush change' only allows a 'hotfix' change type to be specified. This change type diff --git a/libraries/rush-lib/src/api/DaemonConfiguration.ts b/libraries/rush-lib/src/api/DaemonConfiguration.ts new file mode 100644 index 0000000000..582c57f83e --- /dev/null +++ b/libraries/rush-lib/src/api/DaemonConfiguration.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** The opt-in `rush.json` daemon block. Environment overrides take precedence. @beta */ +export interface IDaemonConfigurationJson { + /** Enables the separate client. Defaults to false; existing rush/rushx are unchanged. */ + readonly enabled?: boolean; + /** Idle shutdown timeout in seconds. Defaults to 900. */ + readonly idleTimeoutSeconds?: number; + /** Start an absent daemon. Defaults to true, but only for opted-in invocations. */ + readonly autoStart?: boolean; + /** Retains host file observation for warm projects between requests; never schedules builds. Defaults to false. */ + readonly watch?: boolean; + /** Enables explicit operationSettings[].daemonIpc Node runners for daemon builds. Defaults to false. */ + readonly usePersistentIpcRunners?: boolean; + /** Maximum admission queue wait in seconds. Defaults to 30. */ + readonly queueTimeoutSeconds?: number; + /** Idle resource expiration in an attached daemon warm set. Defaults to 300 seconds. */ + readonly warmIdleTimeoutSeconds?: number; + /** Best-effort sampled RSS budget for an attached warm set, not a hard ceiling. Defaults to 512 MiB. */ + readonly warmMemoryBudgetMB?: number; + /** Best-effort retained project limit; active/protected work is exempt. Defaults to 20 projects. */ + readonly warmSetMaxProjects?: number; + /** Prefer measured time-saved * frequency / resident-memory retention over LRU. Never starts scripts. Defaults to false. */ + readonly autoWarmByTelemetry?: boolean; +} + +const defaults: Required = { + enabled: false, + idleTimeoutSeconds: 900, + autoStart: true, + watch: false, + usePersistentIpcRunners: false, + queueTimeoutSeconds: 30, + warmIdleTimeoutSeconds: 300, + warmMemoryBudgetMB: 512, + warmSetMaxProjects: 20, + autoWarmByTelemetry: false +}; + +/** The exact recognized environment names. Unknown RUSH_DAEMON* names are rejected. @beta */ +export const daemonEnvironmentVariables: Readonly> = + Object.freeze({ + enabled: 'RUSH_DAEMON', + idleTimeoutSeconds: 'RUSH_DAEMON_IDLE_TIMEOUT_SECONDS', + autoStart: 'RUSH_DAEMON_AUTO_START', + watch: 'RUSH_DAEMON_WATCH', + usePersistentIpcRunners: 'RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS', + queueTimeoutSeconds: 'RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS', + warmIdleTimeoutSeconds: 'RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS', + warmMemoryBudgetMB: 'RUSH_DAEMON_WARM_MEMORY_BUDGET_MB', + warmSetMaxProjects: 'RUSH_DAEMON_WARM_SET_MAX_PROJECTS', + autoWarmByTelemetry: 'RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY' + }); + +/** + * Validates and snapshots daemon configuration, using environment, then config, then defaults. + * @remarks Warm policies are consumed by generation-owned WorkspaceWarmSet attachments. They do not alter native CLI execution. + * @beta + */ +export function resolveDaemonConfiguration( + json: IDaemonConfigurationJson = {}, + environment: Readonly> = process.env +): Readonly> { + if (typeof json !== 'object' || json === null || Array.isArray(json)) { + throw new Error('The rush.json daemon block must be an object.'); + } + for (const key of Object.keys(json)) { + if (!Object.prototype.hasOwnProperty.call(defaults, key)) + throw new Error(`Unknown daemon option "${key}".`); + } + const knownNames: Set = new Set([ + ...Object.values(daemonEnvironmentVariables), + 'RUSH_DAEMON_EXPERIMENTAL' + ]); + for (const [name, value] of Object.entries(environment)) { + if (name.startsWith('RUSH_DAEMON') && value !== undefined && !knownNames.has(name)) { + throw new Error(`Unknown daemon environment variable "${name}".`); + } + } + const experimental: string | undefined = environment.RUSH_DAEMON_EXPERIMENTAL; + if (experimental !== undefined && experimental !== '0' && experimental !== '1') { + throw new Error('RUSH_DAEMON_EXPERIMENTAL must be 0 or 1.'); + } + return Object.freeze({ + enabled: booleanOption('enabled', json, environment), + autoStart: booleanOption('autoStart', json, environment), + watch: booleanOption('watch', json, environment), + usePersistentIpcRunners: booleanOption('usePersistentIpcRunners', json, environment), + autoWarmByTelemetry: booleanOption('autoWarmByTelemetry', json, environment), + idleTimeoutSeconds: numberOption('idleTimeoutSeconds', json, environment), + queueTimeoutSeconds: numberOption('queueTimeoutSeconds', json, environment), + warmIdleTimeoutSeconds: numberOption('warmIdleTimeoutSeconds', json, environment), + warmMemoryBudgetMB: numberOption('warmMemoryBudgetMB', json, environment), + warmSetMaxProjects: numberOption('warmSetMaxProjects', json, environment) + }); +} + +function booleanOption( + key: 'enabled' | 'autoStart' | 'watch' | 'autoWarmByTelemetry' | 'usePersistentIpcRunners', + json: IDaemonConfigurationJson, + environment: Readonly> +): boolean { + if (json[key] !== undefined && typeof json[key] !== 'boolean') + throw new Error(`daemon.${key} must be a boolean.`); + const name: string = daemonEnvironmentVariables[key]; + const value: string | undefined = environment[name]; + if (value === undefined) return json[key] ?? defaults[key]; + if (value !== '0' && value !== '1') throw new Error(`${name} must be 0 or 1.`); + return value === '1'; +} + +function numberOption( + key: + | 'idleTimeoutSeconds' + | 'queueTimeoutSeconds' + | 'warmIdleTimeoutSeconds' + | 'warmMemoryBudgetMB' + | 'warmSetMaxProjects', + json: IDaemonConfigurationJson, + environment: Readonly> +): number { + const maximum: number = key.endsWith('Seconds') ? 2147483.647 : Number.MAX_SAFE_INTEGER; + const minimum: number = key === 'queueTimeoutSeconds' ? 0 : Number.MIN_VALUE; + const validate = (value: number, label: string): number => { + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < minimum || + value > maximum || + (key === 'warmSetMaxProjects' && !Number.isSafeInteger(value)) + ) { + throw new Error( + `${label} must be ${key === 'warmSetMaxProjects' ? 'an integer' : 'a finite number'} ${minimum === 0 ? '>=' : '>'} 0 and <= ${maximum}.` + ); + } + return value; + }; + if (json[key] !== undefined) validate(json[key], `daemon.${key}`); + const name: string = daemonEnvironmentVariables[key]; + const value: string | undefined = environment[name]; + if (value === undefined) return json[key] ?? defaults[key]; + if (value.trim() === '' || !/^\d+(?:\.\d+)?$/.test(value)) + throw new Error(`${name} must be a decimal number.`); + return validate(Number(value), name); +} diff --git a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts index e2b461d199..b8f3357b17 100644 --- a/libraries/rush-lib/src/api/EnvironmentConfiguration.ts +++ b/libraries/rush-lib/src/api/EnvironmentConfiguration.ts @@ -7,6 +7,7 @@ import { trueCasePathSync } from 'true-case-path'; import type { IEnvironment } from '../utilities/Utilities'; import { IS_WINDOWS } from '../utilities/executionUtilities'; +import { resolveDaemonConfiguration } from './DaemonConfiguration'; /** * @beta @@ -37,6 +38,17 @@ export const EnvironmentVariableNames = { */ RUSH_PREVIEW_VERSION: 'RUSH_PREVIEW_VERSION', + /** + * Frontend-owned reporter selection, including the legacy emergency override. + * Engines recognize this variable without consuming its value. + */ + RUSH_REPORTER: 'RUSH_REPORTER', + + /** + * Frontend-owned reporter verbosity. Engines recognize this variable without consuming its value. + */ + RUSH_LOG_LEVEL: 'RUSH_LOG_LEVEL', + /** * If this variable is set to "1", Rush will not fail the build when running a version * of Node that does not match the criteria specified in the "nodeSupportedVersionRange" @@ -252,7 +264,30 @@ export const EnvironmentVariableNames = { * to `rush`, `rushx`, and `install-run-rush.ts`. It suppresses informational startup messages * while preserving error output. */ - RUSH_QUIET_MODE: 'RUSH_QUIET_MODE' + RUSH_QUIET_MODE: 'RUSH_QUIET_MODE', + + /** Enables the opt-in standalone daemon client. */ + RUSH_DAEMON: 'RUSH_DAEMON', + /** Overrides the daemon idle shutdown timeout. */ + RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: 'RUSH_DAEMON_IDLE_TIMEOUT_SECONDS', + /** Allows the opted-in client to start an absent daemon. */ + RUSH_DAEMON_AUTO_START: 'RUSH_DAEMON_AUTO_START', + /** Controls persistent daemon observation of warm project files, without scheduling builds. */ + RUSH_DAEMON_WATCH: 'RUSH_DAEMON_WATCH', + /** Enables explicitly configured persistent Node IPC operations in the daemon. */ + RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS: 'RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS', + /** Overrides the request admission queue timeout. */ + RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS: 'RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS', + /** Overrides idle eviction in an attached daemon warm set. */ + RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS: 'RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS', + /** Overrides the attached warm set's best-effort sampled RSS budget. */ + RUSH_DAEMON_WARM_MEMORY_BUDGET_MB: 'RUSH_DAEMON_WARM_MEMORY_BUDGET_MB', + /** Overrides the attached warm set's retained project limit. */ + RUSH_DAEMON_WARM_SET_MAX_PROJECTS: 'RUSH_DAEMON_WARM_SET_MAX_PROJECTS', + /** Enables telemetry-weighted retention of requested work in an attached warm set. */ + RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY: 'RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY', + /** Gates the experimental graph client; requires host graph integration. */ + RUSH_DAEMON_EXPERIMENTAL: 'RUSH_DAEMON_EXPERIMENTAL' } as const; /** @@ -511,6 +546,7 @@ export class EnvironmentConfiguration { */ public static validate(options: IEnvironmentConfigurationInitializeOptions = {}): void { EnvironmentConfiguration.reset(); + resolveDaemonConfiguration(); const unknownEnvVariables: string[] = []; for (const envVarName in process.env) { @@ -651,8 +687,24 @@ export class EnvironmentConfiguration { break; } + case EnvironmentVariableNames.RUSH_DAEMON: + case EnvironmentVariableNames.RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: + case EnvironmentVariableNames.RUSH_DAEMON_AUTO_START: + case EnvironmentVariableNames.RUSH_DAEMON_WATCH: + case EnvironmentVariableNames.RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS: + case EnvironmentVariableNames.RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS: + case EnvironmentVariableNames.RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS: + case EnvironmentVariableNames.RUSH_DAEMON_WARM_MEMORY_BUDGET_MB: + case EnvironmentVariableNames.RUSH_DAEMON_WARM_SET_MAX_PROJECTS: + case EnvironmentVariableNames.RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY: + case EnvironmentVariableNames.RUSH_DAEMON_EXPERIMENTAL: + // Validated together by resolveDaemonConfiguration(). + break; + case EnvironmentVariableNames.RUSH_PARALLELISM: case EnvironmentVariableNames.RUSH_PREVIEW_VERSION: + case EnvironmentVariableNames.RUSH_REPORTER: + case EnvironmentVariableNames.RUSH_LOG_LEVEL: case EnvironmentVariableNames.RUSH_VARIANT: case EnvironmentVariableNames.RUSH_DEPLOY_TARGET_FOLDER: // Handled by @microsoft/rush front end diff --git a/libraries/rush-lib/src/api/PhasedCommandEngine.ts b/libraries/rush-lib/src/api/PhasedCommandEngine.ts new file mode 100644 index 0000000000..0fefc3fddf --- /dev/null +++ b/libraries/rush-lib/src/api/PhasedCommandEngine.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, LockFile } from '@rushstack/node-core-library'; +import type { ITerminalProvider } from '@rushstack/terminal'; +import type { CommandLineAction } from '@rushstack/ts-command-line'; + +import { RushCommandLineParser } from '../cli/RushCommandLineParser'; +import { PhasedScriptAction } from '../cli/scriptActions/PhasedScriptAction'; +import type { GetInputsSnapshotAsyncFn, IInputsSnapshot } from '../logic/incremental/InputsSnapshot'; +import type { IOperationGraph } from '../logic/operations/IOperationGraph'; +import type { Operation, OperationEnabledState } from '../logic/operations/Operation'; +import { PhasedCommandEngineExecution } from '../logic/operations/PhasedCommandEngineExecution'; +import type { RushSession } from '../pluginFramework/RushSession'; +import type { RushConfiguration } from './RushConfiguration'; +import { RushUserConfiguration } from './RushUserConfiguration'; +import { PhasedCommandEngineBusyError } from './PhasedCommandEngineBusyError'; +import { resolvePhasedCommandCwdAsync } from '../utilities/resolvePhasedCommandCwd'; + +/** + * A native phased command graph prepared without executing an iteration. + * @alpha + */ +export interface IPhasedCommandEngine extends AsyncDisposable { + [Symbol.asyncDispose](): Promise; + /** Acquire once per coalesced iteration, before input reconciliation; dispose after output and runner cleanup. */ + readonly acquireExecutionLeaseAsync?: () => Promise; + readonly operationGraph: IOperationGraph; + readonly rushSession: RushSession; + readonly inputsSnapshot: IInputsSnapshot; + readonly getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; + readonly phaseNames: ReadonlyArray; + readonly pluginNames: ReadonlyArray; + readonly isIncremental: boolean; +} + +/** Options for parsing a command for a long-lived engine host. @alpha */ +export interface IParsePhasedCommandOptions { + readonly argv: ReadonlyArray; + readonly cwd: string; + readonly rushConfiguration: RushConfiguration; + readonly terminalProvider: ITerminalProvider; +} + +/** + * A parsed native build/rebuild command. Parsing never runs scripts or changes cwd/process.env. + * + * @remarks + * The initial engine surface deliberately rejects watch/install, event-hook scripts, .env files, and + * externally supplied plugins. Those require request-scoped initialization and asynchronous disposal + * contracts before they can safely run in a shared process. Native graph/cache plugins are not replaced. + * @alpha + */ +export class PhasedCommandEngine { + private readonly _parser: RushCommandLineParser; + private readonly _action: PhasedScriptAction; + private _created: boolean = false; + + public readonly parameterIdentity: string; + public readonly commandName: string; + + private constructor(parser: RushCommandLineParser, action: PhasedScriptAction) { + this._parser = parser; + this._action = action; + this.commandName = action.actionName; + this.parameterIdentity = action.getEngineParameterIdentity(); + } + + public static async parseAsync(options: IParsePhasedCommandOptions): Promise { + const { rushConfiguration, terminalProvider, cwd, argv } = options; + const resolvedCwd: string = await resolvePhasedCommandCwdAsync(cwd, rushConfiguration.rushJsonFolder); + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { + throw new Error('Command help must be handled by the native CLI, not by an engine request.'); + } + if (rushConfiguration._rushPluginsConfiguration.configuration.plugins.length > 0) { + throw new Error('Daemon engine execution does not yet support external Rush plugins. Use --no-daemon.'); + } + for (const folder of [rushConfiguration.rushJsonFolder, RushUserConfiguration.getRushUserFolderPath()]) { + if (FileSystem.exists(path.join(folder, '.env'))) { + throw new Error('Daemon engine execution does not yet support .env initialization. Use --no-daemon.'); + } + } + const parser: RushCommandLineParser = new RushCommandLineParser({ + cwd: resolvedCwd, + engine: { rushConfiguration, terminalProvider } + }); + await parser.executeWithoutErrorHandlingAsync([...argv]); + const action: CommandLineAction | undefined = parser.selectedAction; + if (!(action instanceof PhasedScriptAction) || !['build', 'rebuild'].includes(action.actionName)) { + throw new Error('The production daemon engine currently supports native build and rebuild only.'); + } + action.validateEngineCommand(); + return new PhasedCommandEngine(parser, action); + } + + /** + * Creates the all-project graph through the native CLI preparation pipeline. + * Releases the preparation lock before returning. Hosts must acquire an execution lease around each iteration. + */ + public async createEngineAsync(preparationLock?: LockFile): Promise { + if (this._created) { + throw new Error('This parsed command has already created its engine.'); + } + const lockFolder: string = this._parser.rushConfiguration.commonTempFolder; + if ( + preparationLock && + (preparationLock.isReleased || + preparationLock.filePath !== LockFile.getLockFilePath(lockFolder, 'rush')) + ) { + throw new Error('The borrowed preparation lock is not held for this workspace.'); + } + const lock: LockFile | undefined = preparationLock ?? LockFile.tryAcquire(lockFolder, 'rush'); + if (!lock) throw new PhasedCommandEngineBusyError(); + this._created = true; + let engine: IPhasedCommandEngine | undefined; + let releaseAttempted: boolean = false; + try { + await this._parser.pluginManager.tryInitializeUnassociatedPluginsAsync(); + engine = await this._action.createEngineAsync(); + if (!preparationLock) { + releaseAttempted = true; + lock.release(); + } + const execution: PhasedCommandEngineExecution = new PhasedCommandEngineExecution( + engine, + this._parser.rushConfiguration.commonTempFolder + ); + return { + ...engine, + acquireExecutionLeaseAsync: () => execution.acquireExecutionLeaseAsync(), + [Symbol.asyncDispose]: () => execution[Symbol.asyncDispose]() + }; + } catch (error) { + const cleanupErrors: unknown[] = []; + if (engine) { + try { + await engine[Symbol.asyncDispose](); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + if (!preparationLock && !releaseAttempted) { + try { + lock.release(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + 'Failed to prepare and clean up the native engine.' + ); + } + throw error; + } + } + + /** Resolves native project/phase selection against an existing, compatible graph. */ + public async selectOperationsAsync( + graph: IOperationGraph + ): Promise> { + return await this._action.selectEngineOperationsAsync(graph); + } +} diff --git a/libraries/rush-lib/src/api/PhasedCommandEngineBusyError.ts b/libraries/rush-lib/src/api/PhasedCommandEngineBusyError.ts new file mode 100644 index 0000000000..6267d6da3f --- /dev/null +++ b/libraries/rush-lib/src/api/PhasedCommandEngineBusyError.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Native Rush owns the workspace lock. No engine preparation or iteration has begun. + * A later explicit request may retry after that command finishes. + * @alpha + */ +export class PhasedCommandEngineBusyError extends Error { + public constructor() { + super('Another Rush command is already running in this repository.'); + this.name = 'PhasedCommandEngineBusyError'; + } +} diff --git a/libraries/rush-lib/src/api/PhasedCommandEngineConfigurationChangedError.ts b/libraries/rush-lib/src/api/PhasedCommandEngineConfigurationChangedError.ts new file mode 100644 index 0000000000..4536a154b7 --- /dev/null +++ b/libraries/rush-lib/src/api/PhasedCommandEngineConfigurationChangedError.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * A fresh native project configuration no longer matches the engine's graph and cache policy. + * The host must recreate the engine before executing more work. + * @alpha + */ +export class PhasedCommandEngineConfigurationChangedError extends Error { + public constructor() { + super('Resolved Rush project configuration changed; recreate the phased command engine.'); + this.name = 'PhasedCommandEngineConfigurationChangedError'; + } +} diff --git a/libraries/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index 472673f9b9..177116070e 100644 --- a/libraries/rush-lib/src/api/Rush.ts +++ b/libraries/rush-lib/src/api/Rush.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { InternalError, type IPackageJson, PackageJsonLookup } from '@rushstack/node-core-library'; +import { REPORTER_PROTOCOL_VERSION } from '@rushstack/rush-reporter'; import type { ITerminalProvider } from '@rushstack/terminal'; import '../utilities/SetRushLibPath'; @@ -174,6 +175,10 @@ export class Rush { */ } +Object.defineProperty(Rush, '_reporterProtocolMajor', { + value: REPORTER_PROTOCOL_VERSION.major +}); + function _ensureOwnPackageJsonIsLoaded(): void { if (!_rushLibPackageJsonCache) { const packageJsonFilePath: string | undefined = diff --git a/libraries/rush-lib/src/api/RushConfiguration.ts b/libraries/rush-lib/src/api/RushConfiguration.ts index 210fdd0151..2d2408a9eb 100644 --- a/libraries/rush-lib/src/api/RushConfiguration.ts +++ b/libraries/rush-lib/src/api/RushConfiguration.ts @@ -46,6 +46,7 @@ import type { PackageManagerOptionsConfigurationBase } from '../logic/base/BaseP import { CustomTipsConfiguration } from './CustomTipsConfiguration'; import { SubspacesConfiguration } from './SubspacesConfiguration'; import { Subspace } from './Subspace'; +import { resolveDaemonConfiguration, type IDaemonConfigurationJson } from './DaemonConfiguration'; const MINIMUM_SUPPORTED_RUSH_JSON_VERSION: string = '0.0.0'; const DEFAULT_BRANCH: string = 'main'; @@ -190,6 +191,7 @@ export interface IRushConfigurationJson { approvedPackagesPolicy?: IApprovedPackagesPolicyJson; gitPolicy?: IRushGitPolicyJson; telemetryEnabled?: boolean; + daemon?: IDaemonConfigurationJson; allowedProjectTags?: string[]; projects: IRushConfigurationProjectJson[]; eventHooks?: IEventHooksJson; @@ -539,6 +541,9 @@ export class RushConfiguration { */ public readonly telemetryEnabled: boolean; + /** Validated opt-in daemon settings. Environment overrides config and defaults. @beta */ + public readonly daemon: Readonly>; + /** * Repository settings used by the Rush reporter system. * @beta @@ -878,6 +883,7 @@ export class RushConfiguration { this.reportingConfiguration = { agentEnvironmentVariables: rushConfigurationJson.reporting?.agentEnvironmentVariables || [] }; + this.daemon = resolveDaemonConfiguration(rushConfigurationJson.daemon); this.eventHooks = new EventHooks(rushConfigurationJson.eventHooks || {}); this.versionPolicyConfigurationFilePath = path.join( diff --git a/libraries/rush-lib/src/api/RushProjectConfiguration.ts b/libraries/rush-lib/src/api/RushProjectConfiguration.ts index ad877338b9..9a621e544a 100644 --- a/libraries/rush-lib/src/api/RushProjectConfiguration.ts +++ b/libraries/rush-lib/src/api/RushProjectConfiguration.ts @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { AlreadyReportedError, Async, Path } from '@rushstack/node-core-library'; +import * as path from 'node:path'; + +import { AlreadyReportedError, Async, FileSystem, JsonFile, Path } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { ProjectConfigurationFile, InheritanceType } from '@rushstack/heft-config-file'; -import { RigConfig } from '@rushstack/rig-package'; +import { RigConfig, type IRigConfigJson } from '@rushstack/rig-package'; import type { RushConfigurationProject } from './RushConfigurationProject'; import { RushConstants } from '../logic/RushConstants'; @@ -83,6 +85,21 @@ export interface IRushPhaseSharding { */ export type NodeVersionGranularity = 'major' | 'minor' | 'patch'; +/** + * An explicitly selected, non-cacheable Node IPC tool for an unsharded daemon build operation. + * @alpha + */ +export interface IDaemonIpcConfiguration { + /** + * A project-root-relative .js, .cjs or .mjs entrypoint in a dedicated implementation subdirectory. + * The directory's complete file tree is fingerprinted; imports outside it (other than Node built-ins) + * are unsupported. Do not write build outputs or ordinary input files into this directory. + */ + entryPoint: string; + /** Literal tool arguments, followed by the operation's non-ignored raw custom parameters. */ + args?: string[]; +} + /** * @alpha */ @@ -91,6 +108,11 @@ export interface IOperationSettings { * The name of the operation. This should be a key in the `package.json`'s `scripts` object. */ operationName: string; + /** + * Explicit Node IPC launcher, used only with daemon.usePersistentIpcRunners for incremental daemon builds. + * Native shell, rebuild, missing-script/NoOp, and preassigned sharded runners remain unchanged. + */ + daemonIpc?: IDaemonIpcConfiguration; /** * Specify the folders where this operation writes its output files. If enabled, the Rush build @@ -187,88 +209,93 @@ interface IOldRushProjectJson { buildCacheOptions?: unknown; } -const RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = - new ProjectConfigurationFile({ - projectRelativeFilePath: `config/${RushConstants.rushProjectConfigFilename}`, - jsonSchemaObject: schemaJson, - propertyInheritance: { - operationSettings: { - inheritanceType: InheritanceType.custom, - inheritanceFunction: ( - child: IOperationSettings[] | undefined, - parent: IOperationSettings[] | undefined - ) => { - if (!child) { - return parent; - } else if (!parent) { - return child; - } else { - // Merge any properties that need to be merged - const resultOperationSettingsByOperationName: Map = new Map(); - for (const parentOperationSettings of parent) { - resultOperationSettingsByOperationName.set( - parentOperationSettings.operationName, - parentOperationSettings - ); - } - - const childEncounteredOperationNames: Set = new Set(); - for (const childOperationSettings of child) { - const operationName: string = childOperationSettings.operationName; - if (childEncounteredOperationNames.has(operationName)) { - // If the operation settings already exist, but didn't come from the parent, then - // it shows up multiple times in the child. - const childSourceFilePath: string = - RUSH_PROJECT_CONFIGURATION_FILE.getObjectSourceFilePath(child)!; - throw new Error( - `The operation "${operationName}" occurs multiple times in the "operationSettings" array ` + - `in "${childSourceFilePath}".` +function createProjectConfigurationFile(): ProjectConfigurationFile { + const configurationFile: ProjectConfigurationFile = + new ProjectConfigurationFile({ + projectRelativeFilePath: `config/${RushConstants.rushProjectConfigFilename}`, + jsonSchemaObject: schemaJson, + propertyInheritance: { + operationSettings: { + inheritanceType: InheritanceType.custom, + inheritanceFunction: ( + child: IOperationSettings[] | undefined, + parent: IOperationSettings[] | undefined + ) => { + if (!child) { + return parent; + } else if (!parent) { + return child; + } else { + // Merge any properties that need to be merged + const resultOperationSettingsByOperationName: Map = new Map(); + for (const parentOperationSettings of parent) { + resultOperationSettingsByOperationName.set( + parentOperationSettings.operationName, + parentOperationSettings ); } - childEncounteredOperationNames.add(operationName); - - let mergedOperationSettings: IOperationSettings | undefined = - resultOperationSettingsByOperationName.get(operationName); - if (mergedOperationSettings) { - // The parent operation settings object already exists - const outputFolderNames: string[] | undefined = - mergedOperationSettings.outputFolderNames && childOperationSettings.outputFolderNames - ? [ - ...mergedOperationSettings.outputFolderNames, - ...childOperationSettings.outputFolderNames - ] - : mergedOperationSettings.outputFolderNames || childOperationSettings.outputFolderNames; - - const dependsOnEnvVars: string[] | undefined = - mergedOperationSettings.dependsOnEnvVars && childOperationSettings.dependsOnEnvVars - ? [ - ...mergedOperationSettings.dependsOnEnvVars, - ...childOperationSettings.dependsOnEnvVars - ] - : mergedOperationSettings.dependsOnEnvVars || childOperationSettings.dependsOnEnvVars; - - mergedOperationSettings = { - ...mergedOperationSettings, - ...childOperationSettings, - ...(outputFolderNames ? { outputFolderNames } : {}), - ...(dependsOnEnvVars ? { dependsOnEnvVars } : {}) - }; - resultOperationSettingsByOperationName.set(operationName, mergedOperationSettings); - } else { - resultOperationSettingsByOperationName.set(operationName, childOperationSettings); + const childEncounteredOperationNames: Set = new Set(); + for (const childOperationSettings of child) { + const operationName: string = childOperationSettings.operationName; + if (childEncounteredOperationNames.has(operationName)) { + // If the operation settings already exist, but didn't come from the parent, then + // it shows up multiple times in the child. + const childSourceFilePath: string = configurationFile.getObjectSourceFilePath(child)!; + throw new Error( + `The operation "${operationName}" occurs multiple times in the "operationSettings" array ` + + `in "${childSourceFilePath}".` + ); + } + + childEncounteredOperationNames.add(operationName); + + let mergedOperationSettings: IOperationSettings | undefined = + resultOperationSettingsByOperationName.get(operationName); + if (mergedOperationSettings) { + // The parent operation settings object already exists + const outputFolderNames: string[] | undefined = + mergedOperationSettings.outputFolderNames && childOperationSettings.outputFolderNames + ? [ + ...mergedOperationSettings.outputFolderNames, + ...childOperationSettings.outputFolderNames + ] + : mergedOperationSettings.outputFolderNames || childOperationSettings.outputFolderNames; + + const dependsOnEnvVars: string[] | undefined = + mergedOperationSettings.dependsOnEnvVars && childOperationSettings.dependsOnEnvVars + ? [ + ...mergedOperationSettings.dependsOnEnvVars, + ...childOperationSettings.dependsOnEnvVars + ] + : mergedOperationSettings.dependsOnEnvVars || childOperationSettings.dependsOnEnvVars; + + mergedOperationSettings = { + ...mergedOperationSettings, + ...childOperationSettings, + ...(outputFolderNames ? { outputFolderNames } : {}), + ...(dependsOnEnvVars ? { dependsOnEnvVars } : {}) + }; + resultOperationSettingsByOperationName.set(operationName, mergedOperationSettings); + } else { + resultOperationSettingsByOperationName.set(operationName, childOperationSettings); + } } - } - return Array.from(resultOperationSettingsByOperationName.values()); + return Array.from(resultOperationSettingsByOperationName.values()); + } } + }, + incrementalBuildIgnoredGlobs: { + inheritanceType: InheritanceType.replace } - }, - incrementalBuildIgnoredGlobs: { - inheritanceType: InheritanceType.replace } - } - }); + }); + return configurationFile; +} + +const RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = + createProjectConfigurationFile(); const OLD_RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = new ProjectConfigurationFile({ @@ -278,6 +305,11 @@ const OLD_RUSH_PROJECT_CONFIGURATION_FILE: ProjectConfigurationFile = new Map(); +interface IIsolatedProjectConfigurationLoaders { + readonly configurationFile: ProjectConfigurationFile; + readonly oldConfigurationFile: ProjectConfigurationFile; +} + /** * Use this class to load the "config/rush-project.json" config file. * @@ -300,6 +332,7 @@ export class RushProjectConfiguration { public readonly operationSettingsByOperationName: ReadonlyMap>; readonly #validationCache: WeakSet = new WeakSet(); + readonly #jsonForFingerprint: string; private constructor( project: RushConfigurationProject, @@ -307,11 +340,17 @@ export class RushProjectConfiguration { operationSettingsByOperationName: ReadonlyMap ) { this.project = project; + this.#jsonForFingerprint = JSON.stringify(rushProjectJson); this.incrementalBuildIgnoredGlobs = rushProjectJson.incrementalBuildIgnoredGlobs || []; this.disableBuildCacheForProject = rushProjectJson.disableBuildCacheForProject || false; this.operationSettingsByOperationName = operationSettingsByOperationName; } + /** @internal */ + public _getJsonForFingerprint(): string { + return this.#jsonForFingerprint; + } + /** * Validates that the requested phases are compatible. * Deferral of this logic to its own method means that Rush no longer eagerly validates @@ -509,7 +548,6 @@ export class RushProjectConfiguration { project, terminal ); - if (rushProjectJson) { const operationSettingsByOperationName: ReadonlyMap = _getRushProjectConfiguration(project, rushProjectJson, terminal); @@ -526,6 +564,47 @@ export class RushProjectConfiguration { } } + /** + * Loads a fresh native configuration snapshot without reading or modifying process-wide + * project, inherited-file, or rig caches. The loaders are owned only by this invocation. + * @internal + */ + public static async _tryLoadForProjectsUncachedAsync( + projects: Iterable, + terminal: ITerminal + ): Promise> { + const loaders: IIsolatedProjectConfigurationLoaders = { + configurationFile: createProjectConfigurationFile(), + oldConfigurationFile: new ProjectConfigurationFile({ + projectRelativeFilePath: RUSH_PROJECT_CONFIGURATION_FILE.projectRelativeFilePath, + jsonSchemaObject: anythingSchemaJson + }) + }; + const result: Map = new Map(); + await Async.forEachAsync( + projects, + async (project) => { + const rushProjectJson: IRushProjectJson | undefined = await _tryLoadJsonForProjectAsync( + project, + terminal, + loaders + ); + if (rushProjectJson) { + result.set( + project, + new RushProjectConfiguration( + project, + rushProjectJson, + _getRushProjectConfiguration(project, rushProjectJson, terminal) + ) + ); + } + }, + { concurrency: 50 } + ); + return result; + } + /** * Load only the `incrementalBuildIgnoredGlobs` property from the rush-project.json file, skipping * validation of other parts of the config file. @@ -574,14 +653,19 @@ export class RushProjectConfiguration { async function _tryLoadJsonForProjectAsync( project: RushConfigurationProject, - terminal: ITerminal + terminal: ITerminal, + loaders?: IIsolatedProjectConfigurationLoaders ): Promise { - const rigConfig: RigConfig = await RigConfig.loadForProjectFolderAsync({ - projectFolderPath: project.projectFolder - }); + const configurationFile: ProjectConfigurationFile = + loaders?.configurationFile ?? RUSH_PROJECT_CONFIGURATION_FILE; + const oldConfigurationFile: ProjectConfigurationFile = + loaders?.oldConfigurationFile ?? OLD_RUSH_PROJECT_CONFIGURATION_FILE; + const rigConfig: RigConfig | undefined = loaders + ? await loadIsolatedRigConfigAsync(project.projectFolder) + : await RigConfig.loadForProjectFolderAsync({ projectFolderPath: project.projectFolder }); try { - return await RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + return await configurationFile.tryLoadConfigurationFileForProjectAsync( terminal, project.projectFolder, rigConfig @@ -590,7 +674,7 @@ async function _tryLoadJsonForProjectAsync( // Detect if the project is using the old rush-project.json schema let oldRushProjectJson: IOldRushProjectJson | undefined; try { - oldRushProjectJson = await OLD_RUSH_PROJECT_CONFIGURATION_FILE.tryLoadConfigurationFileForProjectAsync( + oldRushProjectJson = await oldConfigurationFile.tryLoadConfigurationFileForProjectAsync( terminal, project.projectFolder, rigConfig @@ -615,6 +699,25 @@ async function _tryLoadJsonForProjectAsync( } } +async function loadIsolatedRigConfigAsync(projectFolder: string): Promise { + let rigJson: IRigConfigJson; + try { + rigJson = await JsonFile.loadAsync(path.join(projectFolder, 'config', 'rig.json')); + } catch (error) { + if (FileSystem.isNotExistError(error as Error)) return undefined; + throw error; + } + if (!rigJson || typeof rigJson !== 'object' || Array.isArray(rigJson)) { + throw new Error(`The rig configuration for "${projectFolder}" must be a JSON object.`); + } + // bypassCache still writes the shared rig cache. An explicit JSON override uses the + // native schema/resolution path without either reading or populating that cache. + return await RigConfig.loadForProjectFolderAsync({ + projectFolderPath: projectFolder, + overrideRigJsonObject: rigJson + }); +} + /** * Parses and validates the operation settings from the rush-project.json data. Returns the * validated `operationSettingsByOperationName` map used to construct a {@link RushProjectConfiguration}. diff --git a/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts new file mode 100644 index 0000000000..488de5425f --- /dev/null +++ b/libraries/rush-lib/src/api/WorkspaceInputFingerprint.ts @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import { createHash } from 'node:crypto'; + +import { Async, FileSystem, JsonFile, PackageJsonLookup, Path } from '@rushstack/node-core-library'; +import type { ITerminal } from '@rushstack/terminal'; + +import type { RushConfiguration } from './RushConfiguration'; +import type { RushConfigurationProject } from './RushConfigurationProject'; +import { RushProjectConfiguration } from './RushProjectConfiguration'; +import { getDaemonIpcImplementationIdentityAsync } from '../logic/operations/DaemonIpcConfiguration'; + +/** Stable inputs which distinguish reusable, reloadable, and process-bound workspace state. @alpha */ +export interface IWorkspaceInputFingerprint { + readonly configurationHash: string; + readonly environmentHash: string; + readonly installationHash: string; + readonly runtimeHash: string; + readonly selectedRushVersion: string; +} + +/** Options for capturing workspace definition inputs, not ordinary project source/output files. @alpha */ +export interface IWorkspaceInputFingerprintOptions { + readonly rushConfiguration: RushConfiguration; + readonly environment: Readonly>; + /** Additional implementation files/folders owned by the embedding host. */ + readonly runtimePaths?: ReadonlyArray; + /** Invocation-owner cache for implementation files; workspace definitions are always read by content. */ + readonly runtimeCache?: WorkspaceRuntimeFingerprintCache; +} + +/** + * Memoizes runtime content digests behind file identity, size, nanosecond mtime and ctime checks. + * Changes to metadata alone still produce the same content fingerprint. + * @alpha + */ +export class WorkspaceRuntimeFingerprintCache { + private readonly _files: Map }> = new Map(); + private _baseline: ReadonlyMap | undefined; + private _changedPaths: ReadonlyArray = []; + + /** @internal */ + public get changedPaths(): ReadonlyArray { + return this._changedPaths; + } + + /** @internal */ + public _hashPaths(paths: ReadonlyArray): string { + const filenames: Set = new Set(); + for (const filename of paths) { + for (const file of listRuntimeFilesSync(filename)) filenames.add(file); + } + const entries: ReadonlyArray[] = []; + for (const filename of Array.from(filenames).sort()) { + try { + const stat: fsSync.BigIntStats = fsSync.statSync(filename, { bigint: true }); + const realPath: string = fsSync.realpathSync(filename); + const stamp: string = `${realPath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; + let cached: { stamp: string; entry: ReadonlyArray } | undefined = this._files.get(filename); + if (cached?.stamp !== stamp) { + cached = { + stamp, + entry: [ + filename, + realPath, + createHash('sha256').update(fsSync.readFileSync(filename)).digest('hex') + ] + }; + this._files.set(filename, cached); + } + entries.push(cached.entry); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) throw error; + this._files.delete(filename); + entries.push([filename, 'missing']); + } + } + const current: ReadonlyMap = new Map( + entries.map((entry) => [entry[0], JSON.stringify(entry)]) + ); + this._baseline ??= current; + this._changedPaths = Array.from(new Set([...this._baseline.keys(), ...current.keys()])).filter( + (filename) => this._baseline!.get(filename) !== current.get(filename) + ); + return hashText(JSON.stringify(entries)); + } +} + +/** The strongest action required by a workspace input change. @alpha */ +export enum WorkspaceInputChangeTier { + Reuse = 0, + Reload = 1, + Restart = 2 +} + +/** Compares stable content identities; timestamps alone never cause reloads. @alpha */ +export function classifyWorkspaceInputChange( + current: IWorkspaceInputFingerprint, + next: IWorkspaceInputFingerprint +): WorkspaceInputChangeTier { + if ( + current.runtimeHash !== next.runtimeHash || + current.environmentHash !== next.environmentHash || + current.installationHash !== next.installationHash || + current.selectedRushVersion !== next.selectedRushVersion + ) { + return WorkspaceInputChangeTier.Restart; + } + return current.configurationHash === next.configurationHash + ? WorkspaceInputChangeTier.Reuse + : WorkspaceInputChangeTier.Reload; +} + +/** Captures graph definitions and process-bound inputs without constructing a graph or executing commands. @alpha */ +export async function captureWorkspaceInputFingerprintAsync( + options: IWorkspaceInputFingerprintOptions +): Promise { + const { rushConfiguration, environment } = options; + const root: string = rushConfiguration.rushJsonFolder; + const rushJson: { rushVersion: string; projects: Array<{ projectFolder: string }> } = + await JsonFile.loadAsync(rushConfiguration.rushJsonFile); + if (typeof rushJson.rushVersion !== 'string' || !Array.isArray(rushJson.projects)) { + throw new Error('Workspace fingerprints require a valid Rush version and project list.'); + } + const definitions: Set = new Set([ + rushConfiguration.rushJsonFile, + path.join(root, '.gitignore'), + path.join(root, 'package.json'), + path.join(root, '.npmrc'), + path.join(root, '.env') + ]); + const installation: Set = new Set(); + for (const subspace of rushConfiguration.subspaces) { + installation.add(path.join(subspace.getSubspaceTempFolderPath(), 'last-install.flag')); + } + installation.add(path.join(rushConfiguration.commonTempFolder, 'current-variants.json')); + const configurationFiles: string[] = await listFilesAsync(path.join(root, 'common', 'config'), false); + for (const filename of configurationFiles) { + (isProcessBoundConfiguration(filename) ? installation : definitions).add(filename); + } + for (const project of rushJson.projects) { + const projectFolder: string = path.resolve(root, project.projectFolder); + if (!Path.isUnderOrEqual(projectFolder, root)) { + throw new Error('A fingerprint project folder must be inside the workspace.'); + } + for (const relativePath of [ + 'package.json', + '.gitignore', + 'config/rush-project.json', + 'config/rig.json' + ]) { + definitions.add(path.join(projectFolder, relativePath)); + } + } + // __dirname is the bundle folder in published Rush, not necessarily the source module's api folder. + const packageFolder: string | undefined = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname); + if (!packageFolder) + throw new Error('Cannot locate the running Rush package for its implementation fingerprint.'); + const runtimePaths: string[] = [ + path.join(packageFolder, 'package.json'), + path.join(packageFolder, 'lib-commonjs'), + ...(options.runtimePaths ?? []) + ]; + const runtimeHash: string = (options.runtimeCache ?? new WorkspaceRuntimeFingerprintCache())._hashPaths( + runtimePaths + ); + return { + configurationHash: await hashFilesAsync(definitions), + environmentHash: hashText( + JSON.stringify( + Object.entries(environment) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + ) + ), + installationHash: await hashFilesAsync(installation), + runtimeHash: hashText(JSON.stringify([process.execPath, process.version, runtimeHash])), + selectedRushVersion: environment.RUSH_PREVIEW_VERSION ?? rushJson.rushVersion + }; +} + +/** Fingerprints native merged project/rig/inherited configuration using invocation-owned loader caches. @alpha */ +export async function captureProjectConfigurationFingerprintAsync( + rushConfiguration: RushConfiguration, + terminal: ITerminal +): Promise { + const configurations: ReadonlyMap = + await RushProjectConfiguration._tryLoadForProjectsUncachedAsync(rushConfiguration.projects, terminal); + return hashText( + JSON.stringify( + [await getDaemonIpcImplementationIdentityAsync(configurations, rushConfiguration.daemon.usePersistentIpcRunners), + Array.from(configurations, ([project, configuration]) => [ + project.packageName, + configuration._getJsonForFingerprint() + ]).sort(([left], [right]) => left.localeCompare(right))] + ) + ); +} + +function hashText(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +async function hashFilesAsync(filenames: Iterable): Promise { + const entries: string[][] = await Async.mapAsync( + Array.from(filenames).sort(), + async (filename) => { + try { + return [ + filename, + await fs.realpath(filename), + createHash('sha256') + .update(await fs.readFile(filename)) + .digest('hex') + ]; + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) throw error; + return [filename, 'missing']; + } + }, + { concurrency: 3 } + ); + return hashText(JSON.stringify(entries)); +} + +async function listFilesAsync(folderOrFile: string, runtime: boolean): Promise { + try { + const stat: Awaited> = await fs.stat(folderOrFile); + if (!stat.isDirectory()) return [folderOrFile]; + const files: string[] = []; + for (const entry of await fs.readdir(folderOrFile, { withFileTypes: true })) { + if (entry.name === 'node_modules' || (runtime && entry.name === 'test')) continue; + const filename: string = path.join(folderOrFile, entry.name); + if (entry.isDirectory()) files.push(...(await listFilesAsync(filename, runtime))); + else if (!runtime || (/\.(?:js|cjs|mjs|json)$/.test(entry.name) && !entry.name.endsWith('.test.js'))) { + files.push(filename); + } + } + return files.sort(); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) throw error; + return [folderOrFile]; + } +} + +function isProcessBoundConfiguration(filename: string): boolean { + return ['pnpm-lock.yaml', 'npm-shrinkwrap.json', 'yarn.lock', 'rush-plugins.json'].includes( + path.basename(filename) + ); +} + +function listRuntimeFilesSync(folderOrFile: string): string[] { + try { + if (!fsSync.statSync(folderOrFile).isDirectory()) return [folderOrFile]; + const files: string[] = []; + for (const entry of fsSync.readdirSync(folderOrFile, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'test') continue; + const filename: string = path.join(folderOrFile, entry.name); + if (entry.isDirectory()) files.push(...listRuntimeFilesSync(filename)); + else if (/\.(?:js|cjs|mjs|json)$/.test(entry.name) && !entry.name.endsWith('.test.js')) + files.push(filename); + } + return files; + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) throw error; + return [folderOrFile]; + } +} diff --git a/libraries/rush-lib/src/api/test/DaemonConfiguration.test.ts b/libraries/rush-lib/src/api/test/DaemonConfiguration.test.ts new file mode 100644 index 0000000000..c2e46f39ad --- /dev/null +++ b/libraries/rush-lib/src/api/test/DaemonConfiguration.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { JsonSchema } from '@rushstack/node-core-library'; +import { resolveDaemonConfiguration } from '../DaemonConfiguration'; +import schemaJson from '../../schemas/rush.schema.json'; + +describe('daemon configuration', () => { + it('is opt-in and uses environment > configuration > defaults', () => { + expect(resolveDaemonConfiguration({}, {})).toMatchObject({ + enabled: false, + autoStart: true, + usePersistentIpcRunners: false, + idleTimeoutSeconds: 900 + }); + expect( + resolveDaemonConfiguration( + { enabled: true, idleTimeoutSeconds: 60 }, + { + RUSH_DAEMON: '0', + RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: '42' + } + ) + ).toMatchObject({ enabled: false, idleTimeoutSeconds: 42 }); + }); + + it.each([ + { RUSH_DAEMON: 'true' }, + { RUSH_DAEMON_ENABLED: '1' }, + { RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: '0' }, + { RUSH_DAEMON_IDLE_TIMEOUT_SECONDS: '2147483.648' }, + { RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS: '-1' }, + { RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS: 'NaN' }, + { RUSH_DAEMON_WARM_MEMORY_BUDGET_MB: 'Infinity' }, + { RUSH_DAEMON_WARM_SET_MAX_PROJECTS: '1.5' }, + { RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY: '' }, + { RUSH_DAEMON_EXPERIMENTAL: 'yes' }, + { RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS: 'yes' } + ])('rejects invalid overrides %j', (environment) => { + expect(() => resolveDaemonConfiguration({}, environment)).toThrow(); + }); + + it.each([ + { unexpected: true }, + { enabled: 'yes' }, + { autoStart: 1 }, + { watch: 'false' }, + { idleTimeoutSeconds: 0 }, + { idleTimeoutSeconds: 2147483.648 }, + { queueTimeoutSeconds: -1 }, + { warmIdleTimeoutSeconds: -1 }, + { warmMemoryBudgetMB: 0 }, + { warmSetMaxProjects: 0.5 }, + { autoWarmByTelemetry: 1 }, + { usePersistentIpcRunners: 'true' } + ])('publishes schema rejection for %j', (daemon) => { + const schema = JsonSchema.fromLoadedObject(schemaJson); + expect(() => + schema.validateObject( + { rushVersion: '5.179.0', pnpmVersion: '10.27.0', projects: [], daemon }, + 'rush.json' + ) + ).toThrow(); + }); + + it('accepts all valid knobs in the published schema', () => { + JsonSchema.fromLoadedObject(schemaJson).validateObject( + { + rushVersion: '5.179.0', + pnpmVersion: '10.27.0', + projects: [], + daemon: resolveDaemonConfiguration({ queueTimeoutSeconds: 0 }, {}) + }, + 'rush.json' + ); + }); +}); diff --git a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts index 78f597c2a3..0dc982bb2e 100644 --- a/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts +++ b/libraries/rush-lib/src/api/test/EnvironmentConfiguration.test.ts @@ -27,6 +27,16 @@ describe(EnvironmentConfiguration.name, () => { expect(EnvironmentConfiguration.validate).not.toThrow(); }); + it.each([ + { RUSH_REPORTER: 'legacy', RUSH_LOG_LEVEL: 'debug' }, + { RUSH_REPORTER: 'json', RUSH_LOG_LEVEL: 'frontend-owned-value' } + ])('recognizes frontend-owned reporter controls without interpreting them: %p', (env) => { + Object.assign(process.env, env); + expect(EnvironmentConfiguration.validate).not.toThrow(); + expect(process.env.RUSH_REPORTER).toBe(env.RUSH_REPORTER); + expect(process.env.RUSH_LOG_LEVEL).toBe(env.RUSH_LOG_LEVEL); + }); + it('does not allow unknown environment variables', () => { process.env['rush_foobar'] = 'asdf'; // eslint-disable-line dot-notation expect(EnvironmentConfiguration.validate).toThrow(); diff --git a/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts new file mode 100644 index 0000000000..798496f49a --- /dev/null +++ b/libraries/rush-lib/src/api/test/WorkspaceInputFingerprint.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + classifyWorkspaceInputChange, + WorkspaceInputChangeTier, + WorkspaceRuntimeFingerprintCache, + type IWorkspaceInputFingerprint +} from '../WorkspaceInputFingerprint'; + +describe('workspace input fingerprints', () => { + it('classifies content, configuration and process-bound identities', () => { + const current: IWorkspaceInputFingerprint = { + configurationHash: 'configuration', + environmentHash: 'environment', + installationHash: 'installation', + runtimeHash: 'runtime', + selectedRushVersion: '5.179.0' + }; + expect(classifyWorkspaceInputChange(current, { ...current })).toBe(WorkspaceInputChangeTier.Reuse); + expect(classifyWorkspaceInputChange(current, { ...current, configurationHash: 'changed' })).toBe( + WorkspaceInputChangeTier.Reload + ); + for (const changed of [ + { environmentHash: 'changed' }, + { installationHash: 'changed' }, + { runtimeHash: 'changed' }, + { selectedRushVersion: '5.180.0' } + ]) { + expect(classifyWorkspaceInputChange(current, { ...current, ...changed })).toBe( + WorkspaceInputChangeTier.Restart + ); + } + }); + + it('retains content identity across touches and catches same-size runtime edits with restored mtime', () => { + const folder: string = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-fingerprint-')); + try { + const filename: string = path.join(folder, 'runtime.js'); + fs.writeFileSync(filename, 'module.exports = 1;\n'); + const cache: WorkspaceRuntimeFingerprintCache = new WorkspaceRuntimeFingerprintCache(); + const original: string = cache._hashPaths([folder]); + const times: fs.Stats = fs.statSync(filename); + fs.utimesSync(filename, times.atime, new Date(times.mtimeMs + 1000)); + expect(cache._hashPaths([folder])).toBe(original); + fs.writeFileSync(filename, 'module.exports = 2;\n'); + fs.utimesSync(filename, times.atime, times.mtime); + expect(cache._hashPaths([folder])).not.toBe(original); + fs.writeFileSync(path.join(folder, 'added.js'), 'module.exports = 3;\n'); + const added: string = cache._hashPaths([folder]); + fs.rmSync(path.join(folder, 'added.js')); + expect(cache._hashPaths([folder])).not.toBe(added); + } finally { + fs.rmSync(folder, { recursive: true, force: true }); + } + }); +}); diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 93204bd03f..de1b356eac 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -94,6 +94,11 @@ export interface IRushCommandLineParserOptions { builtInPluginConfigurations: IBuiltInPluginConfiguration[]; reporter?: IRushSessionReporterOptions; reporterCloseAsync?: () => Promise; + /** Parse native commands without executing CLI actions or initializing process-global state. */ + engine?: { + rushConfiguration: RushConfiguration; + terminalProvider: ITerminalProvider; + }; } class ReporterTerminalProvider implements ITerminalProvider { @@ -102,17 +107,17 @@ class ReporterTerminalProvider implements ITerminalProvider { public readonly supportsColor: boolean = false; public readonly eolCharacter: string = '\n'; - private readonly _bufferedMessages: Array<{ + readonly #bufferedMessages: Array<{ severity: ReporterMessageSeverity; text: string; minimumLogLevel?: 'verbose' | 'debug'; }> = []; - private _reporter: IScopedReporter | undefined; + #reporter: IScopedReporter | undefined; public setReporter(reporter: IScopedReporter | undefined): void { - this._reporter = reporter; + this.#reporter = reporter; if (reporter) { - for (const message of this._bufferedMessages.splice(0)) { + for (const message of this.#bufferedMessages.splice(0)) { reporter.emitMessage({ ...message, privacy: 'local-sensitive' }); } } @@ -131,18 +136,18 @@ class ReporterTerminalProvider implements ITerminalProvider { text: string; minimumLogLevel?: 'verbose' | 'debug'; } = { - severity: this._toReporterSeverity(severity), + severity: this.#toReporterSeverity(severity), text: data, - ...this._getMinimumLogLevel(severity) + ...this.#getMinimumLogLevel(severity) }; - if (this._reporter) { - this._reporter.emitMessage({ ...message, privacy: 'local-sensitive' }); + if (this.#reporter) { + this.#reporter.emitMessage({ ...message, privacy: 'local-sensitive' }); } else { - this._bufferedMessages.push(message); + this.#bufferedMessages.push(message); } } - private _toReporterSeverity(severity: TerminalProviderSeverity): ReporterMessageSeverity { + #toReporterSeverity(severity: TerminalProviderSeverity): ReporterMessageSeverity { switch (severity) { case TerminalProviderSeverity.error: return 'error'; @@ -157,7 +162,7 @@ class ReporterTerminalProvider implements ITerminalProvider { } } - private _getMinimumLogLevel(severity: TerminalProviderSeverity): { minimumLogLevel?: 'verbose' | 'debug' } { + #getMinimumLogLevel(severity: TerminalProviderSeverity): { minimumLogLevel?: 'verbose' | 'debug' } { switch (severity) { case TerminalProviderSeverity.verbose: return { minimumLogLevel: 'verbose' }; @@ -180,7 +185,7 @@ export class RushCommandLineParser extends CommandLineParser { readonly #quietParameter: CommandLineFlagParameter; readonly #restrictConsoleOutput: boolean = RushCommandLineParser.shouldRestrictConsoleOutput(); readonly #rushOptions: IRushCommandLineParserOptions; - readonly #terminalProvider: ConsoleTerminalProvider | ReporterTerminalProvider; + readonly #terminalProvider: ITerminalProvider; readonly #terminal: Terminal; readonly #autocreateBuildCommand: boolean; #initializationFailed: boolean = false; @@ -230,8 +235,8 @@ export class RushCommandLineParser extends CommandLineParser { const reporterTerminalProvider: ReporterTerminalProvider | undefined = reporter?.operationStreamEnabled ? new ReporterTerminalProvider() : undefined; - const terminalProvider: ConsoleTerminalProvider | ReporterTerminalProvider = - reporterTerminalProvider ?? new ConsoleTerminalProvider(); + const terminalProvider: ITerminalProvider = + this.#rushOptions.engine?.terminalProvider ?? reporterTerminalProvider ?? new ConsoleTerminalProvider(); this.#terminalProvider = terminalProvider; const terminal: Terminal = new Terminal(terminalProvider); this.#terminal = terminal; @@ -246,25 +251,31 @@ export class RushCommandLineParser extends CommandLineParser { let rushJsonFilePath: string | undefined; try { - rushJsonFilePath = RushConfiguration.tryFindRushJsonLocation({ - startingFolder: cwd, - showVerbose: !this.#restrictConsoleOutput && !reporter?.operationStreamEnabled - }); + if (this.#rushOptions.engine) { + this.rushConfiguration = this.#rushOptions.engine.rushConfiguration; + } else { + rushJsonFilePath = RushConfiguration.tryFindRushJsonLocation({ + startingFolder: cwd, + showVerbose: !this.#restrictConsoleOutput && !reporter?.operationStreamEnabled + }); - initializeDotEnv(terminal, rushJsonFilePath); + initializeDotEnv(terminal, rushJsonFilePath); - if (rushJsonFilePath) { - this.rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFilePath); + if (rushJsonFilePath) { + this.rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFilePath); + } } } catch (error) { - this._reportInitializationErrorAndSetExitCode(error as Error); + this.#reportInitializationErrorAndSetExitCode(error as Error); } - NodeJsCompatibility.warnAboutCompatibilityIssues({ - isRushLib: true, - alreadyReportedNodeTooNewError, - rushConfiguration: this.rushConfiguration - }); + if (!this.#rushOptions.engine) { + NodeJsCompatibility.warnAboutCompatibilityIssues({ + isRushLib: true, + alreadyReportedNodeTooNewError, + rushConfiguration: this.rushConfiguration + }); + } this.rushGlobalFolder = new RushGlobalFolder(); @@ -300,7 +311,7 @@ export class RushCommandLineParser extends CommandLineParser { try { this.#addCommandLineConfigActions(commandLineConfiguration); } catch (e) { - this._reportInitializationErrorAndSetExitCode( + this.#reportInitializationErrorAndSetExitCode( new Error( `Error from plugin ${pluginLoader.pluginName} by ${pluginLoader.packageName}: ${( e as Error @@ -356,7 +367,7 @@ export class RushCommandLineParser extends CommandLineParser { public override async executeAsync(args?: string[]): Promise { if (this.#initializationFailed) { - await this._closeReporterAsync(); + await this.#closeReporterAsync(); return false; } @@ -371,7 +382,7 @@ export class RushCommandLineParser extends CommandLineParser { rushArgv.includes('--debug') || rushArgv.includes('-d'); } - this._startReporterSession(); + this.#startReporterSession(); try { await measureAsyncFn('rush:initializeUnassociatedPlugins', () => @@ -380,17 +391,17 @@ export class RushCommandLineParser extends CommandLineParser { const succeeded: boolean = await super.executeAsync(args); if (!this.#reporterCompletionEmitted) { - this._emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); + this.#emitReporterCompletion(succeeded ? 0 : _getNumericProcessExitCode(1)); } return succeeded; } catch (error) { if (!process.exitCode) { process.exitCode = 1; } - this._reportErrorAndSetExitCode(error as Error); + this.#reportErrorAndSetExitCode(error as Error); return false; } finally { - await this._closeReporterAsync(); + await this.#closeReporterAsync(); } } @@ -399,12 +410,15 @@ export class RushCommandLineParser extends CommandLineParser { await super.executeWithoutErrorHandlingAsync(args); } catch (error) { // Capture the original parse error before the base executeAsync renders it and returns false. - this._emitReporterFailureDiagnostic(error as Error, !this.#commandLifecycleEmitter); + this.#emitReporterFailureDiagnostic(error as Error, !this.#commandLifecycleEmitter); throw error; } } protected override async onExecuteAsync(): Promise { + if (this.#rushOptions.engine) { + return; + } // Defensively set the exit code to 1 so if Rush crashes for whatever reason, we'll have a nonzero exit code. // For example, Node.js currently has the inexcusable design of terminating with zero exit code when // there is an uncaught promise exception. This will supposedly be fixed in Node.js 9. @@ -468,14 +482,14 @@ export class RushCommandLineParser extends CommandLineParser { // If we make it here, everything went fine, so reset the exit code back to 0 process.exitCode = 0; } catch (error) { - this._reportErrorAndSetExitCode(error as Error); + this.#reportErrorAndSetExitCode(error as Error); } // This only gets hit if the wrapped execution completes successfully try { await this.telemetry?.ensureFlushedAsync(); } catch (error) { - this._emitReporterFailureDiagnostic(error as Error); + this.#emitReporterFailureDiagnostic(error as Error); throw error; } } @@ -487,7 +501,8 @@ export class RushCommandLineParser extends CommandLineParser { alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, builtInPluginConfigurations: options.builtInPluginConfigurations || [], reporter: options.reporter, - reporterCloseAsync: options.reporterCloseAsync + reporterCloseAsync: options.reporterCloseAsync, + engine: options.engine }; } @@ -537,7 +552,7 @@ export class RushCommandLineParser extends CommandLineParser { this.#populateScriptActions(); } catch (error) { - this._reportInitializationErrorAndSetExitCode(error as Error); + this.#reportInitializationErrorAndSetExitCode(error as Error); } } @@ -569,7 +584,10 @@ export class RushCommandLineParser extends CommandLineParser { } } - #addCommandLineConfigAction(commandLineConfiguration: CommandLineConfiguration, command: Command): void { + #addCommandLineConfigAction( + commandLineConfiguration: CommandLineConfiguration, + command: Command + ): void { if (this.tryGetAction(command.name)) { throw new Error( `${RushConstants.commandLineFilename} defines a command "${command.name}"` + @@ -686,7 +704,7 @@ export class RushCommandLineParser extends CommandLineParser { ); } - private _startReporterSession(): void { + #startReporterSession(): void { if (this.#sessionLifecycleEmitter && this.#sessionStartTimeMs === undefined) { this.#sessionStartTimeMs = performance.now(); this.#sessionLifecycleEmitter.emitSessionStarted({ @@ -695,8 +713,8 @@ export class RushCommandLineParser extends CommandLineParser { } } - private _emitReporterFailureDiagnostic(error: Error, includeMessage: boolean = false): void { - this._startReporterSession(); + #emitReporterFailureDiagnostic(error: Error, includeMessage: boolean = false): void { + this.#startReporterSession(); const emitter: LifecycleEmitter | undefined = this.#commandLifecycleEmitter ?? this.#sessionLifecycleEmitter; const rushSession: RushSession | undefined = this.rushSession; @@ -722,8 +740,8 @@ export class RushCommandLineParser extends CommandLineParser { } } - private _reportErrorAndSetExitCode(error: Error): void { - this._emitReporterFailureDiagnostic(error); + #reportErrorAndSetExitCode(error: Error): void { + this.#emitReporterFailureDiagnostic(error); const rushSession: RushSession | undefined = this.rushSession; if (!(error instanceof AlreadyReportedError)) { @@ -767,7 +785,7 @@ export class RushCommandLineParser extends CommandLineParser { ? numericExitCode : 1; process.exitCode = exitCode; - this._emitReporterCompletion(exitCode); + this.#emitReporterCompletion(exitCode); this.flushTelemetry(); const handleExit = (): never => { @@ -787,7 +805,7 @@ export class RushCommandLineParser extends CommandLineParser { const pendingFlushes: Promise[] = [ this.#rushOptions.reporterCloseAsync - ? this._closeReporterAsync() + ? this.#closeReporterAsync() : rushSession ? _flushRushSessionReporterAsync(rushSession) : Promise.resolve() @@ -798,12 +816,15 @@ export class RushCommandLineParser extends CommandLineParser { void Promise.allSettled(pendingFlushes).then(handleExit); } - private _reportInitializationErrorAndSetExitCode(error: Error): void { + #reportInitializationErrorAndSetExitCode(error: Error): void { + if (this.#rushOptions.engine) { + throw error; + } this.#initializationFailed = true; - this._reportErrorAndSetExitCode(error); + this.#reportErrorAndSetExitCode(error); } - private _closeReporterAsync(): Promise { + #closeReporterAsync(): Promise { if (!this.#reporterClosePromise) { this.#reporterClosePromise = (async (): Promise => { try { @@ -817,7 +838,7 @@ export class RushCommandLineParser extends CommandLineParser { return this.#reporterClosePromise; } - private _emitReporterCompletion(exitCode: number): void { + #emitReporterCompletion(exitCode: number): void { if (!this.#sessionLifecycleEmitter || this.#reporterCompletionEmitted) { return; } diff --git a/libraries/rush-lib/src/cli/RushStartupBanner.ts b/libraries/rush-lib/src/cli/RushStartupBanner.ts index 989d96d3d5..f6b5f59bcd 100644 --- a/libraries/rush-lib/src/cli/RushStartupBanner.ts +++ b/libraries/rush-lib/src/cli/RushStartupBanner.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { Colorize } from '@rushstack/terminal'; +import { Colorize, type ITerminal } from '@rushstack/terminal'; import { RushConstants } from '../logic/RushConstants'; import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; @@ -20,12 +20,15 @@ export class RushStartupBanner { ); } - public static logStreamlinedBanner(rushVersion: string, isManaged: boolean): void { + public static logStreamlinedBanner(rushVersion: string, isManaged: boolean, terminal?: ITerminal): void { const nodeVersion: string = _formatNodeVersion(); const versionSuffix: string = rushVersion ? ' ' + _formatRushVersion(rushVersion, isManaged) : ''; + const message: string = + Colorize.bold(`Rush Multi-Project Build Tool${versionSuffix}`) + ` - Node.js ${nodeVersion}`; + if (terminal) terminal.writeLine(message); // eslint-disable-next-line no-console - console.log(Colorize.bold(`Rush Multi-Project Build Tool${versionSuffix}`) + ` - Node.js ${nodeVersion}`); + else console.log(message); } } diff --git a/libraries/rush-lib/src/cli/RushXCommandLine.ts b/libraries/rush-lib/src/cli/RushXCommandLine.ts index 20f550d65b..ef072ea451 100644 --- a/libraries/rush-lib/src/cli/RushXCommandLine.ts +++ b/libraries/rush-lib/src/cli/RushXCommandLine.ts @@ -2,10 +2,11 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import type * as childProcess from 'node:child_process'; import { type ILogMessageCallbackOptions, pnpmSyncCopyAsync } from 'pnpm-sync-lib'; -import { PackageJsonLookup, type IPackageJson, Text, FileSystem, Async } from '@rushstack/node-core-library'; +import { PackageJsonLookup, type IPackageJson, Text, FileSystem, Async, EnvironmentMap } from '@rushstack/node-core-library'; import { Colorize, ConsoleTerminalProvider, @@ -16,7 +17,7 @@ import { type ITerminal } from '@rushstack/terminal'; -import { Utilities } from '../utilities/Utilities'; +import { Utilities, type ILifecycleCommandOptions } from '../utilities/Utilities'; import { ProjectCommandSet } from '../logic/ProjectCommandSet'; import { type ILaunchOptions, Rush } from '../api/Rush'; import { RushConfiguration } from '../api/RushConfiguration'; @@ -24,13 +25,14 @@ import { NodeJsCompatibility } from '../logic/NodeJsCompatibility'; import { RushStartupBanner } from './RushStartupBanner'; import { EventHooksManager } from '../logic/EventHooksManager'; import { Event } from '../api/EventHooks'; -import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; import { RushConstants } from '../logic/RushConstants'; import { PnpmSyncUtilities } from '../utilities/PnpmSyncUtilities'; -import { initializeDotEnv } from '../logic/dotenv'; +import { initializeDotEnv, loadDotEnvForEnvironment } from '../logic/dotenv'; import { escapeArgumentIfNeeded } from '../utilities/executionUtilities'; -interface IRushXCommandLineArguments { +/** Native Rushx arguments. Options after the command belong to the script. @beta */ +export interface IRushXCommandLineArguments { /** * Flag indicating whether to suppress any rushx startup information. */ @@ -62,6 +64,96 @@ interface IRushXCommandLineArguments { commandArgs: string[]; } +/** Explicit process state and an optional owned asynchronous spawn seam for native Rushx. @beta */ +export interface IRushXCommandOptions { + /** Parse before dotenv initialization, as the native frontend does. */ + readonly arguments: IRushXCommandLineArguments; + readonly abortSignal?: AbortSignal; + readonly cwd: string; + readonly environment: Readonly; + readonly rushConfiguration: RushConfiguration | undefined; + /** The caller-validated governing configuration path in the invocation's namespace, including Windows aliases. */ + readonly rushJsonFilePath?: string; + readonly terminal: ITerminal; + /** Console output preserves native ANSI and newline bytes independently of diagnostic terminal capabilities. */ + readonly consoleTerminal: ITerminal; + readonly launchOptions: ILaunchOptions; + /** When omitted, preserves the native CLI's synchronous inherited-stdio execution. */ + readonly spawn?: ( + command: string, + args: ReadonlyArray, + options: childProcess.SpawnOptions + ) => childProcess.ChildProcess; +} + +/** + * Native Rushx parsing and script execution with explicit request-local process state. + * + * @remarks + * The caller owns dotenv initialization, cwd confinement and child lifetime. Active hooks require in-process execution. + * This helper never changes process cwd, environment or exitCode. + * @beta + */ +export class RushXCommand { + public static parseArguments( + argv: ReadonlyArray, + environment: Readonly + ): IRushXCommandLineArguments { + return _parseCommandLineArguments(argv, environment); + } + + public static getPackageFolder(cwd: string): string { + return path.dirname(_getPackageJsonFilePath(new PackageJsonLookup(), cwd)); + } + + /** Loads repository then user dotenv files into a copy, without consulting cached user state. */ + public static prepareEnvironment( + cwd: string, + environment: Readonly, + rushJsonFilePath: string + ): NodeJS.ProcessEnv { + return loadDotEnvForEnvironment(cwd, environment, rushJsonFilePath); + } + + /** Returns a pre-execution boundary, never a reason to replay an executed script. */ + public static getInProcessReason( + args: IRushXCommandLineArguments, + environment: Readonly, + configuration: RushConfiguration + ): string | undefined { + if (args.help) return 'Rushx help requires the native frontend.'; + if ( + !args.ignoreHooks && + environment[EnvironmentVariableNames._RUSH_RECURSIVE_RUSHX_CALL] !== '1' && + [Event.preRushx, Event.postRushx].some((event) => configuration.eventHooks.get(event).length > 0) + ) { + return 'Rushx event hooks still require process-global argv and inherited synchronous I/O.'; + } + return undefined; + } + + public static async executeAsync(options: IRushXCommandOptions): Promise { + const { terminal, consoleTerminal, environment, arguments: args, rushConfiguration, launchOptions } = options; + try { + const reason: string | undefined = rushConfiguration && + RushXCommand.getInProcessReason(args, environment, rushConfiguration); + if (reason) throw new Error(reason); + options.abortSignal?.throwIfAborted(); + const ignoredHooks: EventHooksManager | undefined = + rushConfiguration && args.ignoreHooks && + environment[EnvironmentVariableNames._RUSH_RECURSIVE_RUSHX_CALL] !== '1' + ? new EventHooksManager(rushConfiguration) : undefined; + ignoredHooks?.handle(Event.preRushx, args.isDebug, true, consoleTerminal); + await _launchRushXInternalAsync(terminal, args, rushConfiguration, launchOptions, options); + ignoredHooks?.handle(Event.postRushx, args.isDebug, true, consoleTerminal); + return 0; + } catch (error) { + consoleTerminal.writeErrorLine(Colorize.red('Error: ' + (error as Error).message)); + return _getRushXExitCode(error); + } + } +} + class ProcessError extends Error { public readonly exitCode: number; public constructor(message: string, exitCode: number) { @@ -80,7 +172,11 @@ class ProcessError extends Error { export class RushXCommandLine { public static async launchRushXAsync(launcherVersion: string, options: ILaunchOptions): Promise { try { - const rushxArguments: IRushXCommandLineArguments = _parseCommandLineArguments(); + const rushxArguments: IRushXCommandLineArguments = _parseCommandLineArguments( + process.argv.slice(2), process.env, + // eslint-disable-next-line no-console + (message) => console.log(message) + ); const rushJsonFilePath: string | undefined = RushConfiguration.tryFindRushJsonLocation({ showVerbose: false }); @@ -128,53 +224,57 @@ export class RushXCommandLine { // Getting here means that we are all done with no major errors process.exitCode = 0; } catch (error) { - if (error instanceof ProcessError) { - process.exitCode = error.exitCode; - } else { - process.exitCode = 1; - } + process.exitCode = _getRushXExitCode(error); // eslint-disable-next-line no-console console.error(Colorize.red('Error: ' + (error as Error).message)); } } } +function _getRushXExitCode(error: unknown): number { + return error instanceof ProcessError ? error.exitCode : 1; +} + async function _launchRushXInternalAsync( terminal: ITerminal, rushxArguments: IRushXCommandLineArguments, rushConfiguration: RushConfiguration | undefined, - options: ILaunchOptions + options: ILaunchOptions, + execution?: IRushXCommandOptions ): Promise { const { quiet, help, commandName, commandArgs } = rushxArguments; + const writeLine: (message: string) => void = execution + ? (message) => execution.consoleTerminal.writeLine(message) + // eslint-disable-next-line no-console + : (message) => console.log(message); if (!quiet) { - RushStartupBanner.logStreamlinedBanner(Rush.version, options.isManaged); + RushStartupBanner.logStreamlinedBanner(Rush.version, options.isManaged, execution?.consoleTerminal); } // Are we in a Rush repo? NodeJsCompatibility.warnAboutCompatibilityIssues({ isRushLib: true, alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError || false, - rushConfiguration + rushConfiguration, + terminal: execution?.consoleTerminal }); // Find the governing package.json for this folder: const packageJsonLookup: PackageJsonLookup = new PackageJsonLookup(); - const packageJsonFilePath: string | undefined = packageJsonLookup.tryGetPackageJsonFilePathFor( - process.cwd() - ); - if (!packageJsonFilePath) { - throw Error( - 'This command should be used inside a project folder. ' + - 'Unable to find a package.json file in the current working directory or any of its parents.' - ); - } + const cwd: string = execution?.cwd ?? process.cwd(); + const packageJsonFilePath: string = _getPackageJsonFilePath(packageJsonLookup, cwd); + const lifecycleConfiguration: ILifecycleCommandOptions['rushConfiguration'] = + _getLifecycleConfiguration(rushConfiguration, execution); + // Match native registration lookup in the discovered configuration's namespace, not by physical project identity. + const projectLookupCwd: string = execution?.rushJsonFilePath && rushConfiguration && lifecycleConfiguration + ? path.resolve(rushConfiguration.rushJsonFolder, path.relative(lifecycleConfiguration.rushJsonFolder, cwd)) + : cwd; - if (rushConfiguration && !rushConfiguration.tryGetProjectForPath(process.cwd())) { + if (rushConfiguration && !rushConfiguration.tryGetProjectForPath(projectLookupCwd)) { // GitHub #2713: Users reported confusion resulting from a situation where "rush install" // did not install the project's dependencies, because the project was not registered. - // eslint-disable-next-line no-console - console.log( + writeLine( Colorize.yellow( 'Warning: You are invoking "rushx" inside a Rush repository, but this project is not registered in ' + `${RushConstants.rushJsonFilename}.` @@ -187,7 +287,7 @@ async function _launchRushXInternalAsync( const projectCommandSet: ProjectCommandSet = new ProjectCommandSet(packageJson); if (help) { - _showUsage(packageJson, projectCommandSet); + _showUsage(packageJson, projectCommandSet, writeLine); return; } @@ -216,24 +316,28 @@ async function _launchRushXInternalAsync( } if (!quiet) { - // eslint-disable-next-line no-console - console.log(`> ${JSON.stringify(commandWithArgsForDisplay)}\n`); + writeLine(`> ${JSON.stringify(commandWithArgsForDisplay)}\n`); } const packageFolder: string = path.dirname(packageJsonFilePath); - const exitCode: number = Utilities.executeLifecycleCommand(commandWithArgs, { - rushConfiguration, + const lifecycleOptions: ILifecycleCommandOptions = { + rushConfiguration: lifecycleConfiguration, workingDirectory: packageFolder, // If there is a rush.json then use its .npmrc from the temp folder. // Otherwise look for npmrc in the project folder. - initCwd: rushConfiguration ? rushConfiguration.commonTempFolder : packageFolder, + initCwd: lifecycleConfiguration ? lifecycleConfiguration.commonTempFolder : packageFolder, handleOutput: false, + ...(execution ? { initialEnvironment: execution.environment } : {}), environmentPathOptions: { includeProjectBin: true } - }); + }; + const exitCode: number = execution?.spawn + ? await _executeOwnedLifecycleAsync(commandWithArgs, lifecycleOptions, execution.spawn) + : Utilities.executeLifecycleCommand(commandWithArgs, lifecycleOptions); + execution?.abortSignal?.throwIfAborted(); if (rushConfiguration?.isPnpm && rushConfiguration?.experimentsConfiguration) { const { configuration: experiments } = rushConfiguration?.experimentsConfiguration; @@ -261,10 +365,27 @@ async function _launchRushXInternalAsync( } } -function _parseCommandLineArguments(): IRushXCommandLineArguments { - // 0 = node.exe - // 1 = rushx - const args: string[] = process.argv.slice(2); +function _getLifecycleConfiguration( + configuration: RushConfiguration | undefined, + execution: IRushXCommandOptions | undefined +): ILifecycleCommandOptions['rushConfiguration'] { + if (!configuration || !execution?.rushJsonFilePath) return configuration; + const rushJsonFolder: string = path.dirname(execution.rushJsonFilePath); + const tempOverride: string | undefined = + new EnvironmentMap(execution.environment).get(EnvironmentVariableNames.RUSH_TEMP_FOLDER); + return { + rushJsonFolder, + commonTempFolder: EnvironmentConfiguration._getRushTempFolderOverride({ + [EnvironmentVariableNames.RUSH_TEMP_FOLDER]: tempOverride + }) || path.join(rushJsonFolder, RushConstants.commonFolderName, RushConstants.rushTempFolderName) + }; +} + +function _parseCommandLineArguments( + args: ReadonlyArray, + environment: Readonly, + reportUnknownArguments?: (message: string) => void +): IRushXCommandLineArguments { const unknownArgs: string[] = []; let help: boolean = false; @@ -296,7 +417,7 @@ function _parseCommandLineArguments(): IRushXCommandLineArguments { } } - const quietModeValue: string | undefined = process.env[EnvironmentVariableNames.RUSH_QUIET_MODE]; + const quietModeValue: string | undefined = environment[EnvironmentVariableNames.RUSH_QUIET_MODE]; if (quietModeValue === '1' || quietModeValue === 'true') { quiet = true; } @@ -308,8 +429,9 @@ function _parseCommandLineArguments(): IRushXCommandLineArguments { if (unknownArgs.length > 0) { // Future TODO: Instead of just displaying usage info, we could display a // specific error about the unknown flag the user tried to pass to rushx. - // eslint-disable-next-line no-console - console.log(Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`)); + reportUnknownArguments?.( + Colorize.red(`Unknown arguments: ${unknownArgs.map((x) => JSON.stringify(x)).join(', ')}`) + ); help = true; } @@ -323,24 +445,21 @@ function _parseCommandLineArguments(): IRushXCommandLineArguments { }; } -function _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommandSet): void { - // eslint-disable-next-line no-console - console.log('usage: rushx [-h]'); - // eslint-disable-next-line no-console - console.log(' rushx [-q/--quiet] [-d/--debug] [--ignore-hooks] ...\n'); - - // eslint-disable-next-line no-console - console.log('Optional arguments:'); - // eslint-disable-next-line no-console - console.log(' -h, --help Show this help message and exit.'); - // eslint-disable-next-line no-console - console.log(' -q, --quiet Hide rushx startup information.'); - // eslint-disable-next-line no-console - console.log(' -d, --debug Run in debug mode.\n'); +function _showUsage( + packageJson: IPackageJson, + projectCommandSet: ProjectCommandSet, + writeLine: (message: string) => void +): void { + writeLine('usage: rushx [-h]'); + writeLine(' rushx [-q/--quiet] [-d/--debug] [--ignore-hooks] ...\n'); + + writeLine('Optional arguments:'); + writeLine(' -h, --help Show this help message and exit.'); + writeLine(' -q, --quiet Hide rushx startup information.'); + writeLine(' -d, --debug Run in debug mode.\n'); if (projectCommandSet.commandNames.length > 0) { - // eslint-disable-next-line no-console - console.log(`Project commands for ${Colorize.cyan(packageJson.name)}:`); + writeLine(`Project commands for ${Colorize.cyan(packageJson.name)}:`); // Calculate the length of the longest script name, for formatting let maxLength: number = 0; @@ -358,8 +477,7 @@ function _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommand const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; const truncateLength: number = Math.max(0, consoleWidth - firstPartLength) - 1; - // eslint-disable-next-line no-console - console.log( + writeLine( // Example: " command: " ' ' + Colorize.cyan(Text.padEnd(commandName + ':', maxLength + 2)) + @@ -369,8 +487,7 @@ function _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommand } if (projectCommandSet.malformedScriptNames.length > 0) { - // eslint-disable-next-line no-console - console.log( + writeLine( '\n' + Colorize.yellow( 'Warning: Some "scripts" entries in the package.json file' + @@ -380,9 +497,35 @@ function _showUsage(packageJson: IPackageJson, projectCommandSet: ProjectCommand ); } } else { - // eslint-disable-next-line no-console - console.log(Colorize.yellow('Warning: No commands are defined yet for this project.')); - // eslint-disable-next-line no-console - console.log('You can define a command by adding a "scripts" table to the project\'s package.json file.'); + writeLine(Colorize.yellow('Warning: No commands are defined yet for this project.')); + writeLine('You can define a command by adding a "scripts" table to the project\'s package.json file.'); } } + +function _getPackageJsonFilePath(lookup: PackageJsonLookup, cwd: string): string { + const packageJsonFilePath: string | undefined = lookup.tryGetPackageJsonFilePathFor(cwd); + if (!packageJsonFilePath) { + throw Error( + 'This command should be used inside a project folder. ' + + 'Unable to find a package.json file in the current working directory or any of its parents.' + ); + } + return packageJsonFilePath; +} + +function _executeOwnedLifecycleAsync( + command: string, + options: ILifecycleCommandOptions, + spawn: NonNullable +): Promise { + const child: childProcess.ChildProcess = Utilities.executeLifecycleCommandAsync( + command, { ...options, stdio: 'pipe' }, spawn + ); + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => { + if (code === null) reject(new Error('An unknown error occurred.')); + else resolve(code); + }); + }); +} diff --git a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts index 6fcaf14679..1f40359f14 100644 --- a/libraries/rush-lib/src/cli/actions/BaseRushAction.ts +++ b/libraries/rush-lib/src/cli/actions/BaseRushAction.ts @@ -128,6 +128,12 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { declare protected readonly rushConfiguration: RushConfiguration; protected override async onExecuteAsync(): Promise { + await this.initializePluginsAsync(); + return super.onExecuteAsync(); + } + + /** Initializes the native command plugins without CLI environment or process-lock side effects. */ + protected async initializePluginsAsync(): Promise { if (!this.rushConfiguration) { throw Utilities.getRushConfigNotFoundError(); } @@ -147,8 +153,6 @@ export abstract class BaseRushAction extends BaseConfiglessRushAction { await sessionHooks.initialize.promise(this); } }); - - return super.onExecuteAsync(); } /** diff --git a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts index 96f8158038..a3acffd702 100644 --- a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts +++ b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts @@ -58,6 +58,23 @@ export class SelectionParameterSet { readonly #selectorParserByScope: Map>; + /** Names of the parameters that affect project selection rather than command shape. */ + public get parameterNames(): ReadonlySet { + return new Set( + [ + this.#fromProject, + this.#impactedByProject, + this.#impactedByExceptProject, + this.#onlyProject, + this.#toProject, + this.#toExceptProject, + this.#fromVersionPolicy, + this.#toVersionPolicy, + ...(this.#subspaceParameter ? [this.#subspaceParameter] : []) + ].map((parameter) => parameter.longName) + ); + } + public constructor( rushConfiguration: RushConfiguration, action: CommandLineParameterProvider, diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 28daceef1d..06a845d66d 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -2,10 +2,11 @@ // See LICENSE in the project root for license information. import { once } from 'node:events'; +import * as path from 'node:path'; import type { AsyncSeriesHook } from 'tapable'; -import { AlreadyReportedError } from '@rushstack/node-core-library'; +import { AlreadyReportedError, EnvironmentMap } from '@rushstack/node-core-library'; import { type ITerminal, Terminal, @@ -27,7 +28,13 @@ import { PhasedCommandHooks, type ICreateOperationsContext } from '../../pluginFramework/PhasedCommandHooks'; -import type { IOperationGraphIterationOptions } from '../../logic/operations/IOperationGraph'; +import type { + IOperationGraph, + IOperationGraphIterationOptions +} from '../../logic/operations/IOperationGraph'; +import type { IPhasedCommandEngine } from '../../api/PhasedCommandEngine'; +import { PhasedCommandEngineConfigurationChangedError } from '../../api/PhasedCommandEngineConfigurationChangedError'; +import { getDaemonIpcImplementationIdentityAsync } from '../../logic/operations/DaemonIpcConfiguration'; import { SetupChecks } from '../../logic/SetupChecks'; import { Stopwatch } from '../../utilities/Stopwatch'; import { BaseScriptAction, type IBaseScriptActionOptions } from './BaseScriptAction'; @@ -38,8 +45,9 @@ import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration'; import { SelectionParameterSet } from '../parsing/SelectionParameterSet'; +import type { IGitSelectorParserOptions } from '../../logic/selectors/GitChangedProjectSelectorParser'; import type { IPhase, IPhasedCommandConfig } from '../../api/CommandLineConfiguration'; -import type { Operation } from '../../logic/operations/Operation'; +import type { Operation, OperationEnabledState } from '../../logic/operations/Operation'; import { associateParametersByPhase } from '../parsing/associateParametersByPhase'; import { PhasedOperationPlugin } from '../../logic/operations/PhasedOperationPlugin'; import { ShellOperationRunnerPlugin } from '../../logic/operations/ShellOperationRunnerPlugin'; @@ -157,6 +165,10 @@ export class PhasedScriptAction extends BaseScriptAction i readonly #changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; readonly #selectionParameters: SelectionParameterSet; + readonly #gitSelectorOptions: IGitSelectorParserOptions = { + includeExternalDependencies: true, + enableFiltering: true + }; readonly #verboseParameter: CommandLineFlagParameter; readonly #parallelismParameter: CommandLineStringParameter | undefined; readonly #ignoreHooksParameter: CommandLineFlagParameter; @@ -232,13 +244,7 @@ export class PhasedScriptAction extends BaseScriptAction i }); this.#selectionParameters = new SelectionParameterSet(this.rushConfiguration, this, { - gitOptions: { - // Include lockfile processing since this expands the selection, and we need to select - // at least the same projects selected with the same query to "rush build" - includeExternalDependencies: true, - // Enable filtering to reduce evaluation cost - enableFiltering: true - }, + gitOptions: this.#gitSelectorOptions, includeSubspaceSelector: false, cwd: this.parser.cwd }); @@ -337,14 +343,90 @@ export class PhasedScriptAction extends BaseScriptAction i } public async runAsync(): Promise { + await this.#runAsync(); + } + + public validateEngineCommand(): void { + if ( + this.#alwaysWatch || + this.#watchParameter?.value || + this.#alwaysInstall || + this.#installParameter?.value || + this.#nodeDiagnosticDirParameter.value || + this.#variantParameter?.value + ) { + throw new Error('Watch, install, variant and diagnostic-directory options require --no-daemon.'); + } + if ( + !this.#ignoreHooksParameter.value && + (this.rushConfiguration.eventHooks.get(Event.preRushBuild).length || + this.rushConfiguration.eventHooks.get(Event.postRushBuild).length) + ) { + throw new Error('Build event-hook scripts require --no-daemon (or an explicit --ignore-hooks).'); + } + } + + public getEngineParameterIdentity(): string { + const selectionNames: ReadonlySet = this.#selectionParameters.parameterNames; + return JSON.stringify([ + this.actionName, + this.parser.getParameterStringMap(), + Object.entries(this.getParameterStringMap()).filter(([name]) => !selectionNames.has(name)) + ]); + } + + public async selectEngineOperationsAsync( + graph: IOperationGraph + ): Promise> { + this.#gitSelectorOptions.getIncrementalBuildIgnoredGlobsAsync = async (project) => { + const configurations: ReadonlyMap = + await RushProjectConfiguration._tryLoadForProjectsUncachedAsync([project], this.#terminal); + return configurations.get(project)?.incrementalBuildIgnoredGlobs; + }; + const projects: Set = await this.#selectionParameters.getSelectedProjectsAsync( + this.#terminal + ); + const includePhaseDeps: boolean = !!this.#includePhaseDeps?.value; + const phases: Set = new Set( + Array.from(includePhaseDeps ? this.#originalPhases : this.#initialPhases, (phase) => phase.name) + ); + const selected: Map = new Map(); + for (const operation of graph.operations) { + if (projects.has(operation.associatedProject) && phases.has(operation.associatedPhase.name)) { + selected.set(operation, true); + } + } + if (includePhaseDeps) { + for (const operation of selected.keys()) { + for (const dependency of operation.dependencies) selected.set(dependency, true); + } + } + if (this.#changedProjectsOnlyParameter?.value) { + for (const operation of selected.keys()) { + if (!operation.settings?.ignoreChangedProjectsOnlyFlag) { + selected.set(operation, 'ignore-dependency-changes'); + } + } + } + return selected; + } + + public async createEngineAsync(): Promise { + this.validateEngineCommand(); + await this.initializePluginsAsync(); + let engine: IPhasedCommandEngine | undefined; + await this.#runAsync((result) => { + engine = result; + }); + if (!engine) throw new Error('Native command preparation did not produce an operation graph.'); + return engine; + } + + async #runAsync(onEngine?: (engine: IPhasedCommandEngine) => void): Promise { // Initialize the stopwatch's start time at 0 (process startup). const stopwatch: Stopwatch = Stopwatch.start(0); - const { - defaultSubspace, - subspacesFeatureEnabled, - pnpmOptions: { useWorkspaces } - } = this.rushConfiguration; + const { defaultSubspace } = this.rushConfiguration; if (this.#alwaysInstall || this.#installParameter?.value) { await measureAsyncFn(`${PERF_PREFIX}:install`, async () => { const { doBasicInstallAsync } = await import( @@ -373,32 +455,16 @@ export class PhasedScriptAction extends BaseScriptAction i }); } - if (!this._runsBeforeInstall) { - await measureAsyncFn(`${PERF_PREFIX}:checkInstallFlag`, async () => { - // TODO: Replace with last-install.flag when "rush link" and "rush unlink" are removed - const lastLinkFlag: FlagFile = new FlagFile( - defaultSubspace.getSubspaceTempFolderPath(), - RushConstants.lastLinkFlagFilename, - {} - ); - // Only check for a valid link flag when subspaces is not enabled - if (!(await lastLinkFlag.isValidAsync()) && !subspacesFeatureEnabled) { - if (useWorkspaces) { - throw new Error('Link flag invalid.\nDid you run "rush install" or "rush update"?'); - } else { - throw new Error('Link flag invalid.\nDid you run "rush link"?'); - } - } - }); - } + await this.#validateInstallStateAsync(); measureFn(`${PERF_PREFIX}:doBeforeTask`, () => this.#doBeforeTask()); const hooks: PhasedCommandHooks = this.hooks; const terminal: ITerminal = this.#terminal; - const presentationTerminal: ITerminal = _isRushSessionOperationStreamEnabled(this.rushSession) - ? new Terminal(new NoOpTerminalProvider()) - : terminal; + const presentationTerminal: ITerminal = + onEngine || _isRushSessionOperationStreamEnabled(this.rushSession) + ? new Terminal(new NoOpTerminalProvider()) + : terminal; // if this is parallelizable, then use the value from the flag (undefined or a number), // if parallelism is not enabled, then restrict to 1 core @@ -490,12 +556,17 @@ export class PhasedScriptAction extends BaseScriptAction i } const isWatch: boolean = this.#watchParameter?.value || this.#alwaysWatch; - const generateFullGraph: boolean = isWatch && this.#includeAllProjectsInWatchGraph; + const generateFullGraph: boolean = !!onEngine || (isWatch && this.#includeAllProjectsInWatchGraph); + let transferredEngine: boolean = false; + let ownedGraph: OperationGraph | undefined; try { const projectSelection: Set = await measureAsyncFn( `${PERF_PREFIX}:getSelectedProjects`, - () => this.#selectionParameters.getSelectedProjectsAsync(terminal, generateFullGraph) + () => + onEngine + ? Promise.resolve(new Set(this.rushConfiguration.projects)) + : this.#selectionParameters.getSelectedProjectsAsync(terminal, generateFullGraph) ); const customParametersByName: Map = new Map(); @@ -511,6 +582,12 @@ export class PhasedScriptAction extends BaseScriptAction i } await measureAsyncFn(`${PERF_PREFIX}:applySituationalPlugins`, async () => { + if (onEngine && this.rushConfiguration.daemon.usePersistentIpcRunners && !this.#noIPCParameter?.value) { + const { DaemonIpcOperationRunnerPlugin } = await import( + /* webpackChunkName: 'DaemonIpcOperationRunnerPlugin' */ '../../logic/operations/DaemonIpcOperationRunnerPlugin' + ); + new DaemonIpcOperationRunnerPlugin().apply(this.hooks); + } if (isWatch && this.#noIPCParameter?.value === false) { new ( await import( @@ -584,8 +661,13 @@ export class PhasedScriptAction extends BaseScriptAction i ._runsBeforeInstall ? new Map() : await measureAsyncFn(`${PERF_PREFIX}:loadProjectConfigurations`, () => - RushProjectConfiguration.tryLoadForProjectsAsync(relevantProjects, terminal) + onEngine + ? RushProjectConfiguration._tryLoadForProjectsUncachedAsync(relevantProjects, terminal) + : RushProjectConfiguration.tryLoadForProjectsAsync(relevantProjects, terminal) ); + const projectConfigurationIdentity: string | undefined = onEngine + ? await getProjectConfigurationIdentityAsync(projectConfigurations, this.rushConfiguration.daemon.usePersistentIpcRunners) + : undefined; const includePhaseDeps: boolean = this.#includePhaseDeps?.value ?? false; @@ -660,11 +742,26 @@ export class PhasedScriptAction extends BaseScriptAction i }; } + const getGraphInputsSnapshotAsync: GetInputsSnapshotAsyncFn | undefined = + onEngine && getInputsSnapshotAsync + ? async () => { + await this.#validateInstallStateAsync(); + const currentConfigurations: ReadonlyMap = + await RushProjectConfiguration._tryLoadForProjectsUncachedAsync(relevantProjects, terminal); + if ( + await getProjectConfigurationIdentityAsync(currentConfigurations, this.rushConfiguration.daemon.usePersistentIpcRunners) !== + projectConfigurationIdentity + ) { + throw new PhasedCommandEngineConfigurationChangedError(); + } + return await getInputsSnapshotAsync(); + } + : getInputsSnapshotAsync; const graphOptions: IOperationGraphOptions = { quietMode: isQuietMode, debugMode: this.parser.isDebug, destinations: [ - _isRushSessionOperationStreamEnabled(this.rushSession) + onEngine || _isRushSessionOperationStreamEnabled(this.rushSession) ? new CallbackWritable({ onWriteChunk: () => undefined }) : StdioWritable.instance ], @@ -672,27 +769,69 @@ export class PhasedScriptAction extends BaseScriptAction i maxParallelism, allowOversubscription: this.#allowOversubscription, isWatch, - pauseNextIteration: false, - getInputsSnapshotAsync, + pauseNextIteration: !!onEngine, + getInputsSnapshotAsync: getGraphInputsSnapshotAsync, abortController: this.sessionAbortController, + closeRunnersOnAbort: !onEngine, telemetry: executionTelemetryHandler }; const graph: OperationGraph = new OperationGraph(operations, graphOptions); + ownedGraph = graph; + if (onEngine) { + // BaseRushAction prepends the repository bin directory by mutating PATH. Engine hosts + // instead supply that same prefix only to operation environments, before plugin transforms. + graph.hooks.createEnvironmentForOperation.tap( + { name: 'PhasedCommandEngine', stage: -Infinity }, + (environment) => { + const result: EnvironmentMap = new EnvironmentMap(environment); + result.set( + 'PATH', + `${path.join(this.rushConfiguration.commonTempFolder, 'node_modules', '.bin')}${path.delimiter}${result.get('PATH') ?? ''}` + ); + return result.toObject(); + } + ); + } const graphContext: IOperationGraphContext = { ...createOperationsContext, initialSnapshot }; + await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { + await hooks.onGraphCreatedAsync.promise(graph, graphContext); + }); + if (onEngine) { + if (!getGraphInputsSnapshotAsync || !initialSnapshot) { + throw new Error('The daemon engine requires a Git-backed workspace inputs snapshot.'); + } + let disposePromise: Promise | undefined; + onEngine({ + operationGraph: graph, + rushSession: this.rushSession, + inputsSnapshot: initialSnapshot, + getInputsSnapshotAsync: getGraphInputsSnapshotAsync, + isIncremental: this.#isIncrementalBuildAllowed, + phaseNames: Array.from(new Set(Array.from(operations, (op) => op.associatedPhase.name))).sort(), + pluginNames: Array.from( + new Set([ + ...this.parser.pluginManager.loadedPluginNames, + ...hooks.createOperationsAsync.taps.map((tap) => tap.name), + ...hooks.onGraphCreatedAsync.taps.map((tap) => tap.name) + ]) + ).sort(), + [Symbol.asyncDispose]: () => + (disposePromise ??= disposeEngineGraphAsync(graph, cobuildConfiguration)) + }); + transferredEngine = true; + return; + } + const abortPromise: Promise = once(this.sessionAbortController.signal, 'abort').then(async () => { terminal.writeLine(`Shutting down Rush...`); return await graph.abortCurrentIterationAsync(); }); - - await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { - await hooks.onGraphCreatedAsync.promise(graph, graphContext); - }); attachReporterOperationEventSink(graph, this.rushSession, this.actionName, isWatch); const executeOptions: IExecuteOperationsOptions = { @@ -725,7 +864,7 @@ export class PhasedScriptAction extends BaseScriptAction i rushConfiguration: this.rushConfiguration, graph, initialSnapshot, - terminal, + terminal: presentationTerminal, debounceMs: this.#watchDebounceMs, renderStatusInPlace: !_isRushSessionOperationStreamEnabled(this.rushSession) }); @@ -746,7 +885,9 @@ export class PhasedScriptAction extends BaseScriptAction i ); } } finally { - if (cobuildConfiguration) { + if (onEngine && !transferredEngine && ownedGraph) { + await disposeEngineGraphAsync(ownedGraph, cobuildConfiguration); + } else if (cobuildConfiguration && !transferredEngine) { await cobuildConfiguration.destroyLockProviderAsync(); } } @@ -808,6 +949,31 @@ export class PhasedScriptAction extends BaseScriptAction i } } + async #validateInstallStateAsync(): Promise { + if (!this._runsBeforeInstall) { + await measureAsyncFn(`${PERF_PREFIX}:checkInstallFlag`, async () => { + const { + defaultSubspace, + subspacesFeatureEnabled, + pnpmOptions: { useWorkspaces } + } = this.rushConfiguration; + // TODO: Replace with last-install.flag when "rush link" and "rush unlink" are removed + const lastLinkFlag: FlagFile = new FlagFile( + defaultSubspace.getSubspaceTempFolderPath(), + RushConstants.lastLinkFlagFilename, + {} + ); + if (!(await lastLinkFlag.isValidAsync()) && !subspacesFeatureEnabled) { + if (useWorkspaces) { + throw new Error('Link flag invalid.\nDid you run "rush install" or "rush update"?'); + } else { + throw new Error('Link flag invalid.\nDid you run "rush link"?'); + } + } + }); + } + } + #doBeforeTask(): void { if ( this.actionName !== RushConstants.buildCommandName && @@ -833,3 +999,47 @@ export class PhasedScriptAction extends BaseScriptAction i this.eventHooksManager.handle(Event.postRushBuild, this.parser.isDebug, this.#ignoreHooksParameter.value); } } + +async function getProjectConfigurationIdentityAsync( + configurations: ReadonlyMap, + persistentIpc: boolean +): Promise { + return JSON.stringify( + [await getDaemonIpcImplementationIdentityAsync(configurations, persistentIpc), + Array.from(configurations, ([project, configuration]) => ({ + project: project.packageName, + incrementalBuildIgnoredGlobs: configuration.incrementalBuildIgnoredGlobs, + disableBuildCacheForProject: configuration.disableBuildCacheForProject, + operations: Array.from(configuration.operationSettingsByOperationName).sort(([left], [right]) => + left.localeCompare(right) + ) + })).sort((left, right) => left.project.localeCompare(right.project))] + ); +} + +async function disposeEngineGraphAsync( + graph: OperationGraph, + cobuildConfiguration: CobuildConfiguration | undefined +): Promise { + graph.abortController.abort(); + const errors: unknown[] = []; + for (const cleanupAsync of [ + () => graph.abortCurrentIterationAsync(), + () => graph.closeRunnersAsync(), + async () => { + await cobuildConfiguration?.destroyLockProviderAsync(); + } + ]) { + try { + await cleanupAsync(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Failed to dispose native phased engine resources.'); + } +} diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts index 590e7a4405..6a7920715e 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParser.test.ts @@ -42,6 +42,7 @@ import { type IEnvironmentConfigIsolation } from './TestUtils'; import { IS_WINDOWS } from '../../utilities/executionUtilities'; +import { AnsiEscape } from '@rushstack/terminal'; // Ordinals into the `mock.calls` array referencing each of the arguments to `spawn`. Note that // the exact structure of these arguments differs between Windows and non-Windows platforms, so @@ -78,14 +79,37 @@ jest.setTimeout(1000000); function expectSpawnToMatchRegexp(spawnCall: SpawnMockCall, expectedRegexp: RegExp): void { if (IS_WINDOWS) { - // On Windows, the command is passed as a single string with the `shell: true` option - spawnOptionEquals(spawnCall, 'shell', true); + // Phased runners supply an explicit environment, so Windows uses that environment's shell. + spawnOptionEquals(spawnCall, 'shell', process.env.COMSPEC || process.env.comspec || 'cmd.exe'); expect(spawnCall[0]).toMatch(expectedRegexp); } else { expect(spawnCall[1]).toEqual(expect.arrayContaining([expect.stringMatching(expectedRegexp)])); } } +async function expectInitializationFailureAsync(repoName: string, expectedMessage: string): Promise { + const originalExitCode: string | number | undefined = process.exitCode; + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { parser, spawnMock } = await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); + await expect(parser.executeAsync()).resolves.toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + expect(exitSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); + expect(spawnMock).not.toHaveBeenCalled(); + const errors: string = AnsiEscape.removeCodes( + errorSpy.mock.calls.map((args) => args.join(' ')).join('\n') + ).replace(/\s+/g, ' '); + expect(errors).toContain(expectedMessage); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + process.exitCode = originalExitCode; + } +} + describe('RushCommandLineParser', () => { describe('execute', () => { let _envIsolation: IEnvironmentConfigIsolation; @@ -101,6 +125,36 @@ describe('RushCommandLineParser', () => { describe('in basic repo', () => { describe("'build' action", () => { + if (IS_WINDOWS) { + it.each([ + { comspec: 'C:\\Native shell\\cmd.exe', expectedShell: 'C:\\Native shell\\cmd.exe' }, + { comspec: undefined, expectedShell: 'cmd.exe' } + ])( + 'uses exactly $expectedShell for an explicit Windows runner environment', + async ({ comspec, expectedShell }) => { + const { parser, spawnMock } = await getCommandLineParserInstanceAsync( + 'basicAndRunBuildActionRepo', + 'build' + ); + const originalComspec: string | undefined = process.env.COMSPEC; + try { + if (comspec === undefined) delete process.env.COMSPEC; + else process.env.COMSPEC = comspec; + + await expect(parser.executeAsync()).resolves.toEqual(true); + + expect(spawnMock.mock.calls).toHaveLength(2); + for (const spawnCall of spawnMock.mock.calls) { + spawnOptionEquals(spawnCall, 'shell', expectedShell); + } + } finally { + if (originalComspec === undefined) delete process.env.COMSPEC; + else process.env.COMSPEC = originalComspec; + } + } + ); + } + it(`executes the package's 'build' script`, async () => { const repoName: string = 'basicAndRunBuildActionRepo'; const reporterSink: CapturingReporterSink = new CapturingReporterSink(); @@ -390,49 +444,37 @@ describe('RushCommandLineParser', () => { }); describe("in repo with 'build' command overridden as a global command", () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'overrideBuildAsGlobalCommandRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"command-line.json defines a command \\"build\\" using the command kind \\"global\\". This command can only be designated as a command kind \\"bulk\\" or \\"phased\\"."` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'overrideBuildAsGlobalCommandRepo', + 'command-line.json defines a command "build" using the command kind "global". This command can only be designated as a command kind "bulk" or "phased".' ); }); }); describe("in repo with 'rebuild' command overridden as a global command", () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'overrideRebuildAsGlobalCommandRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"command-line.json defines a command \\"rebuild\\" using the command kind \\"global\\". This command can only be designated as a command kind \\"bulk\\" or \\"phased\\"."` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'overrideRebuildAsGlobalCommandRepo', + 'command-line.json defines a command "rebuild" using the command kind "global". This command can only be designated as a command kind "bulk" or "phased".' ); }); }); describe("in repo with 'build' command overridden with 'safeForSimultaneousRushProcesses=true'", () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'overrideBuildWithSimultaneousProcessesRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"command-line.json defines a command \\"build\\" using \\"safeForSimultaneousRushProcesses=true\\". This configuration is not supported for \\"build\\"."` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'overrideBuildWithSimultaneousProcessesRepo', + 'command-line.json defines a command "build" using "safeForSimultaneousRushProcesses=true". This configuration is not supported for "build".' ); }); }); describe("in repo with 'rebuild' command overridden with 'safeForSimultaneousRushProcesses=true'", () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'overrideRebuildWithSimultaneousProcessesRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"command-line.json defines a command \\"rebuild\\" using \\"safeForSimultaneousRushProcesses=true\\". This configuration is not supported for \\"rebuild\\"."` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'overrideRebuildWithSimultaneousProcessesRepo', + 'command-line.json defines a command "rebuild" using "safeForSimultaneousRushProcesses=true". This configuration is not supported for "rebuild".' ); }); }); @@ -592,25 +634,19 @@ describe('RushCommandLineParser', () => { }); describe('in repo plugin with conflict build command', () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'pluginWithConflictBuildCommandRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error from plugin rush-build-command-plugin by rush-build-command-plugin: Error: command-line.json defines a command \\"build\\" using a name that already exists"` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'pluginWithConflictBuildCommandRepo', + 'Error from plugin rush-build-command-plugin by rush-build-command-plugin: Error: command-line.json defines a command "build" using a name that already exists' ); }); }); describe("in repo plugin with conflict rebuild command'", () => { - it(`throws an error when starting Rush`, async () => { - const repoName: string = 'pluginWithConflictRebuildCommandRepo'; - - await expect(async () => { - await getCommandLineParserInstanceAsync(repoName, 'doesnt-matter'); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"command-line.json defines a parameter \\"--no-color\\" that is associated with a command \\"build\\" that is not defined in this file."` + it('reports an error and refuses execution when starting Rush', async () => { + await expectInitializationFailureAsync( + 'pluginWithConflictRebuildCommandRepo', + 'command-line.json defines a parameter "--no-color" that is associated with a command "build" that is not defined in this file.' ); }); }); diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts index 6ddafceb84..7cca959786 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterClose.test.ts @@ -78,27 +78,32 @@ describe('RushCommandLineParser reporter close', () => { it('waits for reporter close before an explicit parser exit', async () => { let resolveClose: (() => void) | undefined; + let markCloseStarted!: () => void; + const closeStarted: Promise = new Promise((resolve) => { markCloseStarted = resolve; }); const closeAsync: jest.Mock, []> = jest.fn( - () => - new Promise((resolve: () => void) => { + () => { + markCloseStarted(); + return new Promise((resolve: () => void) => { resolveClose = resolve; - }) + }); + } ); const sink: CapturingReporterSink = new CapturingReporterSink(); - const exitSpy: jest.SpyInstance = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - jest.spyOn(console, 'error').mockImplementation(() => undefined); - process.exitCode = 0; - jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { - throw new Error('parser failed'); - }); const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: `${__dirname}/repo`, reporter: { eventSink: sink, sessionId: 'parser-exit-close' }, reporterCloseAsync: closeAsync }); - const execution: Promise = parser.executeAsync(); + jest.spyOn(parser.pluginManager, 'tryInitializeUnassociatedPluginsAsync') + .mockRejectedValue(new Error('parser failed')); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + process.exitCode = 0; + + const execution: Promise = parser.executeAsync(['build']); + await closeStarted; expect(closeAsync).toHaveBeenCalledTimes(1); expect(sink.events.at(-1)).toMatchObject({ type: 'sessionCompleted', payload: { exitCode: 1 } }); @@ -144,9 +149,7 @@ describe('RushCommandLineParser reporter close', () => { it('reports close failure without rejecting from parser finalization', async () => { const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: `${__dirname}/repo`, - reporterCloseAsync: async () => { - throw new Error('close failed'); - } + reporterCloseAsync: async () => { throw new Error('close failed'); } }); const errorSpy: jest.SpyInstance = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); jest.spyOn(process.stdout, 'write').mockImplementation(() => true); @@ -169,9 +172,7 @@ describe('RushCommandLineParser reporter close', () => { jest.spyOn(RushConfiguration, 'tryFindRushJsonLocation').mockImplementation(() => { throw new Error('configuration failed'); }); - const exitSpy: jest.SpyInstance = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); jest.spyOn(console, 'error').mockImplementation(() => undefined); const parser: RushCommandLineParser = new RushCommandLineParser({ cwd: `${__dirname}/repo`, diff --git a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts index 1ad208b7d7..a1c8437016 100644 --- a/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts +++ b/libraries/rush-lib/src/cli/test/RushCommandLineParserReporterLifecycle.test.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; import { once } from 'node:events'; -import { JsonFile, LockFile } from '@rushstack/node-core-library'; +import { Async, JsonFile, LockFile } from '@rushstack/node-core-library'; import type { IReporterEmitEventInput, IReporterEventSink, IRushDiagnostic } from '@rushstack/rush-reporter'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; @@ -44,6 +44,23 @@ describe('RushCommandLineParser reporter lifecycle', () => { let stdoutSpy: jest.SpyInstance; let stderrSpy: jest.SpyInstance; let lockSpy: jest.SpiedFunction; + let watchTest: Promise | undefined; + let cancelWatchTest: (() => void) | undefined; + + function ownWatchTest(testAsync: (signal: AbortSignal) => Promise): Promise { + const controller: AbortController = new AbortController(); + cancelWatchTest = () => controller.abort(); + watchTest = testAsync(controller.signal); + return watchTest; + } + + async function stopWatchTestAsync(): Promise { + const pending: Promise | undefined = watchTest; + cancelWatchTest?.(); + watchTest = undefined; + cancelWatchTest = undefined; + await pending; + } async function copyRepositoryAsync(): Promise { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-reporter-lifecycle-')); @@ -65,22 +82,75 @@ describe('RushCommandLineParser reporter lifecycle', () => { }); afterEach(async () => { - for (const result of lockSpy.mock.results) { - if (result.type === 'return' && result.value && !result.value.isReleased) { - result.value.release(); + try { + // Jest timeouts do not cancel the test promise or run its finally block. + await stopWatchTestAsync(); + } finally { + for (const result of lockSpy.mock.results) { + if (result.type === 'return' && result.value && !result.value.isReleased) { + result.value.release(); + } + } + try { + await Promise.all( + temporaryFolders + .splice(0) + .map((directory) => fs.promises.rm(directory, { recursive: true, force: true })) + ); + } finally { + process.exitCode = originalExitCode; + process.argv = originalArgv; + EnvironmentConfiguration.reset(); + jest.restoreAllMocks(); } } - await Promise.all( - temporaryFolders - .splice(0) - .map((directory) => fs.promises.rm(directory, { recursive: true, force: true })) - ); - process.exitCode = originalExitCode; - process.argv = originalArgv; - EnvironmentConfiguration.reset(); - jest.restoreAllMocks(); }); + it.each([false, true])( + 'joins a cancelled watch test through setup and finalization (reject: %s)', + async (reject) => { + const [setup, releaseSetup] = Async.getSignal(); + const [finalizing, markFinalizing] = Async.getSignal(); + const [finalized, releaseFinalization] = Async.getSignal(); + const failure: Error = new Error('watch test failed'); + let signal: AbortSignal | undefined; + let joined: boolean = false; + const execution: Promise = ownWatchTest(async (testSignal) => { + signal = testSignal; + await setup; + try { + if (reject) { + throw failure; + } + } finally { + markFinalizing(); + await finalized; + } + }); + const stopping: Promise = stopWatchTestAsync().finally(() => { + joined = true; + }); + const settled: Promise[]> = Promise.allSettled([execution, stopping]); + try { + expect(signal?.aborted).toBe(true); + expect(joined).toBe(false); + releaseSetup(); + await finalizing; + expect(joined).toBe(false); + } finally { + releaseSetup(); + releaseFinalization(); + await settled; + } + expect(joined).toBe(true); + expect(await settled).toEqual( + [0, 1].map(() => + reject ? { status: 'rejected', reason: failure } : { status: 'fulfilled', value: undefined } + ) + ); + } + ); + it.each([ { file: 'rush.json', withClose: false }, { file: 'rush.json', withClose: true }, @@ -391,122 +461,137 @@ describe('RushCommandLineParser reporter lifecycle', () => { { reporting: true, useAlias: true } ])( 'observes a real watch cancellation without changing legacy exit (shadow: $reporting, alias: $useAlias)', - async ({ reporting, useAlias }) => { - const repoPath: string = await copyRepositoryAsync(); - JsonFile.save( - { - commands: [ - { - commandKind: 'bulk', - name: 'watch-test', - summary: 'Watch cancellation fixture', - watchForChanges: true, - enableParallelism: false, - disableBuildCache: true, - safeForSimultaneousRushProcesses: true - } - ] - }, - path.join(repoPath, 'common/config/rush/command-line.json') - ); - JsonFile.save({}, path.join(repoPath, 'common/config/rush/npm-shrinkwrap.json')); - for (const name of ['a', 'b']) { + ({ reporting, useAlias }) => + ownWatchTest(async (signal) => { + const repoPath: string = await copyRepositoryAsync(); JsonFile.save( - { name, version: '1.0.0', scripts: { 'watch-test': 'node watch-test.js' } }, - path.join(repoPath, name, 'package.json') + { + commands: [ + { + commandKind: 'bulk', + name: 'watch-test', + summary: 'Watch cancellation fixture', + watchForChanges: true, + enableParallelism: false, + disableBuildCache: true, + safeForSimultaneousRushProcesses: true + } + ] + }, + path.join(repoPath, 'common/config/rush/command-line.json') ); - await fs.promises.writeFile( - path.join(repoPath, name, 'watch-test.js'), - 'process.stdout.write("watch child output\\n");\n' + JsonFile.save({}, path.join(repoPath, 'common/config/rush/npm-shrinkwrap.json')); + for (const name of ['a', 'b']) { + JsonFile.save( + { name, version: '1.0.0', scripts: { 'watch-test': 'node watch-test.js' } }, + path.join(repoPath, name, 'package.json') + ); + await fs.promises.writeFile( + path.join(repoPath, name, 'watch-test.js'), + 'process.stdout.write("watch child output\\n");\n' + ); + } + // Capture successful fixture setup diagnostics; failed Git commands still throw with their stderr. + execFileSync('git', ['init', '--quiet'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Rush test', + '-c', + 'user.email=rush-test@example.com', + '-c', + 'commit.gpgSign=false', + 'commit', + '--quiet', + '-m', + 'Initialize watch fixture' + ], + { cwd: repoPath, stdio: 'pipe' } ); - } - // Capture successful fixture setup diagnostics; failed Git commands still throw with their stderr. - execFileSync('git', ['init', '--quiet'], { cwd: repoPath, stdio: 'pipe' }); - execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); - execFileSync( - 'git', - [ - '-c', - 'user.name=Rush test', - '-c', - 'user.email=rush-test@example.com', - '-c', - 'commit.gpgSign=false', - 'commit', - '--quiet', - '-m', - 'Initialize watch fixture' - ], - { cwd: repoPath, stdio: 'pipe' } - ); - const sink: CapturingReporterSink = new CapturingReporterSink(); - const exitSpy: jest.SpyInstance = jest - .spyOn(process, 'exit') - .mockImplementation(() => undefined as never); - const watchSpy: jest.SpyInstance = jest.spyOn(fs, 'watch'); - const cwd: string = useAlias ? path.join(path.dirname(repoPath), 'repo-alias') : repoPath; - if (useAlias) { - await fs.promises.symlink( - await fs.promises.realpath(repoPath), + const sink: CapturingReporterSink = new CapturingReporterSink(); + const exitSpy: jest.SpyInstance = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const watchSpy: jest.SpyInstance = jest.spyOn(fs, 'watch'); + const cwd: string = useAlias ? path.join(path.dirname(repoPath), 'repo-alias') : repoPath; + if (useAlias) { + await fs.promises.symlink( + await fs.promises.realpath(repoPath), + cwd, + process.platform === 'win32' ? 'junction' : 'dir' + ); + } + signal.throwIfAborted(); + const parser: RushCommandLineParser = new RushCommandLineParser({ cwd, - process.platform === 'win32' ? 'junction' : 'dir' - ); - } - const parser: RushCommandLineParser = new RushCommandLineParser({ - cwd, - reporter: reporting ? { eventSink: sink, sessionId: 'real-watch-cancellation' } : undefined - }); - await new FlagFile( - parser.rushConfiguration.defaultSubspace.getSubspaceTempFolderPath(), - RushConstants.lastLinkFlagFilename, - {} - ).createAsync(); - const action = parser.getAction('watch-test'); - if (!(action instanceof PhasedScriptAction)) { - throw new Error('Expected the production phased watch action'); - } - let reachedWatchIdle: boolean = false; - let closedWatchers: Promise[] = []; - parser.rushSession.hooks.runPhasedCommand.for('watch-test').tap('CancelRealWatch', (command) => { - command.hooks.onGraphCreatedAsync.tap('CancelRealWatch', (graph) => { - graph.hooks.onIdle.tap({ name: 'CancelRealWatch', stage: Number.MAX_SAFE_INTEGER }, () => { - reachedWatchIdle = true; - closedWatchers = watchSpy.mock.results.map(({ value }) => once(value as fs.FSWatcher, 'close')); - action.sessionAbortController.abort(); - }); + reporter: reporting ? { eventSink: sink, sessionId: 'real-watch-cancellation' } : undefined }); - }); - const execution: Promise = parser.executeAsync(['watch-test', '--verbose']); - try { - await expect(execution).resolves.toBe(true); - await Promise.all(closedWatchers); - expect(reachedWatchIdle).toBe(true); - expect(parser.cwd).toBe(await fs.promises.realpath(repoPath)); - expect(watchSpy.mock.calls.length).toBeGreaterThan(0); - expect(action.sessionAbortController.signal.aborted).toBe(true); - expect(exitSpy).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(0); - expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual( - reporting ? { exitCode: 1, outcome: 'cancelled' } : undefined - ); - if (reporting) { - expect(sink.events.filter(isCompletion).map(({ payload }) => payload)).toEqual([ - expect.objectContaining({ succeeded: true, exitCode: 0 }), - expect.objectContaining({ exitCode: 0 }), - expect.objectContaining({ exitCode: 0 }) - ]); - expect(_getRushSessionTelemetryAggregate(parser.rushSession)).toMatchObject({ - result: 'succeeded', - exitCode: 0, - operationStatusCounts: { success: 2 } + await new FlagFile( + parser.rushConfiguration.defaultSubspace.getSubspaceTempFolderPath(), + RushConstants.lastLinkFlagFilename, + {} + ).createAsync(); + const action = parser.getAction('watch-test'); + if (!(action instanceof PhasedScriptAction)) { + throw new Error('Expected the production phased watch action'); + } + let reachedWatchIdle: boolean = false; + let closedWatchers: Promise[] = []; + const abort = (): void => action.sessionAbortController.abort(); + parser.rushSession.hooks.runPhasedCommand.for('watch-test').tap('CancelRealWatch', (command) => { + command.hooks.onGraphCreatedAsync.tap('CancelRealWatch', (graph) => { + // Wait until the action and watcher have installed their abort listeners. + graph.hooks.beforeExecuteIterationAsync.tap('CancelRealWatch', () => { + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) { + abort(); + } + }); + graph.hooks.onIdle.tap({ name: 'CancelRealWatch', stage: Number.MAX_SAFE_INTEGER }, () => { + reachedWatchIdle = true; + closedWatchers = watchSpy.mock.results.map(({ value }) => once(value as fs.FSWatcher, 'close')); + action.sessionAbortController.abort(); + }); }); - expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + }); + signal.throwIfAborted(); + const execution: Promise = parser.executeAsync(['watch-test', '--verbose']); + try { + await expect(execution).resolves.toBe(true); + await Promise.all(closedWatchers); + expect(reachedWatchIdle).toBe(true); + expect(parser.cwd).toBe(await fs.promises.realpath(repoPath)); + expect(watchSpy.mock.calls.length).toBeGreaterThan(0); + expect(action.sessionAbortController.signal.aborted).toBe(true); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + expect(_getRushSessionDerivedExitStatus(parser.rushSession)).toEqual( + reporting ? { exitCode: 1, outcome: 'cancelled' } : undefined + ); + if (reporting) { + expect(sink.events.filter(isCompletion).map(({ payload }) => payload)).toEqual([ + expect.objectContaining({ succeeded: true, exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }), + expect.objectContaining({ exitCode: 0 }) + ]); + expect(_getRushSessionTelemetryAggregate(parser.rushSession)).toMatchObject({ + result: 'succeeded', + exitCode: 0, + operationStatusCounts: { success: 2 } + }); + expect(sink.events.filter(({ type }) => type === 'diagnosticEmitted')).toEqual([]); + } + } finally { + abort(); + try { + await execution; + await Promise.all(closedWatchers); + } finally { + signal.removeEventListener('abort', abort); + } } - } finally { - action.sessionAbortController.abort(); - await execution; - await Promise.all(closedWatchers); - } - } + }) ); }); diff --git a/libraries/rush-lib/src/cli/test/mockRushCommandLineParser.ts b/libraries/rush-lib/src/cli/test/mockRushCommandLineParser.ts index 9904cfcabf..32e262ab89 100644 --- a/libraries/rush-lib/src/cli/test/mockRushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/test/mockRushCommandLineParser.ts @@ -3,21 +3,3 @@ // Mock child_process so we can verify tasks are (or are not) invoked as we expect jest.mock('node:child_process', () => jest.requireActual('./mock_child_process')); - -function mockReportErrorAndSetExitCode(error: Error): void { - // Just rethrow the error so the unit tests can catch it - throw error; -} - -/** - * Mock RushCommandLineParser itself to prevent `process.exit` to be called on failure - */ -jest.mock('../RushCommandLineParser', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actualModule: any = jest.requireActual('../RushCommandLineParser'); - if (actualModule.RushCommandLineParser) { - // Stub out the troublesome method that calls `process.exit` - actualModule.RushCommandLineParser.prototype._reportErrorAndSetExitCode = mockReportErrorAndSetExitCode; - } - return actualModule; -}); diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 6f0bb4c5e6..acb909888c 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -25,6 +25,11 @@ export { type IRushReportingConfiguration, type ITryFindRushJsonLocationOptions } from './api/RushConfiguration'; +export { + resolveDaemonConfiguration, + daemonEnvironmentVariables, + type IDaemonConfigurationJson +} from './api/DaemonConfiguration'; export { Subspace } from './api/Subspace'; export { SubspacesConfiguration } from './api/SubspacesConfiguration'; @@ -76,6 +81,7 @@ export { } from './api/EnvironmentConfiguration'; export { RushConstants } from './logic/RushConstants'; +export { RushXCommand, type IRushXCommandLineArguments, type IRushXCommandOptions } from './cli/RushXCommandLine'; export { type PackageManagerName, PackageManager } from './api/packageManager/PackageManager'; @@ -84,6 +90,7 @@ export { RushConfigurationProject } from './api/RushConfigurationProject'; export { type IRushProjectJson as _IRushProjectJson, type IOperationSettings, + type IDaemonIpcConfiguration, type NodeVersionGranularity, RushProjectConfiguration, type IRushPhaseSharding @@ -163,6 +170,22 @@ export type { export { type IOperationOptions, type OperationEnabledState, Operation } from './logic/operations/Operation'; export { type IParallelismScalar, type Parallelism } from './logic/operations/ParseParallelism'; export { OperationStatus } from './logic/operations/OperationStatus'; +export { + PhasedCommandEngine, + type IPhasedCommandEngine, + type IParsePhasedCommandOptions +} from './api/PhasedCommandEngine'; +export { PhasedCommandEngineConfigurationChangedError } from './api/PhasedCommandEngineConfigurationChangedError'; +export { PhasedCommandEngineBusyError } from './api/PhasedCommandEngineBusyError'; +export { + captureWorkspaceInputFingerprintAsync, + captureProjectConfigurationFingerprintAsync, + classifyWorkspaceInputChange, + WorkspaceInputChangeTier, + WorkspaceRuntimeFingerprintCache, + type IWorkspaceInputFingerprint, + type IWorkspaceInputFingerprintOptions +} from './api/WorkspaceInputFingerprint'; export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; export { @@ -203,6 +226,7 @@ export { } from './pluginFramework/PhasedCommandHooks'; export type { IOperationGraph, IOperationGraphIterationOptions } from './logic/operations/IOperationGraph'; export type { + IOperationChildProcessReporter as _IOperationChildProcessReporter, IOperationGraphEventSink as _IOperationGraphEventSink, IOperationActivityOptions as _IOperationActivityOptions } from './logic/operations/OperationEventSink'; diff --git a/libraries/rush-lib/src/logic/EventHooksManager.ts b/libraries/rush-lib/src/logic/EventHooksManager.ts index a3b782c616..ad651f9bb9 100644 --- a/libraries/rush-lib/src/logic/EventHooksManager.ts +++ b/libraries/rush-lib/src/logic/EventHooksManager.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { Colorize } from '@rushstack/terminal'; +import { Colorize, type ITerminal } from '@rushstack/terminal'; import type { EventHooks } from '../api/EventHooks'; import { type IEnvironment, Utilities } from '../utilities/Utilities'; @@ -21,7 +21,7 @@ export class EventHooksManager { this.#commonTempFolder = rushConfiguration.commonTempFolder; } - public handle(event: Event, isDebug: boolean, ignoreHooks: boolean): void { + public handle(event: Event, isDebug: boolean, ignoreHooks: boolean, terminal?: ITerminal): void { if (!this.#eventHooks) { return; } @@ -29,8 +29,10 @@ export class EventHooksManager { const scripts: string[] = this.#eventHooks.get(event); if (scripts.length > 0) { if (ignoreHooks) { + const message: string = `Skipping event hooks for ${Event[event]} since --ignore-hooks was specified`; + if (terminal) terminal.writeLine(message); // eslint-disable-next-line no-console - console.log(`Skipping event hooks for ${Event[event]} since --ignore-hooks was specified`); + else console.log(message); return; } diff --git a/libraries/rush-lib/src/logic/NodeJsCompatibility.ts b/libraries/rush-lib/src/logic/NodeJsCompatibility.ts index d2f579be3d..03743f3c28 100644 --- a/libraries/rush-lib/src/logic/NodeJsCompatibility.ts +++ b/libraries/rush-lib/src/logic/NodeJsCompatibility.ts @@ -3,7 +3,7 @@ import * as semver from 'semver'; -import { Colorize } from '@rushstack/terminal'; +import { Colorize, type ITerminal } from '@rushstack/terminal'; // Minimize dependencies to avoid compatibility errors that might be encountered before // NodeJsCompatibility.terminateIfVersionIsTooOld() gets to run. @@ -23,6 +23,7 @@ const nodeMajorVersion: number = semver.major(nodeVersion); export interface IWarnAboutVersionTooNewOptions { isRushLib: boolean; + terminal?: ITerminal; /** * The CLI front-end does an early check for NodeJsCompatibility.warnAboutVersionTooNew(), @@ -47,18 +48,19 @@ export class NodeJsCompatibility { * This reports if the Node.js version is known to have serious incompatibilities. In that situation, the user * should downgrade Rush to an older release that supported their Node.js version. */ - public static reportAncientIncompatibleVersion(): boolean { + public static reportAncientIncompatibleVersion(terminal?: ITerminal): boolean { // IMPORTANT: If this test fails, the Rush CLI front-end process will terminate with an error. // Only increment it when our code base is known to use newer features (e.g. "async"/"await") that // have no hope of working with older Node.js. if (semver.satisfies(nodeVersion, '<14.18.0')) { - // eslint-disable-next-line no-console - console.error( + const message: string = Colorize.red( `Your version of Node.js (${nodeVersion}) is very old and incompatible with Rush. ` + `Please upgrade to the latest Long-Term Support (LTS) version.\n` - ) - ); + ); + if (terminal) terminal.writeErrorLine(message); + // eslint-disable-next-line no-console + else console.error(message); return true; } else { return false; @@ -74,10 +76,10 @@ export class NodeJsCompatibility { public static warnAboutCompatibilityIssues(options: IWarnAboutCompatibilityIssuesOptions): boolean { // Only show the first warning return ( - NodeJsCompatibility.reportAncientIncompatibleVersion() || + NodeJsCompatibility.reportAncientIncompatibleVersion(options.terminal) || NodeJsCompatibility.warnAboutVersionTooNew(options) || - _warnAboutOddNumberedVersion() || - _warnAboutNonLtsVersion(options.rushConfiguration) + _warnAboutOddNumberedVersion(options.terminal) || + _warnAboutNonLtsVersion(options.rushConfiguration, options.terminal) ); } @@ -89,22 +91,20 @@ export class NodeJsCompatibility { if (!options.alreadyReportedNodeTooNewError) { // We are on a much newer release than we have tested and support if (options.isRushLib) { - // eslint-disable-next-line no-console - console.warn( + _writeWarning( Colorize.yellow( `Your version of Node.js (${nodeVersion}) has not been tested with this release ` + `of the Rush engine. Please consider upgrading the "rushVersion" setting in ${RushConstants.rushJsonFilename}, ` + `or downgrading Node.js.\n` - ) + ), options.terminal ); } else { - // eslint-disable-next-line no-console - console.warn( + _writeWarning( Colorize.yellow( `Your version of Node.js (${nodeVersion}) has not been tested with this release ` + `of Rush. Please consider installing a newer version of the "@microsoft/rush" ` + `package, or downgrading Node.js.\n` - ) + ), options.terminal ); } } @@ -124,14 +124,13 @@ export class NodeJsCompatibility { } } -function _warnAboutNonLtsVersion(rushConfiguration: RushConfiguration | undefined): boolean { +function _warnAboutNonLtsVersion(rushConfiguration: RushConfiguration | undefined, terminal?: ITerminal): boolean { if (rushConfiguration && !rushConfiguration.suppressNodeLtsWarning && !NodeJsCompatibility.isLtsVersion) { - // eslint-disable-next-line no-console - console.warn( + _writeWarning( Colorize.yellow( `Your version of Node.js (${nodeVersion}) is not a Long-Term Support (LTS) release. ` + 'These versions frequently have bugs. Please consider installing a stable release.\n' - ) + ), terminal ); return true; @@ -140,19 +139,25 @@ function _warnAboutNonLtsVersion(rushConfiguration: RushConfiguration | undefine } } -function _warnAboutOddNumberedVersion(): boolean { +function _warnAboutOddNumberedVersion(terminal?: ITerminal): boolean { if (NodeJsCompatibility.isOddNumberedVersion) { - // eslint-disable-next-line no-console - console.warn( + _writeWarning( Colorize.yellow( `Your version of Node.js (${nodeVersion}) is an odd-numbered release. ` + `These releases frequently have bugs. Please consider installing a Long Term Support (LTS) ` + `version instead.\n` - ) + ), terminal ); return true; } else { return false; } + +} + +function _writeWarning(message: string, terminal: ITerminal | undefined): void { + if (terminal) terminal.writeWarningLine(message, { doNotOverrideSgrCodes: true }); + // eslint-disable-next-line no-console + else console.warn(message); } diff --git a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts index cb8c0f7e0a..960c313db3 100644 --- a/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts +++ b/libraries/rush-lib/src/logic/ProjectChangeAnalyzer.ts @@ -61,6 +61,10 @@ export interface IGetChangedProjectsOptions { * and exclude matched files from change detection. */ enableFiltering: boolean; + /** Optional request-owned configuration source, avoiding shared project/rig caches. */ + getIncrementalBuildIgnoredGlobsAsync?: ( + project: RushConfigurationProject + ) => Promise | undefined>; /** * If set to `true`, excludes projects where the only changes are: @@ -137,7 +141,13 @@ export class ProjectChangeAnalyzer { changesByProject, async ([project, projectChanges]) => { const filteredChanges: Map = enableFiltering - ? await this._filterProjectDataAsync(project, projectChanges, repoRoot, terminal) + ? await this._filterProjectDataAsync( + project, + projectChanges, + repoRoot, + terminal, + options.getIncrementalBuildIgnoredGlobsAsync + ) : projectChanges; // Skip if no changes @@ -483,9 +493,14 @@ export class ProjectChangeAnalyzer { project: RushConfigurationProject, unfilteredProjectData: Map, rootDir: string, - terminal: ITerminal + terminal: ITerminal, + getIgnoreGlobsAsync?: IGetChangedProjectsOptions['getIncrementalBuildIgnoredGlobsAsync'] ): Promise> { - const ignoreMatcher: Ignore | undefined = await this.#getIgnoreMatcherForProjectAsync(project, terminal); + const ignoreMatcher: Ignore | undefined = await this.#getIgnoreMatcherForProjectAsync( + project, + terminal, + getIgnoreGlobsAsync + ); if (!ignoreMatcher) { return unfilteredProjectData; } @@ -509,10 +524,12 @@ export class ProjectChangeAnalyzer { async #getIgnoreMatcherForProjectAsync( project: RushConfigurationProject, - terminal: ITerminal + terminal: ITerminal, + getIgnoreGlobsAsync?: IGetChangedProjectsOptions['getIncrementalBuildIgnoredGlobsAsync'] ): Promise { - const incrementalBuildIgnoredGlobs: ReadonlyArray | undefined = - await RushProjectConfiguration.tryLoadIgnoreGlobsForProjectAsync(project, terminal); + const incrementalBuildIgnoredGlobs: ReadonlyArray | undefined = getIgnoreGlobsAsync + ? await getIgnoreGlobsAsync(project) + : await RushProjectConfiguration.tryLoadIgnoreGlobsForProjectAsync(project, terminal); if (incrementalBuildIgnoredGlobs && incrementalBuildIgnoredGlobs.length) { const ignoreMatcher: Ignore = ignore(); @@ -752,7 +769,10 @@ export function isPackageJsonVersionOnlyChange( newPackageJsonContent: string ): boolean { try { - return isPackageJsonVersionBumpChange(JSON.parse(oldPackageJsonContent), JSON.parse(newPackageJsonContent)); + return isPackageJsonVersionBumpChange( + JSON.parse(oldPackageJsonContent), + JSON.parse(newPackageJsonContent) + ); } catch (error) { // If we can't parse the JSON, assume it's not a version-only change return false; diff --git a/libraries/rush-lib/src/logic/PurgeManager.ts b/libraries/rush-lib/src/logic/PurgeManager.ts index 3d1225e220..a8be1618ff 100644 --- a/libraries/rush-lib/src/logic/PurgeManager.ts +++ b/libraries/rush-lib/src/logic/PurgeManager.ts @@ -3,6 +3,7 @@ import * as path from 'node:path'; +import { LockFile } from '@rushstack/node-core-library'; import { Colorize } from '@rushstack/terminal'; import { AsyncRecycler } from '../utilities/AsyncRecycler'; @@ -56,9 +57,16 @@ export class PurgeManager { // eslint-disable-next-line no-console console.log('Purging ' + this.#rushConfiguration.commonTempFolder); + const lockFileName: string = path.basename( + LockFile.getLockFilePath(this.#rushConfiguration.commonTempFolder, 'rush') + ); this.commonTempFolderRecycler.moveAllItemsInFolder( this.#rushConfiguration.commonTempFolder, - this.#getMembersToExclude(this.#rushConfiguration.commonTempFolder, true) + [ + ...this.#getMembersToExclude(this.#rushConfiguration.commonTempFolder, true), + lockFileName, + `${lockFileName}.dirty` + ] ); } diff --git a/libraries/rush-lib/src/logic/Telemetry.ts b/libraries/rush-lib/src/logic/Telemetry.ts index 71c5d74f64..8c6ac3fab7 100644 --- a/libraries/rush-lib/src/logic/Telemetry.ts +++ b/libraries/rush-lib/src/logic/Telemetry.ts @@ -161,7 +161,7 @@ export class Telemetry { #dataFolder: string; #rushConfiguration: RushConfiguration; #rushSession: RushSession; - private readonly _flushAsyncTasks: Set> = new Set(); + readonly #flushAsyncTasks: Set> = new Set(); #telemetryStartTime: number = 0; public constructor(rushConfiguration: RushConfiguration, rushSession: RushSession) { @@ -234,13 +234,13 @@ export class Telemetry { * and store the promise into a list so that we can await it later. */ const asyncTaskPromise: Promise = this.#rushSession.hooks.flushTelemetry.promise(this.#store); - this._flushAsyncTasks.add(asyncTaskPromise); + this.#flushAsyncTasks.add(asyncTaskPromise); asyncTaskPromise.then( () => { - this._flushAsyncTasks.delete(asyncTaskPromise); + this.#flushAsyncTasks.delete(asyncTaskPromise); }, () => { - this._flushAsyncTasks.delete(asyncTaskPromise); + this.#flushAsyncTasks.delete(asyncTaskPromise); } ); } @@ -253,7 +253,7 @@ export class Telemetry { * There are some async tasks that are not finished when the process is exiting. */ public async ensureFlushedAsync(): Promise { - await Promise.all(this._flushAsyncTasks); + await Promise.all(this.#flushAsyncTasks); } public get store(): ITelemetryData[] { diff --git a/libraries/rush-lib/src/logic/dotenv.ts b/libraries/rush-lib/src/logic/dotenv.ts index 7df044eeca..8284e28e9d 100644 --- a/libraries/rush-lib/src/logic/dotenv.ts +++ b/libraries/rush-lib/src/logic/dotenv.ts @@ -5,10 +5,12 @@ import path from 'node:path'; import dotenv from 'dotenv'; +import { EnvironmentMap, FileSystem } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { RushUserConfiguration } from '../api/RushUserConfiguration'; -import { EnvironmentConfiguration } from '../api/EnvironmentConfiguration'; +import { EnvironmentConfiguration, EnvironmentVariableNames } from '../api/EnvironmentConfiguration'; +import { RushConstants } from './RushConstants'; export function initializeDotEnv(terminal: ITerminal, rushJsonFilePath: string | undefined): void { if (EnvironmentConfiguration.hasBeenValidated) { @@ -28,3 +30,33 @@ export function initializeDotEnv(terminal: ITerminal, rushJsonFilePath: string | // TODO: Consider adding support for repo-specific `.rush-user` `.env` files. } + +/** Request-local counterpart of initializeDotEnv; encrypted vaults require the native frontend. */ +export function loadDotEnvForEnvironment( + cwd: string, + initialEnvironment: Readonly, + rushJsonFilePath: string +): NodeJS.ProcessEnv { + const environment: EnvironmentMap = new EnvironmentMap(initialEnvironment); + environment.set(EnvironmentVariableNames.RUSH_INVOKED_FOLDER, cwd); + const load = (filename: string): void => { + if (environment.get('DOTENV_KEY')) throw new Error('Encrypted dotenv vaults require in-process Rushx.'); + const { error, parsed } = dotenv.configDotenv({ path: filename, processEnv: {} }); + if (error) { + if (FileSystem.isNotExistError(error)) return; + throw error; + } + if (!parsed) throw new Error(`Dotenv did not return parsed values for ${filename}.`); + for (const [name, value] of Object.entries(parsed)) { + if (environment.get(name) === undefined) environment.set(name, value); + } + }; + load(path.join(path.dirname(rushJsonFilePath), '.env')); + const home: string | undefined = environment.get(process.platform === 'win32' ? 'USERPROFILE' : 'HOME'); + if (home === undefined || !FileSystem.exists(path.resolve(cwd, home))) { + throw new Error("Unable to determine the current user's home directory"); + } + load(path.join(path.resolve(cwd, home), RushConstants.rushUserConfigurationFolderName, '.env')); + if (environment.get('DOTENV_KEY')) throw new Error('Encrypted dotenv vaults require in-process Rushx.'); + return environment.toObject(); +} diff --git a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts index bd98a8e6f2..769e579724 100644 --- a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts @@ -116,6 +116,15 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin { } = this.#options; hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph: IOperationGraph, context: IOperationGraphContext) => { + graph.hooks.beforeDeleteResults.tap(PLUGIN_NAME, () => { + // Terminals and cobuild callbacks can retain the entire completed iteration, including other + // projects' records. All of this scratch state is rebuilt by beforeExecuteIterationAsync. + for (const cacheContext of this.#buildCacheContextByOperation.values()) { + cacheContext.periodicCallback.stop(); + cacheContext.buildCacheTerminalWritable?.close(); + } + this.#buildCacheContextByOperation.clear(); + }); graph.hooks.beforeExecuteIterationAsync.tap( PLUGIN_NAME, ( diff --git a/libraries/rush-lib/src/logic/operations/DaemonIpcConfiguration.ts b/libraries/rush-lib/src/logic/operations/DaemonIpcConfiguration.ts new file mode 100644 index 0000000000..d2eee7c766 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/DaemonIpcConfiguration.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs/promises'; +import type { Dirent, Stats } from 'node:fs'; +import * as path from 'node:path'; +import { createHash, type Hash } from 'node:crypto'; + +import { Path } from '@rushstack/node-core-library'; + +import type { IDaemonIpcConfiguration, RushProjectConfiguration } from '../../api/RushProjectConfiguration'; +import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; + +const MAX_IMPLEMENTATION_ENTRIES: number = 256; +const MAX_IMPLEMENTATION_BYTES: number = 8 * 1024 * 1024; +const MAX_IMPLEMENTATION_DEPTH: number = 16; + +export interface IResolvedDaemonIpcConfiguration { + readonly entryPoint: string; + readonly args: ReadonlyArray; + readonly implementationHash: string; +} + +/** The dedicated entrypoint directory is the explicit implementation boundary, not a module-closure guess. */ +export async function resolveDaemonIpcConfigurationAsync( + projectFolder: string, + descriptor: IDaemonIpcConfiguration +): Promise { + const entry: string = descriptor.entryPoint; + if ( + typeof entry !== 'string' || !entry || entry.includes('\0') || + path.isAbsolute(entry) || path.win32.isAbsolute(entry) || !/\.(?:cjs|mjs|js)$/.test(entry) || + (descriptor.args !== undefined && + (!Array.isArray(descriptor.args) || descriptor.args.some((arg) => typeof arg !== 'string' || arg.includes('\0')))) + ) { + throw new Error('daemonIpc requires a project-relative Node entryPoint and literal string args without NUL.'); + } + const project: string = await fs.realpath(projectFolder); + const lexicalEntry: string = path.resolve(project, entry); + const lexicalRoot: string = path.dirname(lexicalEntry); + if (!Path.isUnder(lexicalRoot, project)) { + throw new Error('daemonIpc.entryPoint must be inside a dedicated implementation subdirectory of the project.'); + } + const root: string = await fs.realpath(lexicalRoot); + const entryPoint: string = await fs.realpath(lexicalEntry); + if (!Path.isUnder(root, project) || !Path.isUnder(entryPoint, root)) { + throw new Error('daemonIpc implementation must remain physically inside its project and dedicated directory.'); + } + const hash: Hash = createHash('sha256'); + hash.update(JSON.stringify([root, entryPoint])); + let entryCount: number = 0; + let byteCount: number = 0; + const visitAsync = async (folder: string, depth: number): Promise => { + if (depth > MAX_IMPLEMENTATION_DEPTH) throw new Error('daemonIpc implementation exceeds 16 directory levels.'); + const entries: Dirent[] = await fs.readdir(folder, { withFileTypes: true }); + entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + for (const item of entries) { + if (++entryCount > MAX_IMPLEMENTATION_ENTRIES) throw new Error('daemonIpc implementation exceeds 256 entries.'); + const filename: string = path.join(folder, item.name); + if (item.isDirectory()) { + hash.update(JSON.stringify(['directory', path.relative(root, filename)])); + await visitAsync(filename, depth + 1); + } else if (item.isFile()) { + const stat: Stats = await fs.stat(filename); + if (byteCount + stat.size > MAX_IMPLEMENTATION_BYTES) throw new Error('daemonIpc implementation exceeds 8 MiB.'); + const contents: Buffer = await fs.readFile(filename); + byteCount += contents.byteLength; + if (byteCount > MAX_IMPLEMENTATION_BYTES) throw new Error('daemonIpc implementation exceeds 8 MiB.'); + hash.update(JSON.stringify(['file', path.relative(root, filename), contents.byteLength])); + hash.update(contents); + } else { + throw new Error(`daemonIpc implementation contains an unsupported link or special file: ${filename}`); + } + } + }; + await visitAsync(root, 0); + if (!(await fs.stat(entryPoint)).isFile()) throw new Error('daemonIpc.entryPoint must be a regular file.'); + return { entryPoint, args: Object.freeze([...(descriptor.args ?? [])]), implementationHash: hash.digest('hex') }; +} + +export async function getDaemonIpcImplementationIdentityAsync( + configurations: ReadonlyMap, + enabled: boolean +): Promise { + if (!enabled) return ''; + const identities: string[][] = []; + for (const [project, configuration] of configurations) { + for (const [name, settings] of configuration.operationSettingsByOperationName) { + if (!settings.daemonIpc) continue; + const resolved: IResolvedDaemonIpcConfiguration = await resolveDaemonIpcConfigurationAsync(project.projectFolder, settings.daemonIpc); + identities.push([project.packageName, name, resolved.implementationHash]); + } + } + identities.sort(([leftProject, leftName], [rightProject, rightName]) => + leftProject < rightProject ? -1 : leftProject > rightProject ? 1 : leftName < rightName ? -1 : leftName > rightName ? 1 : 0 + ); + return JSON.stringify(identities); +} diff --git a/libraries/rush-lib/src/logic/operations/DaemonIpcOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/DaemonIpcOperationRunnerPlugin.ts new file mode 100644 index 0000000000..12cabfc29f --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/DaemonIpcOperationRunnerPlugin.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; + +import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; +import type { IDaemonIpcConfiguration } from '../../api/RushProjectConfiguration'; +import type { Operation } from './Operation'; +import { IPCOperationRunner } from './IPCOperationRunner'; +import { resolveDaemonIpcConfigurationAsync, type IResolvedDaemonIpcConfiguration } from './DaemonIpcConfiguration'; +import { + PLUGIN_NAME as ShellOperationPluginName, + formatCommand, + getCustomParameterValuesByOperation, + getDisplayName, + type ICustomParameterValuesForOperation +} from './ShellOperationRunnerPlugin'; + +/** Installs only explicitly declared Node tools; native watch and preassigned sharded runners are untouched. */ +export class DaemonIpcOperationRunnerPlugin implements IPhasedCommandPlugin { + public apply(hooks: PhasedCommandHooks): void { + hooks.createOperationsAsync.tapPromise( + { name: 'DaemonIpcOperationRunnerPlugin', before: ShellOperationPluginName }, + async (operations, context) => { + if (context.isWatch || !context.isIncrementalBuildAllowed) return operations; + const parameters: (operation: Operation) => ICustomParameterValuesForOperation = getCustomParameterValuesByOperation(); + for (const operation of operations) { + const { associatedPhase: phase, associatedProject: project, settings } = operation; + const descriptor: IDaemonIpcConfiguration | undefined = settings?.daemonIpc; + const canonical: string | undefined = phase.shellCommand ?? project.packageJson.scripts?.[phase.name]; + if (operation.runner || settings?.sharding || !descriptor || !canonical) continue; + const { parameterValues, ignoredParameterValues } = parameters(operation); + const resolved: IResolvedDaemonIpcConfiguration = await resolveDaemonIpcConfigurationAsync(project.projectFolder, descriptor); + const childArgs: string[] = [...resolved.args, ...parameterValues]; + operation.runner = new IPCOperationRunner({ + phase, + project, + name: getDisplayName(phase, project), + initialCommand: `${JSON.stringify(process.execPath)} ${[resolved.entryPoint, ...childArgs].map((arg) => JSON.stringify(arg)).join(' ')}`, + incrementalCommand: undefined, + commandForHash: formatCommand(canonical, parameterValues), + ignoredParameterValues, + requireIpc: true, + spawn: (command, args, nativeOptions) => { + void command; + void args; + return spawn( + process.execPath, + [resolved.entryPoint, ...childArgs], + { ...nativeOptions, shell: false, windowsVerbatimArguments: false } + ); + } + }); + } + return operations; + } + ); + } +} diff --git a/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts new file mode 100644 index 0000000000..3933374f1a --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/HeftChildProcessReporter.ts @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as child_process from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; + +import { type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; +import { + allocateChildDescriptor, + createRushDiagnostic, + encodeNdjsonRecord, + HeftDescriptorHost, + REPORTER_PROTOCOL_VERSION, + type IChildDescriptorPlan, + type IHeftChildResult, + type IReporterChildContext, + type IReporterEventEnvelope, + type IReporterHandshakeResult, + type IRushDiagnostic +} from '@rushstack/rush-reporter'; + +import type { IOperationChildProcessReporter } from './OperationEventSink'; + +function formatDiagnosticForOperationLog(diagnostic: IRushDiagnostic): string { + const toolParameter: unknown = + diagnostic.parameters?.tool?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.tool?.value; + const codeParameter: unknown = + diagnostic.parameters?.code?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.code?.value; + const messageParameter: unknown = + diagnostic.parameters?.message?.privacy === 'secret' ? '[secret]' : diagnostic.parameters?.message?.value; + const toolName: string = + typeof toolParameter === 'string' ? toolParameter : (diagnostic.source?.toolName ?? '@rushstack/heft'); + const code: string = typeof codeParameter === 'string' && codeParameter ? ` (${codeParameter})` : ''; + const message: string = + typeof messageParameter === 'string' && messageParameter ? messageParameter : diagnostic.code; + let location: string = ''; + if (diagnostic.source?.kind === 'file') { + location = diagnostic.source.file; + if (diagnostic.source.line !== undefined) { + location += `:${diagnostic.source.line}`; + if (diagnostic.source.column !== undefined) { + location += `:${diagnostic.source.column}`; + } + } + location += ' - '; + } + return `[${toolName}] ${diagnostic.severity}${code}: ${location}${message}\n`; +} + +/** + * An acknowledgement transport failure that must not change the child process result. + * + * @internal + */ +export class HeftChildReporterNonFatalError extends Error { + public constructor(message: string) { + super(message); + this.name = HeftChildReporterNonFatalError.name; + } +} + +export interface IHeftChildProcessReporterOptions { + readonly parentSessionId: string; + readonly parentRequestId: string; + readonly parentOperationId: string; + readonly iterationId: number; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + readonly onDiagnostic: (diagnostic: IRushDiagnostic) => void; + readonly onStructuredNegotiated: () => void; +} + +/** + * Owns the private reporter descriptors for one operation child process. + * + * @internal + */ +export class HeftChildProcessReporter implements IOperationChildProcessReporter { + public readonly environment: Readonly>; + public readonly stdio: child_process.StdioOptions; + + private readonly _plan: IChildDescriptorPlan; + private readonly _options: IHeftChildProcessReporterOptions; + private _hasWarningOrError: boolean = false; + + public get hasWarningOrError(): boolean { + return this._hasWarningOrError; + } + + public constructor(options: IHeftChildProcessReporterOptions) { + this._options = options; + this._plan = allocateChildDescriptor(); + this.environment = this._plan.env; + this.stdio = ['ignore', ...this._plan.stdio.slice(1)] as child_process.StdioOptions; + } + + public async attachAsync( + child: child_process.ChildProcess, + structuredOutputTerminalProvider: ITerminalProvider + ): Promise { + const eventStream: Readable | null = child.stdio[this._plan.fdNumber] as Readable | null; + const ackStream: Writable | null = child.stdio[this._plan.ackFdNumber] as Writable | null; + if (!eventStream || !ackStream) { + throw new Error('The child reporter descriptors were not created by the process launcher.'); + } + const reporterEventStream: Readable = eventStream; + const reporterAckStream: Writable = ackStream; + let diagnosticEmitted: boolean = false; + const emitDiagnostic = (diagnostic: IRushDiagnostic | undefined): void => { + if (diagnostic !== undefined && !diagnosticEmitted) { + diagnosticEmitted = true; + this._options.onDiagnostic(diagnostic); + } + }; + + await new Promise((resolve, reject) => { + let settled: boolean = false; + let eventEnded: boolean = false; + let acknowledgementStarted: boolean = false; + let acknowledgementCompleted: boolean = false; + let negotiationResult: IReporterHandshakeResult | undefined; + let structuredNegotiationConfirmed: boolean = false; + + const cleanup = (): void => { + reporterEventStream.off('data', onData); + reporterEventStream.off('end', onEnd); + }; + const complete = (): void => { + if (!settled) { + settled = true; + cleanup(); + resolve(); + } + }; + const fail = (error: Error): void => { + if (!settled) { + settled = true; + cleanup(); + reporterEventStream.destroy(); + if (!reporterAckStream.destroyed) { + reporterAckStream.destroy(); + } + reject(error); + } + }; + const confirmNegotiation = (): void => { + if ( + !structuredNegotiationConfirmed && + acknowledgementCompleted && + negotiationResult?.accepted && + negotiationResult.ack.acceptedCapabilities.includes('heft-child-events-v1') + ) { + structuredNegotiationConfirmed = true; + this._options.onStructuredNegotiated(); + } + }; + const maybeComplete = (): void => { + if (eventEnded && (!acknowledgementStarted || acknowledgementCompleted)) { + complete(); + } + }; + function onAcknowledgementError(error: Error): HeftChildReporterNonFatalError { + const transportError: HeftChildReporterNonFatalError = new HeftChildReporterNonFatalError( + `The Heft reporter acknowledgement failed: ${error.message}` + ); + if (!settled) { + emitDiagnostic( + createRushDiagnostic('RUSH_PROTOCOL_INVALID_CHILD_STREAM', { + severity: 'warning', + parameters: { + reason: { value: transportError.message, privacy: 'public' } + } + }) + ); + fail(transportError); + } + return transportError; + } + function onAcknowledgementClose(): void { + if (!settled && acknowledgementStarted && !acknowledgementCompleted) { + onAcknowledgementError(new Error('The acknowledgement stream closed before it was delivered.')); + } + reporterAckStream.off('error', onAcknowledgementError); + } + function onEventClose(): void { + if (!eventEnded && !settled) { + fail(new Error('The Heft reporter event stream closed before it completed.')); + } + reporterEventStream.off('error', onEventError); + } + const forwardEnvelope = (envelope: IReporterEventEnvelope): void => { + let forwardedEnvelope: IReporterEventEnvelope = envelope; + if (envelope.type === 'diagnosticEmitted') { + const payload: IRushDiagnostic = envelope.payload as IRushDiagnostic; + this._hasWarningOrError ||= payload.severity === 'warning' || payload.severity === 'error'; + if (envelope.privacy !== 'secret') { + structuredOutputTerminalProvider.write( + formatDiagnosticForOperationLog(payload), + payload.severity === 'warning' + ? TerminalProviderSeverity.warning + : TerminalProviderSeverity.error + ); + } + if (typeof envelope.payload === 'object' && envelope.payload !== null) { + forwardedEnvelope = { + ...envelope, + payload: { ...envelope.payload, iterationId: this._options.iterationId } + }; + } + } else if (envelope.type === 'externalOutput') { + const payload: { stream?: unknown; text?: unknown } = envelope.payload as { + stream?: unknown; + text?: unknown; + }; + if ( + (payload.stream !== 'stdout' && payload.stream !== 'stderr') || + typeof payload.text !== 'string' + ) { + throw new Error('The validated child output envelope contained an invalid payload.'); + } + this._hasWarningOrError ||= payload.stream === 'stderr'; + if (envelope.privacy !== 'secret') { + structuredOutputTerminalProvider.write( + payload.text, + payload.stream === 'stderr' ? TerminalProviderSeverity.error : TerminalProviderSeverity.log + ); + } + forwardedEnvelope = { + ...envelope, + payload: { ...payload, iterationId: this._options.iterationId } + }; + } + this._options.ingestForeignEnvelope(forwardedEnvelope); + }; + + reporterAckStream.once('error', onAcknowledgementError); + reporterAckStream.once('close', onAcknowledgementClose); + reporterEventStream.on('error', onEventError); + reporterEventStream.once('close', onEventClose); + + let processor: { write(chunk: string): void; flush(): IHeftChildResult }; + try { + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: this._options.parentSessionId, + parentRequestId: this._options.parentRequestId, + parentOperationId: this._options.parentOperationId, + supportedProtocolVersion: REPORTER_PROTOCOL_VERSION, + context: this._options.context, + trustedSource: { + packageName: '@rushstack/heft', + packageVersion: 'unknown' + }, + trustedPrivacy: 'local-sensitive', + forwardEnvelope, + sendHelloAck: (ack) => { + if (acknowledgementStarted) { + throw new Error('The Heft reporter acknowledgement was sent more than once.'); + } + acknowledgementStarted = true; + if (reporterAckStream.destroyed) { + throw onAcknowledgementError( + new Error('The acknowledgement stream closed before negotiation.') + ); + } + reporterAckStream.end(encodeNdjsonRecord(ack), (error?: Error | null) => { + if (error) { + onAcknowledgementError(error); + } else if (!settled) { + acknowledgementCompleted = true; + confirmNegotiation(); + maybeComplete(); + } + }); + }, + onNegotiation: (result) => { + negotiationResult = result; + if (result.accepted) { + confirmNegotiation(); + } else { + emitDiagnostic(result.diagnostic); + } + } + }); + processor = host.createStreamProcessor(); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter context was invalid.')); + return; + } + + function onData(chunk: string): void { + try { + processor.write(chunk); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter stream failed.')); + } + } + function onEventError(error: Error): void { + fail(error); + } + function onEnd(): void { + try { + const result: IHeftChildResult = processor.flush(); + emitDiagnostic(result.diagnostic); + eventEnded = true; + if ( + !result.accepted && + negotiationResult?.accepted && + negotiationResult.ack.acceptedCapabilities.includes('heft-child-events-v1') + ) { + fail( + new Error( + 'The negotiated Heft reporter stream was corrupt or incomplete. ' + + 'See the RUSH_PROTOCOL_INVALID_CHILD_STREAM diagnostic for details.' + ) + ); + return; + } + if (!acknowledgementStarted && !reporterAckStream.destroyed) { + reporterAckStream.destroy(); + } + maybeComplete(); + } catch (error) { + fail(error instanceof Error ? error : new Error('The child reporter stream failed.')); + } + } + reporterEventStream.setEncoding('utf8'); + reporterEventStream.on('data', onData); + reporterEventStream.once('end', onEnd); + }); + } +} diff --git a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts index 47769d76d6..8400f371d5 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts @@ -69,8 +69,10 @@ export interface IConfigurableOperation extends IBaseOperationExecutionResult { enabled: boolean; /** - * True if the operation's runner should remain active after this iteration, false otherwise. - * Defaults to true. + * True if the operation's runner should remain active after its execution, false otherwise. + * When false, the runner is closed before dependent operations can execute, including when this + * operation is skipped. Configure this through {@link OperationGraphHooks.configureIteration}. + * Defaults to true on each iteration; the previous iteration's policy is not inherited. */ shouldRunnerPersist: boolean; } @@ -106,7 +108,7 @@ export interface IOperationExecutionResult extends IBaseOperationExecutionResult */ readonly enabled: boolean; /** - * True if the operation's runner should remain active after this iteration, false otherwise. + * True if the operation's runner should remain active after its execution, false otherwise. */ readonly shouldRunnerPersist: boolean; /** diff --git a/libraries/rush-lib/src/logic/operations/IOperationGraph.ts b/libraries/rush-lib/src/logic/operations/IOperationGraph.ts index 294a620567..5575bf77ab 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationGraph.ts @@ -111,10 +111,24 @@ export interface IOperationGraph { /** * Cleans up any resources used by the operation runners, if applicable. + * + * Does not wait for executing operations to finish before closing their runners. Hosts performing + * idle eviction must coordinate this call with iteration scheduling. Operations, their last results, + * and host-owned watchers are not removed. + * * @param operations - The operations whose runners should be closed, or undefined to close all runners. */ closeRunnersAsync(operations?: Iterable): Promise; + /** + * Drops retained results after the host has awaited runner and watcher cleanup. + * Rejects executing/prepared iterations and runners that still report active resources. + * Detaches completed iteration contexts so surviving results do not retain evicted records. + * Does not change enabled states, disk caches, or operation definitions. + * Optional for compatibility with hosts that do not support idle eviction. + */ + deleteResults?(operations: Iterable): void; + /** * Executes a single iteration of the operations. * @param options - Options for this execution iteration. @@ -122,6 +136,13 @@ export interface IOperationGraph { */ scheduleIterationAsync(options: IOperationGraphIterationOptions): Promise; + /** + * Discards prepared, unstarted work without executing scripts or closing retained runners. + * Throws while an iteration is executing. Completed results remain unchanged. + * @returns Whether a prepared iteration was discarded. + */ + discardScheduledIteration(): boolean; + /** * Executes all operations in the currently scheduled iteration, if any. * @returns A promise which is resolved when all operations have been processed to a final state. diff --git a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts index 911f893f88..1e820a0af4 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts @@ -8,6 +8,7 @@ import type { OperationStatus } from './OperationStatus'; import type { OperationMetadataManager } from './OperationMetadataManager'; import type { IStopwatchResult } from '../../utilities/Stopwatch'; import type { IEnvironment } from '../../utilities/Utilities'; +import type { IOperationChildProcessReporter } from './OperationEventSink'; /** * A snapshot of a previous operation execution, passed to runners to inform incremental behavior. @@ -81,13 +82,24 @@ export interface IOperationRunnerContext { */ getInvalidateCallback(): (reason: string) => void; + /** + * Allocates a negotiated reporter channel for a child process, when enabled. + * + * @internal + */ + createChildProcessReporter(): IOperationChildProcessReporter | undefined; + /** * Invokes the specified callback with a terminal that is associated with this operation. * * Will write to a log file corresponding to the phase and project, and clean it up upon completion. */ runWithTerminalAsync( - callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, + callback: ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise, options: { createLogFile: boolean; logFileSuffix?: string; @@ -143,6 +155,13 @@ export interface IOperationRunner { */ readonly isActive?: boolean; + /** + * Last measured RSS of this runner's retained child process, in bytes. + * Undefined if unmeasured or no longer resident. This is a sample, not a limit, allocation estimate, + * or measurement of descendants. Runners sharing a process must not each report its RSS. + */ + readonly residentMemoryBytes?: number; + /** * Method to be executed for the operation. * @param context - The context object containing information about the execution environment. @@ -157,6 +176,7 @@ export interface IOperationRunner { /** * If this runner performs any background work to optimize future runs, this method will clean it up. + * Must be safe to call again after the resources have been released. */ closeAsync?(): Promise; } diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts index 27249b26bf..5d4404254f 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ChildProcess } from 'node:child_process'; +import type { ChildProcess, SpawnOptions } from 'node:child_process'; import { once } from 'node:events'; +import { StringDecoder } from 'node:string_decoder'; import type { IAfterExecuteEventMessage, @@ -29,18 +30,31 @@ export interface IIPCOperationRunnerOptions { incrementalCommand: string | undefined; commandForHash: string; ignoredParameterValues: ReadonlyArray; + /** Explicit daemon Node descriptors must negotiate IPC instead of succeeding as one-shot shell commands. */ + requireIpc?: boolean; + /** + * Optional process factory for an explicit IPC executable. Receives the native lifecycle + * environment, stdio and process-group options; the default preserves shell command execution. + */ + spawn?: (command: string, args: ReadonlyArray, options: SpawnOptions) => ChildProcess; } function isAfterExecuteEventMessage(message: unknown): message is IAfterExecuteEventMessage { - return typeof message === 'object' && (message as IAfterExecuteEventMessage).event === 'after-execute'; + return ( + !!message && + typeof message === 'object' && + (message as IAfterExecuteEventMessage).event === 'after-execute' + ); } function isRequestRunEventMessage(message: unknown): message is IRequestRunEventMessage { - return typeof message === 'object' && (message as IRequestRunEventMessage).event === 'requestRun'; + return ( + !!message && typeof message === 'object' && (message as IRequestRunEventMessage).event === 'requestRun' + ); } function isSyncEventMessage(message: unknown): message is ISyncEventMessage { - return typeof message === 'object' && (message as ISyncEventMessage).event === 'sync'; + return !!message && typeof message === 'object' && (message as ISyncEventMessage).event === 'sync'; } /** @@ -58,9 +72,14 @@ export class IPCOperationRunner implements IOperationRunner { readonly #incrementalCommand: string | undefined; readonly #commandForHash: string; readonly #ignoredParameterValues: ReadonlyArray; + readonly #spawn: IIPCOperationRunnerOptions['spawn']; + readonly #requireIpc: boolean; #ipcProcess: ChildProcess | undefined; #processReadyPromise: Promise | undefined; + #processClosedPromise: Promise | undefined; + #residentMemoryBytes: number | undefined; + #closing: boolean = false; public constructor(options: IIPCOperationRunnerOptions) { const { @@ -81,23 +100,35 @@ export class IPCOperationRunner implements IOperationRunner { this.#commandForHash = commandForHash; this.#ignoredParameterValues = ignoredParameterValues; + this.#spawn = options.spawn; + this.#requireIpc = options.requireIpc ?? false; } public get isActive(): boolean { - return !!(this.#ipcProcess && !this.#ipcProcess.killed && typeof this.#ipcProcess.exitCode !== 'number'); + return !!(this.#ipcProcess && this.#ipcProcess.exitCode === null && this.#ipcProcess.signalCode === null); + } + + public get residentMemoryBytes(): number | undefined { + return this.isActive ? this.#residentMemoryBytes : undefined; } public async executeAsync( context: IOperationRunnerContext, lastState?: IOperationLastState ): Promise { + if (this.#closing) { + // A failed close may already have sent "exit". Never send new work to that retiring child. + await this.closeAsync(); + } const commandToRun: string = lastState && this.#incrementalCommand ? this.#incrementalCommand : this.#initialCommand; const invalidate: (reason: string) => void = context.getInvalidateCallback(); return await context.runWithTerminalAsync( async (terminal: ITerminal, terminalProvider: ITerminalProvider): Promise => { let isConnected: boolean = false; - if (!this.#ipcProcess || typeof this.#ipcProcess.exitCode === 'number') { + if (!this.#ipcProcess || !this.isActive) { + await this.#processClosedPromise; + this.#residentMemoryBytes = undefined; // Log any ignored parameters if (this.#ignoredParameterValues.length > 0) { terminal.writeLine( @@ -112,18 +143,23 @@ export class IPCOperationRunner implements IOperationRunner { const { environment: initialEnvironment } = context; - this.#ipcProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { - rushConfiguration, - workingDirectory: projectFolder, - initCwd: rushConfiguration.commonTempFolder, - handleOutput: true, - environmentPathOptions: { - includeProjectBin: true + this.#ipcProcess = Utilities.executeLifecycleCommandAsync( + commandToRun, + { + rushConfiguration, + workingDirectory: projectFolder, + initCwd: rushConfiguration.commonTempFolder, + handleOutput: true, + environmentPathOptions: { + includeProjectBin: true + }, + ipc: true, + connectSubprocessTerminator: true, + initialEnvironment }, - ipc: true, - connectSubprocessTerminator: true, - initialEnvironment - }); + this.#spawn + ); + this.#processClosedPromise = new Promise((resolve) => this.#ipcProcess!.once('close', resolve)); let resolveReadyPromise!: () => void; @@ -145,14 +181,17 @@ export class IPCOperationRunner implements IOperationRunner { terminal.writeLine(`Connecting to existing IPC process...`); } const subProcess: ChildProcess = this.#ipcProcess; + const requireIpc: boolean = this.#requireIpc; let hasWarningOrError: boolean = false; + const stdoutDecoder: StringDecoder = new StringDecoder('utf8'); + const stderrDecoder: StringDecoder = new StringDecoder('utf8'); function onStdout(data: Buffer): void { - const text: string = data.toString(); + const text: string = stdoutDecoder.write(data); terminalProvider.write(text, TerminalProviderSeverity.log); } function onStderr(data: Buffer): void { - const text: string = data.toString(); + const text: string = stderrDecoder.write(data); terminalProvider.write(text, TerminalProviderSeverity.error); hasWarningOrError = true; } @@ -162,23 +201,44 @@ export class IPCOperationRunner implements IOperationRunner { subProcess.stderr?.on('data', onStderr); const status: OperationStatus = await new Promise((resolve, reject) => { - function finishHandler(message: unknown): void { + const finishHandler = (message: unknown): void => { if (isAfterExecuteEventMessage(message)) { + const memory: number | undefined = message.residentMemoryBytes; + this.#residentMemoryBytes = + typeof memory === 'number' && Number.isSafeInteger(memory) && memory > 0 ? memory : undefined; terminal.writeLine('Received finish notification'); subProcess.stdout?.off('data', onStdout); subProcess.stderr?.off('data', onStderr); + terminalProvider.write(stdoutDecoder.end(), TerminalProviderSeverity.log); + terminalProvider.write(stderrDecoder.end(), TerminalProviderSeverity.error); subProcess.off('message', finishHandler); subProcess.off('error', reject); - subProcess.off('exit', onExit); + subProcess.off('close', onExit); terminal.writeLine('Disconnected from IPC process'); // These types are currently distinct but have the same underlying values resolve(message.status as unknown as OperationStatus); } - } + }; function onExit(exitCode: number | null, signal: NodeJS.Signals | null): void { try { - if (signal) { + subProcess.stdout?.off('data', onStdout); + subProcess.stderr?.off('data', onStderr); + terminalProvider.write(stdoutDecoder.end(), TerminalProviderSeverity.log); + terminalProvider.write(stderrDecoder.end(), TerminalProviderSeverity.error); + subProcess.off('message', finishHandler); + subProcess.off('error', reject); + subProcess.off('close', onExit); + if (isConnected) { + context.error = new OperationError( + 'error', + 'IPC process exited before reporting its operation result.' + ); + resolve(OperationStatus.Failure); + } else if (requireIpc) { + context.error = new OperationError('error', 'The explicit daemon Node tool exited without completing IPC readiness.'); + resolve(OperationStatus.Failure); + } else if (signal) { context.error = new OperationError('error', `Terminated by signal: ${signal}`); resolve(OperationStatus.Failure); } else if (exitCode !== 0) { @@ -190,6 +250,7 @@ export class IPCOperationRunner implements IOperationRunner { } else { resolve(OperationStatus.Success); } + if (requireIpc && context.error) terminal.writeErrorLine(context.error.message); } catch (error) { reject(error as OperationError); } @@ -197,7 +258,7 @@ export class IPCOperationRunner implements IOperationRunner { subProcess.on('message', finishHandler); subProcess.on('error', reject); - subProcess.on('exit', onExit); + subProcess.on('close', onExit); this.#processReadyPromise!.then(() => { isConnected = true; terminal.writeLine('Child supports IPC protocol. Sending "run" command...'); @@ -234,12 +295,24 @@ export class IPCOperationRunner implements IOperationRunner { return; } - if (subProcess.connected) { + this.#closing = true; + if (this.isActive) { + if (!subProcess.connected) { + throw new Error(`Cannot close the live IPC runner "${this.name}": its IPC channel is disconnected.`); + } + const closed: Promise = once(subProcess, 'close'); const exitCommand: IExitCommandMessage = { command: 'exit' }; subProcess.send(exitCommand); - await once(subProcess, 'exit'); + await closed; } + // Even after "exit", stdio/descendants can still be draining. Resource ownership ends at "close". + await this.#processClosedPromise; + this.#ipcProcess = undefined; + this.#processReadyPromise = undefined; + this.#processClosedPromise = undefined; + this.#residentMemoryBytes = undefined; + this.#closing = false; } } diff --git a/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts b/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts index 03ebc73fbc..2b2ea20896 100644 --- a/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts @@ -64,6 +64,9 @@ export class LegacySkipPlugin implements IPhasedCommandPlugin { this.#options; hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.beforeDeleteResults.tap(PLUGIN_NAME, (operations) => { + for (const operation of operations) stateMap.delete(operation); + }); graph.hooks.beforeExecuteIterationAsync.tap( PLUGIN_NAME, ( diff --git a/libraries/rush-lib/src/logic/operations/NullOperationRunner.ts b/libraries/rush-lib/src/logic/operations/NullOperationRunner.ts index 03e9260bc7..8559829ba2 100644 --- a/libraries/rush-lib/src/logic/operations/NullOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/NullOperationRunner.ts @@ -53,3 +53,13 @@ export class NullOperationRunner implements IOperationRunner { return ''; } } + +/** Recognizes the unchanged native no-resource implementation, not a custom runner reporting NoOp. */ +export function isResourceFreeNullOperationRunner(runner: object | undefined): boolean { + return ( + runner instanceof NullOperationRunner && + Object.getPrototypeOf(runner) === NullOperationRunner.prototype && + runner.executeAsync === NullOperationRunner.prototype.executeAsync && + !('closeAsync' in runner) + ); +} diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index f1f0290a24..d734f764e3 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { ITerminalChunk } from '@rushstack/terminal'; +import type * as child_process from 'node:child_process'; + +import type { ITerminalChunk, ITerminalProvider } from '@rushstack/terminal'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { OperationStatus } from './OperationStatus'; @@ -22,6 +24,21 @@ export interface IOperationActivityOptions { readonly stderr?: boolean; } +/** + * A negotiated reporter channel allocated for one operation child process. + * + * @internal + */ +export interface IOperationChildProcessReporter { + readonly environment: Readonly>; + readonly hasWarningOrError: boolean; + readonly stdio: child_process.StdioOptions; + attachAsync( + child: child_process.ChildProcess, + structuredOutputTerminalProvider: ITerminalProvider + ): Promise; +} + /** * A structured, presentation-free event sink for the operation graph. * @@ -92,4 +109,12 @@ export interface IOperationGraphEventSink { * carrying the plain (pre-colorization) text. */ onActivity?(text: string, options?: IOperationActivityOptions): void; + + /** + * Allocates a reporter channel for a child spawned by the specified operation. + */ + createChildProcessReporter?( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined; } diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 3715aaeffa..ed7c448268 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -20,7 +20,7 @@ import { CollatedTerminal, type CollatedWriter, type StreamCollator } from '@rus import { coerceParallelism } from './ParseParallelism'; import { OperationStatus, TERMINAL_STATUSES } from './OperationStatus'; -import type { IOperationGraphEventSink } from './OperationEventSink'; +import type { IOperationChildProcessReporter, IOperationGraphEventSink } from './OperationEventSink'; import { OperationChunkTap } from './OperationChunkTap'; import type { IOperationRunner, IOperationRunnerContext } from './IOperationRunner'; import type { Operation } from './Operation'; @@ -45,7 +45,7 @@ import { */ export interface IOperationExecutionRecordContext { iterationId: number; - streamCollator: StreamCollator; + streamCollator: StreamCollator | undefined; onOperationStateChanged?: (record: OperationExecutionRecord) => void; createEnvironment?: (record: OperationExecutionRecord) => IEnvironment; invalidate?: (operations: Iterable, reason: string) => void; @@ -168,7 +168,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera public logFilePaths: ILogFilePaths | undefined; - readonly #context: IOperationExecutionRecordContext; + #context: IOperationExecutionRecordContext; #collatedWriter: CollatedWriter | undefined = undefined; #status: OperationStatus; @@ -223,6 +223,9 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera public get collatedWriter(): CollatedWriter { // Lazy instantiate because the registerTask() call affects display ordering if (!this.#collatedWriter) { + if (!this.#context.streamCollator) { + throw new InternalError('Cannot reopen the output of a detached execution record.'); + } this.#collatedWriter = this.#context.streamCollator.registerTask(this.name); } return this.#collatedWriter; @@ -286,6 +289,13 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return this.#context.eventSink; } + /** + * {@inheritdoc IOperationRunnerContext.createChildProcessReporter} + */ + public createChildProcessReporter(): IOperationChildProcessReporter | undefined { + return this.#context.eventSink?.createChildProcessReporter?.(this.name, this.iterationId); + } + public get silent(): boolean { return !this.enabled || this.runner.silent; } @@ -324,6 +334,31 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return this.#operationCompleted; } + /** + * Releases iteration-wide references after the host has drained all output. + * Retained hashes, warnings, timing and results remain usable for incremental decisions. + */ + public detachExecutionContext(): void { + if (!this.#operationCompleted) { + throw new InternalError('Cannot detach an unfinished execution record.'); + } + if (this.#context.inputsSnapshot) { + this.getStateHash(); + } + const { iterationId, maxParallelism, debugMode, quietMode } = this.#context; + this.#context = { + iterationId, + maxParallelism, + debugMode, + quietMode, + inputsSnapshot: undefined, + streamCollator: undefined + }; + this.#collatedWriter = undefined; + this.dependencies.clear(); + this.consumers.clear(); + } + /** * Adds the reporter's lossless operation-output tap ahead of any legacy presentation transforms. * @@ -403,10 +438,14 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera * {@inheritdoc IOperationRunnerContext.runWithTerminalAsync} */ public async runWithTerminalAsync( - callback: (terminal: ITerminal, terminalProvider: ITerminalProvider) => Promise, + callback: ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise, options: { createLogFile: boolean; - logFileSuffix: string; + logFileSuffix?: string; } ): Promise { const { associatedProject, stdioSummarizer, problemCollector } = this; @@ -479,10 +518,16 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera const terminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider(collatedTerminal, { debugEnabled: this.debugMode }); + const structuredChildOutputTerminalProvider: CollatedTerminalProvider = new CollatedTerminalProvider( + new CollatedTerminal(normalizeNewlineTransform), + { + debugEnabled: this.debugMode + } + ); const terminal: Terminal = new Terminal(terminalProvider); //#endregion - const result: T = await callback(terminal, terminalProvider); + const result: T = await callback(terminal, terminalProvider, structuredChildOutputTerminalProvider); normalizeNewlineTransform.close(); diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 673dee63f8..17a57096d0 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -52,6 +52,8 @@ export interface IOperationGraphOptions { * Consumers (e.g. ProjectWatcher) can subscribe to this to perform cleanup. */ abortController: AbortController; + /** Hosts with awaited lifetime cleanup can disable the legacy fire-and-forget abort cleanup. */ + closeRunnersOnAbort?: boolean; isWatch?: boolean; pauseNextIteration?: boolean; @@ -245,7 +247,9 @@ export class OperationGraph implements IOperationGraph { if (this.#idleTimeout) { clearTimeout(this.#idleTimeout); } - void this.closeRunnersAsync(); + if (options.closeRunnersOnAbort !== false) { + void this.closeRunnersAsync(); + } }, { once: true } ); @@ -487,6 +491,31 @@ export class OperationGraph implements IOperationGraph { } } + public deleteResults(operations: Iterable): void { + if (this.#currentIteration || this.#scheduledIteration) { + throw new Error('Cannot delete results of an executing or prepared graph.'); + } + const selected: Set = new Set(operations); + for (const operation of selected) { + if (!this.operations.has(operation)) { + throw new Error(`Cannot delete results for an operation outside this graph: ${operation.name}`); + } + if (operation.runner?.isActive) { + throw new Error(`Cannot delete results of an active runner: ${operation.name}`); + } + } + // Each completed record originally owns a context and edges referencing the whole iteration. + // Detach survivors too; deleting only the selected map keys would retain the cold records. + this.hooks.beforeDeleteResults.call(selected); + for (const record of this.resultByOperation.values()) { + record.detachExecutionContext(); + } + for (const operation of selected) { + this.resultByOperation.delete(operation); + } + this.#scheduleManagerStateChanged(); + } + /** * Shorthand for scheduling an iteration then executing it. * Call `abortCurrentIterationAsync()` to cancel the execution of any operations that have not yet begun execution. @@ -520,9 +549,36 @@ export class OperationGraph implements IOperationGraph { } /** - * Executes all operations which have been registered, returning a promise which is resolved when all operations have been processed to a final state. - * Aborts the current iteration first, if any. + * Discards unstarted records without executing scripts, changing completed results, or closing retained runners. */ + public discardScheduledIteration(): boolean { + if (this.#currentIteration) { + throw new Error('Cannot discard prepared work while an iteration is executing.'); + } + const iteration: IExecutionIterationContext | undefined = this.#scheduledIteration; + if (!iteration) return false; + this.#setScheduledIteration(undefined); + const errors: unknown[] = []; + for (const record of iteration.records.values()) { + record.status = OperationStatus.Aborted; + for (const close of [ + () => record.finalizeOperation(), + () => record.stdioSummarizer.close(), + () => record.problemCollector.close() + ]) { + try { + close(); + } catch (error) { + errors.push(error); + } + } + } + this.hooks.onExecutionStatesUpdated.call(new Set(iteration.records.values())); + if (errors.length) throw new AggregateError(errors, 'Failed to discard prepared operation records.'); + return true; + } + + /** Executes the prepared iteration, after awaiting cancellation of any current iteration. */ public async executeScheduledIterationAsync(): Promise { await this.abortCurrentIterationAsync(); @@ -664,9 +720,7 @@ export class OperationGraph implements IOperationGraph { maxParallelism: this.#maxParallelism, onOperationStateChanged: undefined, createEnvironment: createEnvironmentForOperation, - invalidate: (operations: Iterable, reason: string) => { - graph.invalidateOperations(operations, reason); - }, + invalidate: graph.invalidateOperations.bind(graph), get debugMode(): boolean { return graph.debugMode; }, @@ -980,19 +1034,20 @@ export class OperationGraph implements IOperationGraph { } try { await graph.closeRunnersAsync(recordsToClose.map((record) => record.operation)); - } catch (e) { - if (e instanceof AggregateError) { - for (const error of e.errors) { - if (error instanceof OperationRunnerCloseError) { - const record: OperationExecutionRecord | undefined = executionRecords.get(error.operation); - if (record) { - reportRunnerCleanupFailure(record, error.cause); - } + } catch (error) { + const failures: readonly unknown[] = + error instanceof AggregateError && error.errors.length > 0 ? error.errors : [error]; + for (const failure of failures) { + if (failure instanceof OperationRunnerCloseError) { + const record: OperationExecutionRecord | undefined = executionRecords.get(failure.operation); + if (record) { + reportRunnerCleanupFailure(record, failure.cause); + continue; } } - } else { + const cleanupError: Error = failure instanceof Error ? failure : new Error(String(failure)); for (const record of recordsToClose) { - reportRunnerCleanupFailure(record, e); + reportRunnerCleanupFailure(record, cleanupError); } } } diff --git a/libraries/rush-lib/src/logic/operations/PhasedCommandEngineExecution.ts b/libraries/rush-lib/src/logic/operations/PhasedCommandEngineExecution.ts new file mode 100644 index 0000000000..4fe099a0b9 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/PhasedCommandEngineExecution.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { LockFile } from '@rushstack/node-core-library'; + +import type { IPhasedCommandEngine } from '../../api/PhasedCommandEngine'; +import { PhasedCommandEngineBusyError } from '../../api/PhasedCommandEngineBusyError'; + +/** + * Owns the native process lock only while a host is reconciling or executing one graph iteration. + */ +export class PhasedCommandEngineExecution implements AsyncDisposable { + private readonly _engine: IPhasedCommandEngine; + private readonly _lockFolder: string; + private _activeLeaseCompletion: Promise | undefined; + private _disposePromise: Promise | undefined; + private _disposing: boolean = false; + private _releaseError: unknown; + + public constructor(engine: IPhasedCommandEngine, lockFolder: string) { + this._engine = engine; + this._lockFolder = lockFolder; + } + + public async acquireExecutionLeaseAsync(): Promise { + if (this._disposing) throw new Error('The native phased engine is being disposed.'); + if (this._releaseError !== undefined) throw this._releaseError; + if (this._activeLeaseCompletion) + throw new Error('The native phased engine already has an execution lease.'); + + const lock: LockFile | undefined = LockFile.tryAcquire(this._lockFolder, 'rush'); + if (!lock) throw new PhasedCommandEngineBusyError(); + try { + // Native CLI actions leave their process lock file behind on exit. Their work may have + // changed ignored outputs, so do not trust the previous in-memory success records. + if (lock.dirtyWhenAcquired) { + this._engine.operationGraph.invalidateOperations(undefined, 'native-command-completed'); + } + } catch (error) { + lock.release(); + throw error; + } + + let completeLease: () => void = () => undefined; + this._activeLeaseCompletion = new Promise((resolve) => { + completeLease = resolve; + }); + let releasePromise: Promise | undefined; + const releaseAsync: () => Promise = async () => { + try { + lock.release(); + } catch (error) { + this._releaseError = error; + throw error; + } finally { + this._activeLeaseCompletion = undefined; + completeLease(); + } + }; + return { [Symbol.asyncDispose]: () => (releasePromise ??= releaseAsync()) }; + } + + public [Symbol.asyncDispose](): Promise { + this._disposing = true; + this._disposePromise ??= this._disposeAsync(); + return this._disposePromise; + } + + private async _disposeAsync(): Promise { + await this._activeLeaseCompletion; + try { + await this._engine[Symbol.asyncDispose](); + } catch (error) { + if (this._releaseError !== undefined) { + throw new AggregateError( + [this._releaseError, error], + 'Failed to release and dispose the native engine.' + ); + } + throw error; + } + if (this._releaseError !== undefined) throw this._releaseError; + } +} diff --git a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts index f56727c90b..181e85324e 100644 --- a/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/ReporterOperationEventSink.ts @@ -3,24 +3,37 @@ import { createRushDiagnostic, + ProblemMatcherRegistry, + ProblemMatcherRunner, type IRushDiagnostic, + type IProblemMatch, + type IProblemMatcher, type LifecycleEmitter, type OperationStreamEmitter, type OperationStatus as ReporterOperationStatus } from '@rushstack/rush-reporter'; +import { FileError } from '@rushstack/node-core-library'; import { TerminalChunkKind, type ITerminalChunk } from '@rushstack/terminal'; -import type { RushSession } from '../../pluginFramework/RushSession'; import { _correlateRushSessionError, + _getRushSessionChildProcessReporter, _getRushSessionLifecycleEmitter, - _getRushSessionOperationStreamEmitter + _getRushSessionOperationStreamEmitter, + type IRushSessionChildProcessReporter, + type RushSession } from '../../pluginFramework/RushSession'; +import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { IOperationExecutionResult } from './IOperationExecutionResult'; -import type { IOperationGraphEventSink, IOperationActivityOptions } from './OperationEventSink'; +import type { + IOperationChildProcessReporter, + IOperationGraphEventSink, + IOperationActivityOptions +} from './OperationEventSink'; import type { Operation } from './Operation'; import { OperationStatus, SUCCESS_STATUSES } from './OperationStatus'; import type { OperationGraph } from './OperationGraph'; +import { HeftChildProcessReporter } from './HeftChildProcessReporter'; interface IReporterOperation { readonly emitter: LifecycleEmitter; @@ -28,6 +41,7 @@ interface IReporterOperation { readonly operationId: string; readonly phaseName: string; readonly projectName: string; + readonly problemMatcherRunnersByLegacyId: Map; readonly streamEmitter: OperationStreamEmitter | undefined; readonly cycles: Map; latestCompletedIterationId: number | undefined; @@ -43,6 +57,40 @@ interface IReporterOperationCycle { streamClosed: boolean; } +function createFileErrorMatcher( + name: string, + format: 'Unix' | 'VisualStudio', + severity: 'error' | 'warning' +): IProblemMatcher { + const definition: { readonly regexp: string } = FileError.getProblemMatcher({ format }); + const severityWord: string = severity === 'error' ? 'Error' : 'Warning'; + return { + name, + tool: 'heft', + severity, + enabledByDefault: true, + pattern: new RegExp(definition.regexp.replace('(Error|Warning)', `(${severityWord})`)), + extract(match: RegExpMatchArray): IProblemMatch { + return { + file: match[2], + line: Number(match[3]), + column: Number(match[4]), + code: match[5], + message: match[6] + }; + } + }; +} + +function createProblemMatcherRunner(): ProblemMatcherRunner { + const registry: ProblemMatcherRegistry = new ProblemMatcherRegistry(); + registry.register(createFileErrorMatcher('heft-file-error-unix', 'Unix', 'error')); + registry.register(createFileErrorMatcher('heft-file-warning-unix', 'Unix', 'warning')); + registry.register(createFileErrorMatcher('heft-file-error-visualstudio', 'VisualStudio', 'error')); + registry.register(createFileErrorMatcher('heft-file-warning-visualstudio', 'VisualStudio', 'warning')); + return new ProblemMatcherRunner(registry.getMatchers('heft')); +} + class ReporterOperationEventSink implements IOperationGraphEventSink { public readonly onOperationChunk: | (( @@ -98,6 +146,7 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { operationId, phaseName, projectName, + problemMatcherRunnersByLegacyId: new Map(), streamEmitter, cycles: new Map(), latestCompletedIterationId: undefined @@ -142,8 +191,12 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (!operation || resolvedIterationId === undefined) { return; } - const cycle: IReporterOperationCycle = this._getCycle(operation, resolvedIterationId); + const matcherKey: string = `${resolvedIterationId}:${operationId}`; + operation.problemMatcherRunnersByLegacyId.set( + matcherKey, + operation.streamEmitter ? createProblemMatcherRunner() : undefined + ); cycle.registeredOperationIds.add(operationId); cycle.silent &&= silent; @@ -255,6 +308,35 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { }); } + public createChildProcessReporter( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined { + const operation: IReporterOperation | undefined = this._operationsByLegacyId.get(operationId); + const childProcessReporter: IRushSessionChildProcessReporter | undefined = + _getRushSessionChildProcessReporter(this._rushSession); + // Windows lifecycle commands run through a shell that does not preserve Node's fd mapping. + if (IS_WINDOWS || !operation?.streamEmitter || !childProcessReporter) { + return undefined; + } + + const matcherKey: string = `${iterationId}:${operationId}`; + operation.problemMatcherRunnersByLegacyId.set(matcherKey, createProblemMatcherRunner()); + return new HeftChildProcessReporter({ + parentSessionId: childProcessReporter.parentSessionId, + parentRequestId: childProcessReporter.parentRequestId, + parentOperationId: operation.operationId, + iterationId, + context: childProcessReporter.context, + ingestForeignEnvelope: childProcessReporter.ingestForeignEnvelope, + onDiagnostic: (diagnostic: IRushDiagnostic) => + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId }), + onStructuredNegotiated: () => { + operation.problemMatcherRunnersByLegacyId.set(matcherKey, undefined); + } + }); + } + private _onOperationChunk( operationId: string, chunk: ITerminalChunk, @@ -272,6 +354,14 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { } else if (chunk.kind === TerminalChunkKind.Stderr) { operation.streamEmitter.writeOutput(operation.operationId, 'stderr', chunk.text, resolvedIterationId); } + + const stream: 'stdout' | 'stderr' = chunk.kind === TerminalChunkKind.Stderr ? 'stderr' : 'stdout'; + const matcherKey: string = `${resolvedIterationId}:${operationId}`; + for (const diagnostic of operation.problemMatcherRunnersByLegacyId + .get(matcherKey) + ?.writeOutput(chunk.text, operation.operationId, stream) ?? []) { + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId: resolvedIterationId }); + } } private _onOperationStreamClosed( @@ -288,6 +378,11 @@ class ReporterOperationEventSink implements IOperationGraphEventSink { if (cycle.streamClosed) { return; } + const matcherKey: string = `${resolvedIterationId}:${operationId}`; + for (const diagnostic of operation.problemMatcherRunnersByLegacyId.get(matcherKey)?.flush() ?? []) { + operation.emitter.emitDiagnostic({ ...diagnostic, iterationId: resolvedIterationId }); + } + operation.problemMatcherRunnersByLegacyId.delete(matcherKey); cycle.closedOperationIds.add(operationId); if (cycle.closedOperationIds.size === operation.legacyOperationIds.size) { cycle.streamClosed = true; @@ -404,6 +499,16 @@ class CompositeOperationGraphEventSink implements IOperationGraphEventSink { this._first.onActivity?.(text, options); this._second.onActivity?.(text, options); } + + public createChildProcessReporter( + operationId: string, + iterationId: number + ): IOperationChildProcessReporter | undefined { + return ( + this._second.createChildProcessReporter?.(operationId, iterationId) ?? + this._first.createChildProcessReporter?.(operationId, iterationId) + ); + } } /** diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index ee511fd7d5..c76fce6931 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type * as child_process from 'node:child_process'; +import * as path from 'node:path'; import { Path } from '@rushstack/node-core-library'; import { type ITerminal, type ITerminalProvider, TerminalProviderSeverity } from '@rushstack/terminal'; @@ -10,7 +11,10 @@ import type { IPhase } from '../../api/CommandLineConfiguration'; import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import { Utilities } from '../../utilities/Utilities'; +import { IS_WINDOWS } from '../../utilities/executionUtilities'; import type { IOperationRunner, IOperationRunnerContext, IOperationLastState } from './IOperationRunner'; +import type { IOperationChildProcessReporter } from './OperationEventSink'; +import { HeftChildReporterNonFatalError } from './HeftChildProcessReporter'; import { OperationError } from './OperationError'; import { OperationStatus } from './OperationStatus'; @@ -75,7 +79,11 @@ export class ShellOperationRunner implements IOperationRunner { lastState?: IOperationLastState ): Promise { return await context.runWithTerminalAsync( - async (terminal: ITerminal, terminalProvider: ITerminalProvider) => { + async ( + terminal: ITerminal, + terminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => { let hasWarningOrError: boolean = false; // Log any ignored parameters @@ -96,6 +104,8 @@ export class ShellOperationRunner implements IOperationRunner { const { rushConfiguration, projectFolder } = this.#rushProject; const { environment: initialEnvironment } = context; + const childProcessReporter: IOperationChildProcessReporter | undefined = + !IS_WINDOWS && isHeftCommand(commandToRun) ? context.createChildProcessReporter() : undefined; const subProcess: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { rushConfiguration: rushConfiguration, @@ -105,8 +115,19 @@ export class ShellOperationRunner implements IOperationRunner { environmentPathOptions: { includeProjectBin: true }, - initialEnvironment + initialEnvironment, + additionalEnvironment: childProcessReporter?.environment, + stdio: childProcessReporter?.stdio }); + let reporterError: Error | undefined; + const reporterDrainPromise: Promise = childProcessReporter + ? childProcessReporter + .attachAsync(subProcess, structuredChildOutputTerminalProvider) + .catch((error) => { + reporterError = + error instanceof Error ? error : new Error('The Heft child reporter channel failed.'); + }) + : Promise.resolve(); // Hook into events, in order to get live streaming of the log subProcess.stdout?.on('data', (data: Buffer) => { @@ -119,22 +140,20 @@ export class ShellOperationRunner implements IOperationRunner { hasWarningOrError = true; }); - const status: OperationStatus = await new Promise( - (resolve: (status: OperationStatus) => void, reject: (error: OperationError) => void) => { + const closePromise: Promise<{ + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + }> = new Promise( + ( + resolve: (result: { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + }) => void, + reject: (error: OperationError) => void + ) => { subProcess.on('close', (exitCode: number | null, signal: NodeJS.Signals | null) => { try { - // Do NOT reject here immediately, give a chance for other logic to suppress the error - if (signal) { - context.error = new OperationError('error', `Terminated by signal: ${signal}`); - resolve(OperationStatus.Failure); - } else if (exitCode !== 0) { - context.error = new OperationError('error', `Returned error code: ${exitCode}`); - resolve(OperationStatus.Failure); - } else if (hasWarningOrError) { - resolve(OperationStatus.SuccessWithWarning); - } else { - resolve(OperationStatus.Success); - } + resolve({ exitCode, signal }); } catch (error) { context.error = error as OperationError; reject(error as OperationError); @@ -142,8 +161,28 @@ export class ShellOperationRunner implements IOperationRunner { }); } ); + const [{ exitCode, signal }]: [ + { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null }, + void + ] = await Promise.all([closePromise, reporterDrainPromise]); - return status; + if (signal) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', `Terminated by signal: ${signal}`); + return OperationStatus.Failure; + } else if (exitCode !== 0) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', `Returned error code: ${exitCode}`); + return OperationStatus.Failure; + } else if (reporterError && !(reporterError instanceof HeftChildReporterNonFatalError)) { + // eslint-disable-next-line require-atomic-updates -- This operation context has one active runner. + context.error = new OperationError('error', reporterError.message); + return OperationStatus.Failure; + } else if (hasWarningOrError || childProcessReporter?.hasWarningOrError) { + return OperationStatus.SuccessWithWarning; + } else { + return OperationStatus.Success; + } }, { createLogFile: true @@ -156,6 +195,63 @@ export class ShellOperationRunner implements IOperationRunner { } } +/** + * Returns whether a lifecycle command directly launches Heft. + * + * @internal + */ +export function isHeftCommand(command: string): boolean { + let quote: "'" | '"' | undefined; + let escaped: boolean = false; + for (const character of command) { + if (escaped) { + escaped = false; + continue; + } + if (character === '\\' && quote !== "'") { + escaped = true; + continue; + } + if (quote) { + if (quote === '"' && (character === '$' || character === '`')) { + return false; + } + if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ('&|;<>()`$\r\n'.includes(character)) { + return false; + } + } + if (quote || escaped) { + return false; + } + + const tokens: string[] = + command + .match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) + ?.map((token: string) => token.replace(/^(['"])(.*)\1$/, '$2')) ?? []; + if (tokens.length === 0) { + return false; + } + + const executableName: string = path.basename(tokens[0].replace(/\\/g, '/')).toLowerCase(); + if (executableName === 'heft' || executableName === 'heft.cmd' || executableName === 'heft.exe') { + return true; + } + if ((executableName === 'node' || executableName === 'node.exe') && tokens.length > 1) { + const scriptName: string = path.basename(tokens[1].replace(/\\/g, '/')).toLowerCase(); + return scriptName === 'heft' || scriptName === 'heft.js'; + } + return false; +} + /** * When running a command from the "scripts" block in package.json, if the command * contains Unix-style path slashes and the OS is Windows, the package managers will diff --git a/libraries/rush-lib/src/logic/operations/test/DaemonIpcConfiguration.test.ts b/libraries/rush-lib/src/logic/operations/test/DaemonIpcConfiguration.test.ts new file mode 100644 index 0000000000..c8f2b1a319 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/DaemonIpcConfiguration.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { JsonSchema } from '@rushstack/node-core-library'; + +import type { IDaemonIpcConfiguration } from '../../../api/RushProjectConfiguration'; +import schemaJson from '../../../schemas/rush-project.schema.json'; +import { + resolveDaemonIpcConfigurationAsync, + type IResolvedDaemonIpcConfiguration +} from '../DaemonIpcConfiguration'; + +describe('explicit daemon Node implementation boundary', () => { + let folder: string; + const descriptor: IDaemonIpcConfiguration = { entryPoint: 'tools/entry.cjs', args: ['two words', '"literal"'] }; + beforeEach(() => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'daemon-ipc-definition-')); + fs.mkdirSync(path.join(folder, 'tools')); + fs.writeFileSync(path.join(folder, descriptor.entryPoint), "require('./helper.cjs');\n"); + fs.writeFileSync(path.join(folder, 'tools/helper.cjs'), 'module.exports = 1;\n'); + fs.writeFileSync(path.join(folder, 'input.txt'), 'one'); + }); + afterEach(() => fs.rmSync(folder, { recursive: true, force: true })); + + it('retains raw args and fingerprints only the bounded implementation tree by content', async () => { + const first: IResolvedDaemonIpcConfiguration = await resolveDaemonIpcConfigurationAsync(folder, descriptor); + expect(first.args).toEqual(['two words', '"literal"']); + expect(first.entryPoint).toBe(fs.realpathSync.native(path.join(folder, descriptor.entryPoint))); + fs.writeFileSync(path.join(folder, 'input.txt'), 'changed ordinary build input'); + fs.utimesSync(path.join(folder, descriptor.entryPoint), new Date(), new Date()); + expect((await resolveDaemonIpcConfigurationAsync(folder, descriptor)).implementationHash).toBe(first.implementationHash); + fs.writeFileSync(path.join(folder, 'tools/helper.cjs'), 'module.exports = 2;\n'); + const helperChanged: IResolvedDaemonIpcConfiguration = await resolveDaemonIpcConfigurationAsync(folder, descriptor); + expect(helperChanged.implementationHash).not.toBe(first.implementationHash); + fs.writeFileSync(path.join(folder, 'tools/added.json'), '{}'); + expect((await resolveDaemonIpcConfigurationAsync(folder, descriptor)).implementationHash).not.toBe(helperChanged.implementationHash); + }); + + it.each([ + { entryPoint: '../entry.cjs' }, + { entryPoint: '/entry.cjs' }, + { entryPoint: 'C:\\entry.cjs' }, + { entryPoint: 'entry.cjs' }, + { entryPoint: 'tools/entry.ts' }, + { entryPoint: 'tools/entry.cjs', args: ['\0'] } + ])('refuses an invalid executable/argument boundary: %j', async (invalid: IDaemonIpcConfiguration) => { + await expect(resolveDaemonIpcConfigurationAsync(folder, invalid)).rejects.toThrow(); + }); + + it('rejects oversized implementations instead of silently truncating their fingerprint', async () => { + fs.writeFileSync(path.join(folder, 'tools/too-large.bin'), Buffer.alloc(8 * 1024 * 1024)); + await expect(resolveDaemonIpcConfigurationAsync(folder, descriptor)).rejects.toThrow('8 MiB'); + }); + + it.each([ + {}, + { entryPoint: '' }, + { entryPoint: 'tools/entry.cjs', args: [42] }, + { entryPoint: 'tools/entry.cjs', command: 'arbitrary-shell' } + ])('schema rejects malformed public descriptors: %j', (invalid: object) => { + expect(() => JsonSchema.fromLoadedObject(schemaJson).validateObject({ + operationSettings: [{ operationName: '_phase:build', daemonIpc: invalid }] + }, 'rush-project.json')).toThrow(); + }); + + it('schema accepts the explicit Node descriptor', () => { + JsonSchema.fromLoadedObject(schemaJson).validateObject({ + operationSettings: [{ operationName: '_phase:build', daemonIpc: descriptor }] + }, 'rush-project.json'); + }); +}); diff --git a/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts new file mode 100644 index 0000000000..092160e850 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/HeftChildProcessReporter.test.ts @@ -0,0 +1,865 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import { PassThrough, Writable } from 'node:stream'; + +import { PlaintextReporter, type IReporterEventEnvelope, type IRushDiagnostic } from '@rushstack/rush-reporter'; +import { StringBufferTerminalProvider } from '@rushstack/terminal'; + +import { HeftChildProcessReporter, HeftChildReporterNonFatalError } from '../HeftChildProcessReporter'; + +const CONTEXT = { + reporter: 'json', + logLevel: 'debug', + color: false, + terminalWidth: 120 +} as const; + +// eslint-disable-next-line @rushstack/no-new-null -- ChildProcess.close uses null for signal exits. +function waitForCloseAsync(child: childProcess.ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); +} + +describe(HeftChildProcessReporter.name, () => { + it('negotiates structured child events and parent context', async () => { + const envelopes: IReporterEventEnvelope[] = []; + let plaintext: string = ''; + const plaintextReporter: PlaintextReporter = new PlaintextReporter({ + variant: 'detailed', + write: (text: string) => { + plaintext += text; + } + }); + let structuredNegotiated: boolean = false; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + envelopes.push(envelope); + plaintextReporter.report(envelope); + return envelope.eventId; + }, + onDiagnostic: () => { + throw new Error('A compatible child must not emit a protocol diagnostic.'); + }, + onStructuredNegotiated: () => { + structuredNegotiated = true; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1', 'reporter-context-v1'], + requiredFeatures: [] + }) + '\\n'); + const ack = JSON.parse(fs.readFileSync(ackFd, 'utf8').trim()); + if (ack.context.reporter !== 'json' || ack.context.terminalWidth !== 120) process.exit(3); + for (let sequence = 1; sequence <= 2; sequence++) { + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_' + sequence, + sessionId: 'child-session', + sequence, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: sequence === 1 ? 'stdout' : 'stderr', text: String(sequence) } + }) + '\\n'); + } + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_3', + sessionId: 'child-session', + sequence: 3, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'public', + required: true, + type: 'diagnosticEmitted', + payload: { + diagnosticId: 'diagnostic-1', + code: 'RUSH_EXTERNAL_TOOL_PROBLEM', + category: 'operation', + severity: 'error', + summaryKey: 'diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary', + parameters: { + tool: { value: 'typescript', privacy: 'public' }, + code: { value: 'TS1005', privacy: 'public' }, + message: { value: 'semicolon expected', privacy: 'local-sensitive' } + }, + source: { kind: 'file', file: 'src/index.ts', line: 4, column: 2, toolName: 'typescript' } + } + }) + '\\n'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_4', + sessionId: 'child-session', + sequence: 4, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'secret', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'TOP_SECRET_CHILD_OUTPUT' } + }) + '\\n'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const eventStream = child.stdio[3]; + const acknowledgementStream = child.stdio[4]; + const initialListenerCounts = { + eventData: eventStream?.listenerCount('data'), + eventError: eventStream?.listenerCount('error'), + eventEnd: eventStream?.listenerCount('end'), + acknowledgementError: acknowledgementStream?.listenerCount('error'), + acknowledgementClose: acknowledgementStream?.listenerCount('close') + }; + const structuredOutputTerminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child, structuredOutputTerminalProvider) + ]); + + expect(exitCode).toBe(0); + expect(structuredNegotiated).toBe(true); + expect(reporter.hasWarningOrError).toBe(true); + expect(envelopes.map((envelope) => envelope.sequence)).toEqual([1, 2, 3, 4]); + expect(envelopes.map((envelope) => envelope.parentSessionId)).toEqual([ + 'parent-session', + 'parent-session', + 'parent-session', + 'parent-session' + ]); + expect(envelopes.map((envelope) => envelope.parentRequestId)).toEqual([ + 'parent-request', + 'parent-request', + 'parent-request', + 'parent-request' + ]); + expect(envelopes.map((envelope) => envelope.parentOperationId)).toEqual([ + 'project#build', + 'project#build', + 'project#build', + 'project#build' + ]); + expect(envelopes.map((envelope) => envelope.scope?.operationId)).toEqual([ + 'project#build', + 'project#build', + 'project#build', + 'project#build' + ]); + expect(envelopes.map((envelope) => envelope.source)).toEqual([ + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, + { packageName: '@rushstack/heft', packageVersion: 'unknown' }, + { packageName: '@rushstack/heft', packageVersion: 'unknown' } + ]); + expect(envelopes.map((envelope) => envelope.privacy)).toEqual([ + 'local-sensitive', + 'local-sensitive', + 'local-sensitive', + 'secret' + ]); + expect(envelopes.map((envelope) => (envelope.payload as { iterationId?: number }).iterationId)).toEqual([ + 7, 7, 7, 7 + ]); + expect((envelopes[3].payload as { text?: string }).text).toBe('[secret child output omitted]'); + expect(structuredOutputTerminalProvider.getOutput()).toBe('1'); + expect(structuredOutputTerminalProvider.getErrorOutput({ normalizeSpecialCharacters: false })).toBe( + '2[typescript] error (TS1005): src/index.ts:4:2 - semicolon expected\n' + ); + await plaintextReporter.closeAsync(); + expect(plaintext).toContain('typescript'); + expect(plaintext).toContain('TS1005'); + expect(plaintext).toContain('src/index.ts:4:2'); + expect(plaintext.match(/semicolon expected/g)).toHaveLength(1); + expect(plaintext).not.toContain('TOP_SECRET_CHILD_OUTPUT'); + expect(eventStream?.listenerCount('data')).toBe(initialListenerCounts.eventData); + expect(eventStream?.listenerCount('error')).toBe(initialListenerCounts.eventError); + expect(eventStream?.listenerCount('end')).toBe(initialListenerCounts.eventEnd); + expect(acknowledgementStream?.listenerCount('error')).toBe(initialListenerCounts.acknowledgementError); + expect(acknowledgementStream?.listenerCount('close')).toBe(initialListenerCounts.acknowledgementClose); + }); + + it('preserves old child stdout and stderr when no hello is sent', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => { + throw new Error('An old child must remain on raw fallback.'); + } + }); + const child: childProcess.ChildProcess = childProcess.spawn( + process.execPath, + ['-e', "process.stdout.write('old stdout'); process.stderr.write('old stderr');"], + { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + } + ); + let stdout: string = ''; + let stderr: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child, new StringBufferTerminalProvider()) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('old stdout'); + expect(stderr).toBe('old stderr'); + expect(diagnostics).toEqual([]); + }); + + it('keeps raw fallback active when the child event capability is not negotiated', async () => { + let structuredNegotiated: boolean = false; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => { + structuredNegotiated = true; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: [], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + process.stdout.write('capability fallback'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child, new StringBufferTerminalProvider()) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('capability fallback'); + expect(structuredNegotiated).toBe(false); + }); + + it('surfaces unsupported requirements and retains fallback output', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => { + throw new Error('An unsupported child must not negotiate structured reporting.'); + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 2, minor: 0 }, + producerVersion: '@rushstack/heft 2.0.0', + capabilities: ['heft-child-events-v1'], + requiredFeatures: ['future-required-feature'] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + process.stdout.write('fallback after rejection'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child, new StringBufferTerminalProvider()) + ]); + + expect(exitCode).toBe(0); + expect(stdout).toBe('fallback after rejection'); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_UPDATE_REQUIRED']); + }); + + it('rejects exactly once when the acknowledgement pipe closes before delivery', async () => { + let structuredNegotiationCount: number = 0; + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => { + structuredNegotiationCount++; + } + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.closeSync(ackFd); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + process.stdout.write('raw fallback after acknowledgement close'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + let stdout: string = ''; + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk; + }); + const closePromise: Promise = waitForCloseAsync(child); + let rejectionCount: number = 0; + const attachPromise: Promise = reporter + .attachAsync(child, new StringBufferTerminalProvider()) + .catch((error) => { + rejectionCount++; + throw error; + }); + + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); + expect(await closePromise).toBe(0); + expect(stdout).toBe('raw fallback after acknowledgement close'); + expect(rejectionCount).toBe(1); + expect(structuredNegotiationCount).toBe(0); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'RUSH_PROTOCOL_INVALID_CHILD_STREAM', + severity: 'warning' + }); + }); + + it('handles an acknowledgement write callback error followed by an EPIPE event', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: Writable = new Writable({ + write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + void chunk; + void encoding; + const error: NodeJS.ErrnoException = new Error('write EPIPE'); + error.code = 'EPIPE'; + callback(error); + } + }); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + let rejectionCount: number = 0; + const attachPromise: Promise = reporter + .attachAsync(child, new StringBufferTerminalProvider()) + .catch((error) => { + rejectionCount++; + throw error; + }); + + eventStream.end( + `${JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + })}\n` + ); + + await expect(attachPromise).rejects.toThrow(/acknowledgement failed: write EPIPE/); + await new Promise((resolve) => setImmediate(resolve)); + expect(rejectionCount).toBe(1); + }); + + it('does not forward records after a preclosed acknowledgement stream rejects negotiation', async () => { + const envelopes: IReporterEventEnvelope[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + envelopes.push(envelope); + return envelope.eventId; + }, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: PassThrough = new PassThrough(); + acknowledgementStream.destroy(); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + const provider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const attachPromise: Promise = reporter.attachAsync(child, provider); + + eventStream.end( + [ + { + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }, + { + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'must not forward' } + } + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n' + ); + + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); + expect(envelopes).toEqual([]); + expect(provider.getOutput()).toBe(''); + }); + + it('does not replace a fatal forwarding failure with an acknowledgement warning', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: () => { + throw new Error('foreign envelope rejected'); + }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => undefined + }); + const eventStream: PassThrough = new PassThrough(); + const acknowledgementStream: Writable = new Writable({ + write( + chunk: Buffer | string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + void chunk; + void encoding; + setImmediate(callback); + } + }); + const child = { + stdio: [null, null, null, eventStream, acknowledgementStream] + } as unknown as childProcess.ChildProcess; + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + + eventStream.end( + [ + { + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }, + { + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'trigger forwarding failure' } + } + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n' + ); + + await expect(attachPromise).rejects.toThrow('foreign envelope rejected'); + expect(diagnostics).toEqual([]); + }); + + it('rejects stream callback failures without an uncaught process error', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'trigger callback' } + }) + '\\n'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const provider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + jest.spyOn(provider, 'write').mockImplementation(() => { + throw new Error('archive write failed'); + }); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(reporter.attachAsync(child, provider)).rejects.toThrow('archive write failed'); + expect(await closePromise).not.toBeNull(); + }); + + it('rejects attach when the validated envelope callback fails', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: () => { + throw new Error('foreign envelope rejected'); + }, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: 'untrusted', packageVersion: '0.0.0' }, + privacy: 'public', + required: false, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'output' } + }) + '\\n'); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(attachPromise).rejects.toThrow('foreign envelope rejected'); + expect(await closePromise).toBe(0); + }); + + it('rejects attach instead of emitting an unhandled acknowledgement pipe error', async () => { + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: () => undefined, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.closeSync(ackFd); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + setTimeout(() => process.exit(0), 50); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const attachPromise: Promise = reporter.attachAsync(child, new StringBufferTerminalProvider()); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(attachPromise).rejects.toBeInstanceOf(HeftChildReporterNonFatalError); + expect(await closePromise).toBe(0); + }); + + it('drains three MiB of accepted output in order without losing chunks', async () => { + const sequences: number[] = []; + let outputBytes: number = 0; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + const payload: { text: string } = envelope.payload as { text: string }; + expect(Buffer.byteLength(payload.text)).toBe(64 * 1024); + sequences.push(envelope.sequence); + outputBytes += Buffer.byteLength(payload.text); + return envelope.eventId; + }, + onDiagnostic: (diagnostic) => { + throw new Error(`Unexpected diagnostic: ${diagnostic.code}`); + }, + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const fd = Number(process.env._RUSH_REPORTER_CHILD_FD); + fs.writeSync(fd, JSON.stringify({ + kind: 'hello', protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(Number(process.env._RUSH_REPORTER_CHILD_ACK_FD), 'utf8'); + for (let sequence = 1; sequence <= 48; sequence++) { + fs.writeSync(fd, JSON.stringify({ + protocolVersion: { major: 1, minor: 2 }, eventId: 'child_' + sequence, + sessionId: 'child', sequence, timestamp: '2026-09-09T00:00:00Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', required: true, type: 'externalOutput', + payload: { stream: 'stdout', text: 'x'.repeat(64 * 1024) } + }) + '\\n'); + } + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const [exitCode]: [number | null, void] = await Promise.all([ + waitForCloseAsync(child), + reporter.attachAsync(child, { supportsColor: false, eolCharacter: '\n', write: () => undefined }) + ]); + + expect(exitCode).toBe(0); + expect(outputBytes).toBe(3 * 1024 * 1024); + expect(sequences).toHaveLength(48); + for (let index: number = 0; index < sequences.length; index++) { + expect(sequences[index]).toBe(index + 1); + } + }); + + it.each(['malformed', 'oversized', 'truncated'] as const)( + 'rejects an accepted %s stream even when the child exits successfully', + async (corruption) => { + const diagnostics: IRushDiagnostic[] = []; + const envelopes: IReporterEventEnvelope[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => { + envelopes.push(envelope); + return envelope.eventId; + }, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + const event = { + protocolVersion: { major: 1, minor: 2 }, + eventId: 'child_1', + sessionId: 'child-session', + sequence: 1, + timestamp: '2026-01-01T00:00:00.000Z', + source: { packageName: '@rushstack/heft', packageVersion: '1.2.25' }, + privacy: 'local-sensitive', + required: true, + type: 'externalOutput', + payload: { stream: 'stdout', text: 'preserved before corruption\\n' } + }; + fs.writeSync(eventFd, JSON.stringify(event) + '\\n'); + const corruption = ${JSON.stringify(corruption)}; + fs.writeSync(eventFd, corruption === 'oversized' + ? 'x'.repeat(1024 * 1024 + 1) + '\\n' + : corruption === 'malformed' ? '{invalid}\\n' : '{"eventId":'); + if (corruption !== 'truncated') { + fs.writeSync(eventFd, JSON.stringify({ + ...event, eventId: 'child_2', sequence: 2, + payload: { stream: 'stdout', text: 'cannot recover after corruption\\n' } + }) + '\\n'); + } + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + const closePromise: Promise = waitForCloseAsync(child); + + await expect(reporter.attachAsync(child, new StringBufferTerminalProvider())).rejects.toThrow( + 'The negotiated Heft reporter stream was corrupt or incomplete.' + ); + expect(await closePromise).toBe(0); + expect(envelopes.map((envelope) => envelope.payload)).toEqual([ + { stream: 'stdout', text: 'preserved before corruption\n', iterationId: 7 } + ]); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_INVALID_CHILD_STREAM']); + } + ); + + it('rejects a truncated accepted descriptor stream without hiding the child crash', async () => { + const diagnostics: IRushDiagnostic[] = []; + const reporter: HeftChildProcessReporter = new HeftChildProcessReporter({ + parentSessionId: 'parent-session', + parentRequestId: 'parent-request', + parentOperationId: 'project#build', + iterationId: 7, + context: CONTEXT, + ingestForeignEnvelope: (envelope) => envelope.eventId, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onStructuredNegotiated: () => undefined + }); + const script: string = ` + const fs = require('node:fs'); + const eventFd = Number(process.env._RUSH_REPORTER_CHILD_FD); + const ackFd = Number(process.env._RUSH_REPORTER_CHILD_ACK_FD); + fs.writeSync(eventFd, JSON.stringify({ + kind: 'hello', + protocolVersion: { major: 1, minor: 2 }, + producerVersion: '@rushstack/heft 1.2.25', + capabilities: ['heft-child-events-v1'], + requiredFeatures: [] + }) + '\\n'); + fs.readFileSync(ackFd, 'utf8'); + fs.writeSync(eventFd, '{"eventId":'); + process.exit(7); + `; + const child: childProcess.ChildProcess = childProcess.spawn(process.execPath, ['-e', script], { + env: { ...process.env, ...reporter.environment }, + stdio: reporter.stdio + }); + + const closePromise: Promise = waitForCloseAsync(child); + + await expect(reporter.attachAsync(child, new StringBufferTerminalProvider())).rejects.toThrow( + 'The negotiated Heft reporter stream was corrupt or incomplete.' + ); + expect(await closePromise).toBe(7); + expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['RUSH_PROTOCOL_INVALID_CHILD_STREAM']); + }); +}); diff --git a/libraries/rush-lib/src/logic/operations/test/IPCOperationRunnerResources.test.ts b/libraries/rush-lib/src/logic/operations/test/IPCOperationRunnerResources.test.ts new file mode 100644 index 0000000000..43b097f60d --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/IPCOperationRunnerResources.test.ts @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('../../../utilities/Utilities'); +jest.mock('../OperationStateFile'); +jest.mock('../ProjectLogWritable', () => { + const actual = jest.requireActual('../ProjectLogWritable'); + const { MockWritable } = jest.requireActual('@rushstack/terminal'); + return { ...actual, initializeProjectLogFilesAsync: jest.fn(async () => new MockWritable()) }; +}); + +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; + +import { MockWritable } from '@rushstack/terminal'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import { Utilities } from '../../../utilities/Utilities'; +import { IPCOperationRunner } from '../IPCOperationRunner'; +import { Operation } from '../Operation'; +import { OperationGraph } from '../OperationGraph'; +import { OperationStatus } from '../OperationStatus'; + +describe('native IPC resource lifetime and measured RSS', () => { + let child: ChildProcess; + let closed: Promise; + let graph: OperationGraph; + let runner: IPCOperationRunner; + + function create(script: string): void { + jest.mocked(Utilities.executeLifecycleCommandAsync).mockImplementation(() => { + child = spawn(process.execPath, ['-e', script], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); + closed = once(child, 'close'); + return child; + }); + const phase: IPhase = { + name: 'phase', + allowWarningsOnSuccess: false, + associatedParameters: new Set(), + dependencies: { self: new Set(), upstream: new Set() }, + isSynthetic: false, + logFilenameIdentifier: 'phase', + missingScriptBehavior: 'silent' + }; + const project: RushConfigurationProject = { + packageName: 'ipc', + projectFolder: __dirname, + rushConfiguration: { commonTempFolder: __dirname } + } as RushConfigurationProject; + runner = new IPCOperationRunner({ + name: 'ipc', + phase, + project, + initialCommand: 'initial', + incrementalCommand: 'incremental', + commandForHash: 'build', + ignoredParameterValues: [] + }); + const operation: Operation = new Operation({ phase, project, runner, logFilenameIdentifier: 'ipc' }); + graph = new OperationGraph(new Set([operation]), { + quietMode: true, + debugMode: false, + parallelism: 3, + allowOversubscription: true, + destinations: [new MockWritable()], + abortController: new AbortController() + }); + } + + afterEach(async () => { + if (child?.exitCode === null && child.signalCode === null) child.kill(); + await closed; + graph?.abortController.abort(); + jest.mocked(Utilities.executeLifecycleCommandAsync).mockReset(); + }); + + it('accepts producer RSS, leaves old children unmeasured, and clears invalid/stopped samples', async () => { + create(` + let runs = 0; + process.on('message', message => { + if (message.command === 'exit') process.exit(0); + if (message.command === 'run') { + runs++; + const telemetry = runs === 1 ? {} : + { residentMemoryBytes: runs === 2 ? process.memoryUsage().rss : -1 }; + process.send({ event: 'after-execute', status: 'SUCCESS', ...telemetry }); + } + }); + process.send({ event: 'sync' }); + `); + await graph.executeAsync({}); + expect(runner.residentMemoryBytes).toBeUndefined(); + await graph.executeAsync({}); + expect(runner.residentMemoryBytes).toBeGreaterThan(0); + await graph.executeAsync({}); + expect(runner.residentMemoryBytes).toBeUndefined(); + await graph.closeRunnersAsync(); + expect(runner.isActive).toBe(false); + expect(runner.residentMemoryBytes).toBeUndefined(); + expect(Utilities.executeLifecycleCommandAsync).toHaveBeenCalledTimes(1); + }); + + it('does not report success if a retiring IPC child exits before reporting a result', async () => { + create(` + process.on('message', () => process.exit(0)); + process.send({ event: 'sync' }); + `); + const result = await graph.executeAsync({}); + expect(result.status).toBe(OperationStatus.Failure); + expect([...result.operationResults.values()][0].error?.message).toContain('before reporting'); + await graph.closeRunnersAsync(); + }); + + it('awaits close even after exit while descendant stdio is still open', async () => { + create(` + process.on('message', message => { + if (message.command === 'run') process.send({ event: 'after-execute', status: 'SUCCESS' }); + if (message.command === 'exit') { + require('node:child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 150)'], + { stdio: ['ignore', 1, 2] }); + process.exit(0); + } + }); + process.send({ event: 'sync' }); + `); + await graph.executeAsync({}); + const exited: Promise = once(child, 'exit'); + let isClosed: boolean = false; + child.once('close', () => { + isClosed = true; + }); + const closedAtResolution: boolean[] = []; + const closing: Promise = graph.closeRunnersAsync().then(() => { + closedAtResolution.push(isClosed); + }); + await exited; + const secondClosing: Promise = graph.closeRunnersAsync().then(() => { + closedAtResolution.push(isClosed); + }); + await Promise.all([closing, secondClosing, closed]); + expect(closedAtResolution).toEqual([true, true]); + expect(isClosed).toBe(true); + expect(runner.isActive).toBe(false); + }); +}); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts index 49adc88178..0f6b7af772 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts @@ -35,6 +35,8 @@ jest.mock('../ProjectLogWritable', () => { }; }); +import { spawn, type ChildProcess } from 'node:child_process'; + import { type ITerminal, Terminal } from '@rushstack/terminal'; import { CollatedTerminal } from '@rushstack/stream-collator'; import { MockWritable, PrintUtilities } from '@rushstack/terminal'; @@ -47,6 +49,7 @@ import { _printOperationStatus } from '../OperationResultSummarizerPlugin'; import { _printTimeline } from '../ConsoleTimelinePlugin'; import { OperationStatus } from '../OperationStatus'; import { Operation } from '../Operation'; +import { IPCOperationRunner } from '../IPCOperationRunner'; import { Utilities } from '../../../utilities/Utilities'; import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; import { MockOperationRunner } from './MockOperationRunner'; @@ -116,11 +119,7 @@ class ClosableRunner extends MockOperationRunner { public override async executeAsync(context: IOperationRunnerContext): Promise { this.isActive = true; - const status: OperationStatus = await super.executeAsync(context); - if (!context.shouldRunnerPersist) { - await this.closeAsync(); - } - return status; + return await super.executeAsync(context); } } @@ -1162,6 +1161,53 @@ describe('deferred invalidation during active iteration', () => { }); }); +describe('discarding prepared iterations', () => { + function pausedGraph(runner: IOperationRunner): OperationGraph { + return createGraph({ + quietMode: true, debugMode: false, parallelism: 1, allowOversubscription: false, + destinations: [mockWritable], abortController: new AbortController(), + pauseNextIteration: true, closeRunnersOnAbort: false + }, runner); + } + + it('drops unstarted work without running or closing its runner', async () => { + const runAsync = jest.fn(async () => OperationStatus.Success); + const runner: ClosableRunner = new ClosableRunner('discarded', runAsync); + const graph: OperationGraph = pausedGraph(runner); + try { + expect(await graph.scheduleIterationAsync({})).toBe(true); + expect(graph.discardScheduledIteration()).toBe(true); + expect(graph.discardScheduledIteration()).toBe(false); + expect(graph.hasScheduledIteration).toBe(false); + expect(graph.resultByOperation.size).toBe(0); + expect(runAsync).not.toHaveBeenCalled(); + expect(runner.closeAsync).not.toHaveBeenCalled(); + } finally { + graph.abortController.abort(); + await graph.closeRunnersAsync(); + } + }); + + it('preserves completed results and the resident runner when replacing a prepared iteration', async () => { + const runner: ClosableRunner = new ClosableRunner('retained'); + const graph: OperationGraph = pausedGraph(runner); + try { + await graph.executeAsync({}); + const operation: Operation = [...graph.operations][0]; + const result = graph.resultByOperation.get(operation); + await graph.scheduleIterationAsync({}); + graph.discardScheduledIteration(); + expect(graph.resultByOperation.get(operation)).toBe(result); + expect(result?.status).toBe(OperationStatus.Success); + expect(runner.isActive).toBe(true); + expect(runner.closeAsync).not.toHaveBeenCalled(); + } finally { + graph.abortController.abort(); + await graph.closeRunnersAsync(); + } + }); +}); + describe('runner persistence policy', () => { const graphOptions: IOperationGraphOptions = { quietMode: false, @@ -1220,14 +1266,21 @@ describe('runner persistence policy', () => { await graph.executeAsync({}); expect(persistentRunner.closeAsync).not.toHaveBeenCalled(); - expect(oneShotRunner.closeAsync).toHaveBeenCalledTimes(2); + expect(oneShotRunner.closeAsync).toHaveBeenCalledTimes(1); expect(changingRunner.closeAsync).not.toHaveBeenCalled(); persistentOperations = new Set([persistentOperation]); await graph.executeAsync({}); expect(persistentRunner.closeAsync).not.toHaveBeenCalled(); - expect(oneShotRunner.closeAsync).toHaveBeenCalledTimes(4); - expect(changingRunner.closeAsync).toHaveBeenCalledTimes(2); + expect(oneShotRunner.closeAsync).toHaveBeenCalledTimes(2); + expect(changingRunner.closeAsync).toHaveBeenCalledTimes(1); + + persistentOperations = new Set([persistentOperation, changingOperation]); + await graph.executeAsync({}); + expect(persistentRunner.closeAsync).not.toHaveBeenCalled(); + expect(oneShotRunner.closeAsync).toHaveBeenCalledTimes(3); + expect(changingRunner.closeAsync).toHaveBeenCalledTimes(1); + expect(changingRunner.isActive).toBe(true); }); it('releases a non-persistent runner before its dependents execute', async () => { @@ -1271,13 +1324,83 @@ describe('runner persistence policy', () => { configureRunnerPersistence(graph, (operation) => operation !== upstreamOperation); await graph.executeAsync({}); - expect(upstreamRunner.closeAsync).toHaveBeenCalledTimes(2); + expect(upstreamRunner.closeAsync).toHaveBeenCalledTimes(1); expect(downstreamRunner.closeAsync).not.toHaveBeenCalled(); // The non-persistent upstream runner must be released immediately upon its own completion, // before the downstream operation begins executing — not deferred to the end of the iteration. expect(executionOrder).toEqual(['upstream:run', 'upstream:close', 'downstream:run']); }); + it('blocks dependents when a non-persistent runner fails to close', async () => { + const upstreamRunner: ClosableRunner = new ClosableRunner('upstream'); + const closeError: Error = new Error('close failed'); + upstreamRunner.closeAsync.mockRejectedValue(closeError); + const downstreamRun: jest.Mock = jest.fn(async () => OperationStatus.Success); + const upstreamOperation: Operation = new Operation({ + runner: upstreamRunner, + logFilenameIdentifier: 'upstream', + phase: mockPhase, + project: getOrCreateProject('upstream') + }); + const downstreamOperation: Operation = new Operation({ + runner: new MockOperationRunner('downstream', downstreamRun), + logFilenameIdentifier: 'downstream', + phase: mockPhase, + project: getOrCreateProject('downstream') + }); + downstreamOperation.addDependency(upstreamOperation); + const graph: OperationGraph = new OperationGraph( + new Set([upstreamOperation, downstreamOperation]), + graphOptions + ); + configureRunnerPersistence(graph, (operation) => operation !== upstreamOperation); + + const result: IExecutionResult = await graph.executeAsync({}); + + expect(result.status).toBe(OperationStatus.Failure); + expect(result.operationResults.get(upstreamOperation)?.error).toBe(closeError); + expect(result.operationResults.get(downstreamOperation)?.status).toBe(OperationStatus.Blocked); + expect(upstreamRunner.closeAsync).toHaveBeenCalledTimes(1); + expect(downstreamRun).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'blocks dependents after an aggregate cleanup notification failure (empty: %s)', + async (empty) => { + const upstreamRunner: ClosableRunner = new ClosableRunner('upstream'); + const downstreamRun: jest.Mock = jest.fn(async () => OperationStatus.Success); + const upstream: Operation = new Operation({ + runner: upstreamRunner, logFilenameIdentifier: 'upstream', + phase: mockPhase, project: getOrCreateProject('upstream') + }); + const downstream: Operation = new Operation({ + runner: new MockOperationRunner('downstream', downstreamRun), + logFilenameIdentifier: 'downstream', phase: mockPhase, project: getOrCreateProject('downstream') + }); + downstream.addDependency(upstream); + const graph: OperationGraph = new OperationGraph(new Set([upstream, downstream]), graphOptions); + configureRunnerPersistence(graph, (operation) => operation !== upstream); + const cause: Error = new Error('cleanup notification failed'); + const failure: AggregateError = new AggregateError(empty ? [] : [cause], 'aggregate cleanup failed'); + let threw: boolean = false; + graph.hooks.onExecutionStatesUpdated.tap('cleanup-notification', () => { + if (upstreamRunner.closeAsync.mock.calls.length > 0 && !threw) { + threw = true; + throw failure; + } + }); + + const result: IExecutionResult = await graph.executeAsync({}); + + expect(threw).toBe(true); + expect(result.status).toBe(OperationStatus.Failure); + expect(result.operationResults.get(upstream)?.error).toBe(empty ? failure : cause); + expect(result.operationResults.get(downstream)?.status).toBe(OperationStatus.Blocked); + expect(upstreamRunner.closeAsync).toHaveBeenCalledTimes(1); + expect(downstreamRun).not.toHaveBeenCalled(); + } + ); + it('closes an active cold runner when an iteration bypasses runner execution', async () => { const persistentRunner: ClosableRunner = new ClosableRunner('persistent'); const coldRunner: ClosableRunner = new ClosableRunner('cold'); @@ -1395,6 +1518,177 @@ describe('runner persistence policy', () => { }); }); +describe('IPC runner persistence', () => { + let children: ChildProcess[]; + let childClosedPromises: Promise[]; + let events: string[]; + let graphOptions: IOperationGraphOptions; + + beforeEach(() => { + children = []; + childClosedPromises = []; + events = []; + graphOptions = { + quietMode: true, + debugMode: false, + parallelism: 2, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + jest.mocked(Utilities.executeLifecycleCommandAsync).mockImplementation(() => { + const child: ChildProcess = spawn( + process.execPath, + [ + '-e', + ` + process.on('message', (message) => { + if (message.command === 'run') { + process.send({ event: 'after-execute', status: 'SUCCESS' }); + } else if (message.command === 'exit') { + process.exit(0); + } + }); + process.send({ event: 'sync' }); + ` + ], + { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] } + ); + children.push(child); + child.on('exit', () => events.push('child:exit')); + childClosedPromises.push( + new Promise((resolve) => { + child.once('close', () => { + events.push('child:close'); + resolve(); + }); + }) + ); + return child; + }); + }); + + afterEach(async () => { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + } + } + await Promise.all(childClosedPromises); + jest.mocked(Utilities.executeLifecycleCommandAsync).mockReset(); + }); + + function createIPCRunner(): IPCOperationRunner { + return new IPCOperationRunner({ + name: 'ipc', + phase: mockPhase, + project: { + packageName: 'ipc', + projectFolder: __dirname, + rushConfiguration: { commonTempFolder: __dirname } + } as RushConfigurationProject, + initialCommand: 'initial', + incrementalCommand: 'incremental', + commandForHash: 'build', + ignoredParameterValues: [] + }); + } + + it('reuses, closes and restarts a child as the iteration policy changes, restoring the default when omitted', async () => { + const runner: IPCOperationRunner = createIPCRunner(); + const graph: OperationGraph = createGraph(graphOptions, runner); + let shouldRunnerPersist: boolean | undefined; + graph.hooks.configureIteration.tap('test', (records) => { + if (shouldRunnerPersist !== undefined) { + for (const record of records.values()) { + record.shouldRunnerPersist = shouldRunnerPersist; + } + } + }); + + for (const [policy, expectedChildren] of [ + [undefined, 1], + [false, 1], + [true, 2], + [false, 2], + [undefined, 3] + ] as const) { + shouldRunnerPersist = policy; + const result: IExecutionResult = await graph.executeAsync({}); + expect(result.status).toBe(OperationStatus.Success); + expect(children).toHaveLength(expectedChildren); + expect(runner.isActive).toBe(policy ?? true); + expect([...result.operationResults.values()][0].shouldRunnerPersist).toBe(policy ?? true); + } + expect( + jest.mocked(Utilities.executeLifecycleCommandAsync).mock.calls.map(([command]) => command) + ).toEqual(['initial', 'incremental', 'incremental']); + }); + + it.each([undefined, OperationStatus.FromCache, OperationStatus.Skipped, 'disabled'] as const)( + 'closes a retained child before dependents when the next execution returns %s', + async (bypassStatus) => { + const runner: IPCOperationRunner = createIPCRunner(); + const upstream: Operation = new Operation({ + runner, + phase: mockPhase, + project: getOrCreateProject('ipc'), + logFilenameIdentifier: 'ipc' + }); + const downstream: Operation = new Operation({ + runner: new MockOperationRunner('downstream', async () => { + events.push('downstream:run'); + return OperationStatus.Success; + }), + phase: mockPhase, + project: getOrCreateProject('downstream'), + logFilenameIdentifier: 'downstream' + }); + downstream.addDependency(upstream); + const graph: OperationGraph = new OperationGraph(new Set([upstream, downstream]), graphOptions); + await graph.executeAsync({}); + expect(runner.isActive).toBe(true); + events.length = 0; + + configureRunnerPersistence(graph, (operation) => operation !== upstream); + if (bypassStatus === 'disabled') { + graph.hooks.configureIteration.tap('disable-upstream', (records) => { + records.get(upstream)!.enabled = false; + }); + } else { + graph.hooks.beforeExecuteOperationAsync.tapPromise('bypass-upstream', async (record) => { + return record.operation === upstream ? bypassStatus : undefined; + }); + } + const result: IExecutionResult = await graph.executeAsync({}); + + expect(result.status).toBe(OperationStatus.Success); + expect(runner.isActive).toBe(false); + expect(children).toHaveLength(1); + expect(events).toEqual(['child:exit', 'child:close', 'downstream:run']); + } + ); + + it('closes an idle child without dropping its operation or last result and restarts it on execution', async () => { + const runner: IPCOperationRunner = createIPCRunner(); + const graph: OperationGraph = createGraph(graphOptions, runner); + await graph.executeAsync({}); + const [operation]: Operation[] = [...graph.operations]; + const lastResult: IOperationExecutionResult | undefined = graph.resultByOperation.get(operation); + + await graph.closeRunnersAsync([operation]); + + expect(runner.isActive).toBe(false); + expect(events).toEqual(['child:exit', 'child:close']); + expect(graph.operations.has(operation)).toBe(true); + expect(graph.resultByOperation.get(operation)).toBe(lastResult); + expect(lastResult?.status).toBe(OperationStatus.Success); + expect((await graph.executeAsync({})).status).toBe(OperationStatus.Success); + expect(children).toHaveLength(2); + expect(runner.isActive).toBe(true); + }); +}); + describe('closeRunnersAsync', () => { it('invokes closeAsync on runners and triggers onExecutionStatesUpdated hook', async () => { const localOptions: IOperationGraphOptions = { diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts index 501a8186cd..b43da0d743 100644 --- a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -535,6 +535,7 @@ describe('OperationGraph event sink (dual-emit)', () => { operationStreamEnabled: true } }); + const graph: OperationGraph = new OperationGraph( new Set([ createOperation('visible', new MockOperationRunner('visible'), mockPhase, '@scope/visible'), diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts index 686f82c83f..837877c889 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunner.test.ts @@ -1,7 +1,25 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { convertSlashesForWindows } from '../ShellOperationRunner'; +import type * as childProcess from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +import { + StringBufferTerminalProvider, + Terminal, + type ITerminal, + type ITerminalProvider +} from '@rushstack/terminal'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import { Utilities } from '../../../utilities/Utilities'; +import type { IOperationRunnerContext } from '../IOperationRunner'; +import type { IOperationChildProcessReporter } from '../OperationEventSink'; +import { HeftChildReporterNonFatalError } from '../HeftChildProcessReporter'; +import { OperationStatus } from '../OperationStatus'; +import { ShellOperationRunner, convertSlashesForWindows, isHeftCommand } from '../ShellOperationRunner'; describe(convertSlashesForWindows.name, () => { it('converted inputs', () => { @@ -15,6 +33,160 @@ describe(convertSlashesForWindows.name, () => { expect(convertSlashesForWindows('/blah/bleep { + it.each([ + ['heft run --only build', true], + ['./node_modules/.bin/heft build', true], + ['node ./node_modules/@rushstack/heft/bin/heft build', true], + ['"C:\\repo\\node_modules\\.bin\\heft.cmd" build', true], + ['heft build --message "safe && quoted"', true], + ['eslint .', false], + ['node scripts/build.js', false], + ['cross-env NODE_ENV=test heft build', false], + ['heft build && node spawn-grandchild.js', false], + ['heft build $(node spawn-grandchild.js)', false], + ['heft build &', false], + ['heft build > output.log', false], + ['heft build `node spawn-grandchild.js`', false], + ['heft build "$(node spawn-grandchild.js)"', false], + ['heft build "`node spawn-grandchild.js`"', false], + ["heft build '$(literal)'", true], + ['heft build "unterminated', false] + ])('classifies %s', (command: string, expected: boolean) => { + expect(isHeftCommand(command)).toBe(expected); + }); + + it('does not allocate Unix reporter pipes for a shell child that can spawn a grandchild', async () => { + if (process.platform === 'win32') { + return; + } + const stdout: PassThrough = new PassThrough(); + const stderr: PassThrough = new PassThrough(); + const child: childProcess.ChildProcess = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdio: [] + }) as unknown as childProcess.ChildProcess; + const executeSpy = jest + .spyOn(Utilities, 'executeLifecycleCommandAsync') + .mockImplementation((command, options) => { + expect(command).toBe('node spawn-grandchild.js'); + expect(options.additionalEnvironment).toBeUndefined(); + expect(options.stdio).toBeUndefined(); + queueMicrotask(() => { + stdout.end(); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }); + const createChildProcessReporter = jest.fn(); + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const context = { + environment: undefined, + createChildProcessReporter, + async runWithTerminalAsync( + callback: ( + terminal: ITerminal, + operationTerminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise + ): Promise { + return await callback(new Terminal(terminalProvider), terminalProvider, terminalProvider); + } + } as unknown as IOperationRunnerContext; + const runner: ShellOperationRunner = new ShellOperationRunner({ + phase: { allowWarningsOnSuccess: false } as IPhase, + rushProject: { + projectFolder: process.cwd(), + rushConfiguration: { commonTempFolder: process.cwd() } + } as RushConfigurationProject, + displayName: 'grandchild retention', + initialCommand: 'node spawn-grandchild.js', + incrementalCommand: undefined, + commandForHash: 'node spawn-grandchild.js', + ignoredParameterValues: [] + }); + + try { + await expect(runner.executeAsync(context)).resolves.toBe(OperationStatus.Success); + expect(createChildProcessReporter).not.toHaveBeenCalled(); + } finally { + executeSpy.mockRestore(); + } + }); + + it.each<[Error, OperationStatus]>([ + [new HeftChildReporterNonFatalError('acknowledgement failed'), OperationStatus.Success], + [new Error('The negotiated Heft reporter stream was corrupt or incomplete.'), OperationStatus.Failure] + ])('handles reporter failure %s with operation status %s', async (reporterError, expectedStatus) => { + if (process.platform === 'win32') { + return; + } + const stdout: PassThrough = new PassThrough(); + const stderr: PassThrough = new PassThrough(); + const child: childProcess.ChildProcess = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdio: [] + }) as unknown as childProcess.ChildProcess; + const executeSpy = jest.spyOn(Utilities, 'executeLifecycleCommandAsync').mockImplementation(() => { + queueMicrotask(() => { + stdout.end(); + stderr.end(); + child.emit('close', 0, null); + }); + return child; + }); + const childReporter: IOperationChildProcessReporter = { + environment: {}, + hasWarningOrError: false, + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + attachAsync: async () => { + throw reporterError; + } + }; + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(); + const context = { + environment: undefined, + error: undefined, + createChildProcessReporter: () => childReporter, + async runWithTerminalAsync( + callback: ( + terminal: ITerminal, + operationTerminalProvider: ITerminalProvider, + structuredChildOutputTerminalProvider: ITerminalProvider + ) => Promise + ): Promise { + return await callback(new Terminal(terminalProvider), terminalProvider, terminalProvider); + } + } as unknown as IOperationRunnerContext; + const runner: ShellOperationRunner = new ShellOperationRunner({ + phase: { allowWarningsOnSuccess: false } as IPhase, + rushProject: { + projectFolder: process.cwd(), + rushConfiguration: { commonTempFolder: process.cwd() } + } as RushConfigurationProject, + displayName: 'nonfatal reporter error', + initialCommand: 'heft build', + incrementalCommand: undefined, + commandForHash: 'heft build', + ignoredParameterValues: [] + }); + + try { + await expect(runner.executeAsync(context)).resolves.toBe(expectedStatus); + if (expectedStatus === OperationStatus.Failure) { + expect(context.error?.message).toContain(reporterError.message); + } else { + expect(context.error).toBeUndefined(); + } + } finally { + executeSpy.mockRestore(); + } + }); + }); it('ignored inputs', () => { expect(convertSlashesForWindows('/blah\\bleep && /bloop')).toEqual('/blah\\bleep && /bloop'); expect(convertSlashesForWindows('cmd.exe /c blah')).toEqual('cmd.exe /c blah'); diff --git a/libraries/rush-lib/src/logic/selectors/GitChangedProjectSelectorParser.ts b/libraries/rush-lib/src/logic/selectors/GitChangedProjectSelectorParser.ts index ee5095fd24..f50ecfa2cd 100644 --- a/libraries/rush-lib/src/logic/selectors/GitChangedProjectSelectorParser.ts +++ b/libraries/rush-lib/src/logic/selectors/GitChangedProjectSelectorParser.ts @@ -18,6 +18,8 @@ export interface IGitSelectorParserOptions { * and exclude matched files from change detection. */ enableFiltering: boolean; + /** Optional request-owned project configuration reader. */ + getIncrementalBuildIgnoredGlobsAsync?: IGetChangedProjectsOptions['getIncrementalBuildIgnoredGlobsAsync']; } export class GitChangedProjectSelectorParser implements ISelectorParser { diff --git a/libraries/rush-lib/src/logic/test/PurgeManager.test.ts b/libraries/rush-lib/src/logic/test/PurgeManager.test.ts new file mode 100644 index 0000000000..eb62fac867 --- /dev/null +++ b/libraries/rush-lib/src/logic/test/PurgeManager.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { LockFile } from '@rushstack/node-core-library'; + +import { EnvironmentConfiguration } from '../../api/EnvironmentConfiguration'; +import { Rush } from '../../api/Rush'; +import { RushConfiguration } from '../../api/RushConfiguration'; +import type { RushGlobalFolder } from '../../api/RushGlobalFolder'; +import { PurgeManager } from '../PurgeManager'; + +describe(PurgeManager.name, () => { + it('purges temporary files without deleting the active native repository mutex', async () => { + const folder: string = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'rush-purge-lock-'))); + const commonTempFolder: string = path.join(folder, 'common/temp'); + const originalTemp: string | undefined = process.env.RUSH_TEMP_FOLDER; + let lock: LockFile | undefined; + try { + fs.mkdirSync(commonTempFolder, { recursive: true }); + process.env.RUSH_TEMP_FOLDER = commonTempFolder; + EnvironmentConfiguration.reset(); + const rushJsonFile: string = path.join(folder, 'rush.json'); + fs.writeFileSync( + rushJsonFile, + JSON.stringify({ + rushVersion: Rush.version, + npmVersion: '10.0.0', + projects: [] + }) + ); + const configuration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + expect(configuration.commonTempFolder).toBe(commonTempFolder); + const globalFolder: RushGlobalFolder = { + path: path.join(folder, 'global'), + nodeSpecificPath: path.join(folder, 'global/node') + }; + const obsoleteFile: string = path.join(commonTempFolder, 'obsolete.txt'); + fs.writeFileSync(obsoleteFile, 'temporary contents'); + lock = LockFile.tryAcquire(commonTempFolder, 'rush'); + expect(lock).toBeDefined(); + if (!lock) throw new Error('The purge fixture could not acquire its native lock.'); + const manager: PurgeManager = new PurgeManager(configuration, globalFolder); + + manager.purgeNormal(); + await manager.startDeleteAllAsync(); + + expect(fs.existsSync(obsoleteFile)).toBe(false); + expect(fs.existsSync(lock.filePath)).toBe(true); + expect(lock.isReleased).toBe(false); + expect(LockFile.tryAcquire(commonTempFolder, 'rush')).toBeUndefined(); + if (process.platform === 'win32') { + expect(fs.existsSync(`${lock.filePath}.dirty`)).toBe(true); + } + lock.release(); + expect(fs.existsSync(lock.filePath)).toBe(false); + expect(fs.existsSync(`${lock.filePath}.dirty`)).toBe(false); + } finally { + try { + if (lock && !lock.isReleased) lock.release(); + } finally { + if (originalTemp === undefined) delete process.env.RUSH_TEMP_FOLDER; + else process.env.RUSH_TEMP_FOLDER = originalTemp; + EnvironmentConfiguration.reset(); + fs.rmSync(folder, { recursive: true, force: true }); + } + } + }); +}); diff --git a/libraries/rush-lib/src/logic/test/Telemetry.test.ts b/libraries/rush-lib/src/logic/test/Telemetry.test.ts index 6bff8eab19..3e569a4d56 100644 --- a/libraries/rush-lib/src/logic/test/Telemetry.test.ts +++ b/libraries/rush-lib/src/logic/test/Telemetry.test.ts @@ -19,8 +19,14 @@ class CapturingSink implements IReporterEventSink { } } -interface ITelemetryPrivateMembers extends Omit { - _flushAsyncTasks: Set>; +function createFlushGate(): { promise: Promise; release: () => void; reject: (error: Error) => void } { + let release!: () => void; + let rejectGate!: (error: Error) => void; + const promise: Promise = new Promise((resolve, reject) => { + release = resolve; + rejectGate = reject; + }); + return { promise, release, reject: rejectGate }; } describe(Telemetry.name, () => { @@ -185,12 +191,10 @@ describe(Telemetry.name, () => { terminalProvider: new ConsoleTerminalProvider(), getIsDebugMode: () => false }); - const customFlushTelemetry: jest.Mock = jest.fn(); - rushSession.hooks.flushTelemetry.tap('test', customFlushTelemetry); - const telemetry: ITelemetryPrivateMembers = new Telemetry( - rushConfig, - rushSession - ) as unknown as ITelemetryPrivateMembers; + const gate = createFlushGate(); + const customFlushTelemetry: jest.Mock = jest.fn(() => gate.promise); + rushSession.hooks.flushTelemetry.tapPromise('test', customFlushTelemetry); + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); const logData: ITelemetryData = { name: 'testData1', durationInSeconds: 100, @@ -202,10 +206,16 @@ describe(Telemetry.name, () => { expect(customFlushTelemetry).toHaveBeenCalledTimes(1); expect(customFlushTelemetry.mock.calls[0][0][0]).toEqual(expect.objectContaining(logData)); + let flushed: boolean = false; + const completion: Promise = telemetry.ensureFlushedAsync().then(() => { flushed = true; }); + await Promise.resolve(); + expect(flushed).toBe(false); + gate.release(); + await completion; + expect(flushed).toBe(true); await telemetry.ensureFlushedAsync(); - - // Ensure the tasks get cleaned up - expect(telemetry._flushAsyncTasks.size).toEqual(0); + expect(customFlushTelemetry).toHaveBeenCalledTimes(1); + expect(telemetry.store).toEqual([]); }); it('calls custom flush telemetry twice', async () => { @@ -215,12 +225,13 @@ describe(Telemetry.name, () => { terminalProvider: new ConsoleTerminalProvider(), getIsDebugMode: () => false }); - const customFlushTelemetry: jest.Mock = jest.fn(); - rushSession.hooks.flushTelemetry.tap('test', customFlushTelemetry); - const telemetry: ITelemetryPrivateMembers = new Telemetry( - rushConfig, - rushSession - ) as unknown as ITelemetryPrivateMembers; + const firstGate = createFlushGate(); + const secondGate = createFlushGate(); + const customFlushTelemetry: jest.Mock = jest.fn() + .mockImplementationOnce(() => firstGate.promise) + .mockImplementationOnce(() => secondGate.promise); + rushSession.hooks.flushTelemetry.tapPromise('test', customFlushTelemetry); + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); const logData: ITelemetryData = { name: 'testData1', durationInSeconds: 100, @@ -243,9 +254,37 @@ describe(Telemetry.name, () => { expect(customFlushTelemetry).toHaveBeenCalledTimes(2); expect(customFlushTelemetry.mock.calls[1][0][0]).toEqual(expect.objectContaining(logData2)); + let flushed: boolean = false; + const completion: Promise = telemetry.ensureFlushedAsync().then(() => { flushed = true; }); + firstGate.release(); + await Promise.resolve(); + expect(flushed).toBe(false); + secondGate.release(); + await completion; + expect(flushed).toBe(true); await telemetry.ensureFlushedAsync(); + expect(customFlushTelemetry).toHaveBeenCalledTimes(2); + expect(telemetry.store).toEqual([]); + }); - // Ensure the tasks get cleaned up - expect(telemetry._flushAsyncTasks.size).toEqual(0); + it('reports a pending flush rejection once and releases it before a later public join', async () => { + const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile( + `${__dirname}/telemetry/telemetryEnabled.json` + ); + const rushSession: RushSession = new RushSession({ + terminalProvider: new ConsoleTerminalProvider(), + getIsDebugMode: () => false + }); + const gate = createFlushGate(); + rushSession.hooks.flushTelemetry.tapPromise('rejecting-flush', () => gate.promise); + const telemetry: Telemetry = new Telemetry(rushConfig, rushSession); + telemetry.log({ name: 'failed-flush', durationInSeconds: 1, result: 'Succeeded' }); + telemetry.flush(); + const failure: Error = new Error('telemetry upload failed'); + const rejected: Promise = expect(telemetry.ensureFlushedAsync()).rejects.toBe(failure); + gate.reject(failure); + await rejected; + await expect(telemetry.ensureFlushedAsync()).resolves.toBeUndefined(); + expect(telemetry.store).toEqual([]); }); }); diff --git a/libraries/rush-lib/src/pluginFramework/OperationGraphHooks.ts b/libraries/rush-lib/src/pluginFramework/OperationGraphHooks.ts index e78552903d..d372e354e6 100644 --- a/libraries/rush-lib/src/pluginFramework/OperationGraphHooks.ts +++ b/libraries/rush-lib/src/pluginFramework/OperationGraphHooks.ts @@ -43,7 +43,7 @@ export class OperationGraphHooks { * Hook invoked to decide what work a potential new iteration contains. * Use the `lastExecutedRecords` to determine which operations are new or have had their inputs changed. * Set `enabled` and `shouldRunnerPersist` on the values in `initialRecords` to control which operations - * execute and which runners remain active after the iteration. + * execute and which runners remain active after their operation completes. * * @remarks * This hook is synchronous to guarantee that the `lastExecutedRecords` map remains stable for the @@ -122,6 +122,16 @@ export class OperationGraphHooks { 'onInvalidateOperations' ); + /** + * Releases plugin-held completed-iteration state before idle result deletion. + * The graph rejects active/prepared iterations before invoking this hook. A thrown cleanup error + * prevents result deletion. Only invoked by hosts explicitly using `deleteResults()`. + */ + public readonly beforeDeleteResults: SyncHook<[ReadonlySet]> = new SyncHook( + ['operations'], + 'beforeDeleteResults' + ); + /** * Hook invoked after an iteration has finished and the command is watching for changes. * May be used to display additional relevant data to the user. diff --git a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts index 013cce44e2..d9fd335af0 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginLoader/PluginLoaderBase.ts @@ -53,7 +53,7 @@ export abstract class PluginLoaderBase< protected readonly _terminal: ITerminal; protected _manifestCache: Readonly | undefined; - private _packageVersionCache: string | undefined; + #packageVersionCache: string | undefined; /** * The folder that should be used for resolving the plugin's NPM package. @@ -88,17 +88,17 @@ export abstract class PluginLoaderBase< } public get packageVersion(): string { - if (!this._packageVersionCache) { + if (!this.#packageVersionCache) { const packageJson: IPackageJson = PackageJsonLookup.instance.loadPackageJson( path.join(this.packageFolder, 'package.json') ); if (!packageJson.version) { throw new InternalError(`Rush plugin package "${this.packageName}" does not specify a version.`); } - this._packageVersionCache = packageJson.version; + this.#packageVersionCache = packageJson.version; } - return this._packageVersionCache; + return this.#packageVersionCache; } public getCommandLineConfiguration(): CommandLineConfiguration | undefined { diff --git a/libraries/rush-lib/src/pluginFramework/PluginManager.ts b/libraries/rush-lib/src/pluginFramework/PluginManager.ts index d5fc01eccc..38d3b69cf4 100644 --- a/libraries/rush-lib/src/pluginFramework/PluginManager.ts +++ b/libraries/rush-lib/src/pluginFramework/PluginManager.ts @@ -119,6 +119,10 @@ export class PluginManager { return this.#error; } + public get loadedPluginNames(): ReadonlySet { + return this.#loadedPluginNames; + } + public async updateAsync(): Promise { await this._preparePluginAutoinstallersAsync(this.#autoinstallerPluginLoaders); const preparedAutoinstallerNames: Set = new Set(); diff --git a/libraries/rush-lib/src/pluginFramework/RushSession.ts b/libraries/rush-lib/src/pluginFramework/RushSession.ts index 2018cd5afd..022fd51597 100644 --- a/libraries/rush-lib/src/pluginFramework/RushSession.ts +++ b/libraries/rush-lib/src/pluginFramework/RushSession.ts @@ -11,6 +11,7 @@ import { isReporterEventRequired, resolveExitStatus as resolveRushExitStatus, type IReporterEmitEventInput, + type IReporterChildContext, type IReporterEventEnvelope, type IReporterEventScope, type IReporterEventSink, @@ -62,6 +63,17 @@ export interface IRushSessionReporterOptions { */ readonly operationStreamEnabled?: boolean; + /** + * Enables negotiated reporting for compatible child processes. + * + * @internal + */ + readonly childProcessReporter?: { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + }; + /** * Flushes and closes the frontend-owned reporters before an explicit process exit. * @@ -117,6 +129,18 @@ interface IRushSessionReportingState { readonly observer: IRushSessionShadowEventObserver; } +/** + * Parent-owned state used to correlate a negotiated child reporter stream. + * + * @internal + */ +export interface IRushSessionChildProcessReporter { + readonly parentSessionId: string; + readonly parentRequestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; +} + interface IRushSessionShadowEventObserver { ingest(event: IReporterEmitEventInput, eventId: string): void; buildTelemetryAggregate(): ITelemetryAggregate; @@ -521,6 +545,34 @@ export function _isRushSessionOperationStreamEnabled(rushSession: RushSession): return _getSessionState(rushSession).options.reporter?.operationStreamEnabled === true; } +/** + * Returns the parent-owned child reporter channel for the opt-in operation stream. + * + * @internal + */ +export function _getRushSessionChildProcessReporter( + rushSession: RushSession +): IRushSessionChildProcessReporter | undefined { + const reporterOptions: IRushSessionReporterOptions | undefined = + _getSessionState(rushSession).options.reporter; + const childProcessReporter: + | { + readonly requestId: string; + readonly context: IReporterChildContext; + readonly ingestForeignEnvelope: (envelope: IReporterEventEnvelope) => string; + } + | undefined = reporterOptions?.childProcessReporter; + if (!reporterOptions?.operationStreamEnabled || !childProcessReporter) { + return undefined; + } + return { + parentSessionId: reporterOptions.sessionId, + parentRequestId: childProcessReporter.requestId, + context: childProcessReporter.context, + ingestForeignEnvelope: childProcessReporter.ingestForeignEnvelope + }; +} + /** * Flushes the frontend-owned reporter host, when available. * diff --git a/libraries/rush-lib/src/schemas/rush-project.schema.json b/libraries/rush-lib/src/schemas/rush-project.schema.json index e2dbc47a01..e45130007c 100644 --- a/libraries/rush-lib/src/schemas/rush-project.schema.json +++ b/libraries/rush-lib/src/schemas/rush-project.schema.json @@ -41,6 +41,24 @@ "type": "string", "description": "The name of the operation. This should be a key in the \"package.json\" file's \"scripts\" section." }, + "daemonIpc": { + "type": "object", + "additionalProperties": false, + "required": ["entryPoint"], + "description": "Explicit non-cacheable Node IPC tool, enabled only by daemon.usePersistentIpcRunners for an unsharded incremental daemon build.", + "properties": { + "entryPoint": { + "type": "string", + "minLength": 1, + "description": "Project-root-relative .js/.cjs/.mjs entrypoint in a dedicated implementation subdirectory. Its complete file tree is fingerprinted; imports outside that tree other than Node built-ins are unsupported." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Literal argv tokens, without shell quoting or expansion. Native non-ignored operation parameters follow these tokens." + } + } + }, "outputFolderNames": { "type": "array", diff --git a/libraries/rush-lib/src/schemas/rush.schema.json b/libraries/rush-lib/src/schemas/rush.schema.json index 9593b014ee..f80e981f6d 100644 --- a/libraries/rush-lib/src/schemas/rush.schema.json +++ b/libraries/rush-lib/src/schemas/rush.schema.json @@ -244,6 +244,76 @@ }, "additionalProperties": false }, + "daemon": { + "description": "Opt-in standalone daemon client configuration. Environment overrides config, then defaults. Observation and warm policies use the generation-owned WorkspaceWarmSet host attachment; no automatic build scheduling.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable the separate rush-client/rushx-client binaries. Existing rush/rushx remain unchanged. RUSH_DAEMON overrides." + }, + "idleTimeoutSeconds": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 2147483.647, + "default": 900, + "description": "Idle shutdown timeout. RUSH_DAEMON_IDLE_TIMEOUT_SECONDS overrides." + }, + "autoStart": { + "type": "boolean", + "default": true, + "description": "Auto-start only for opted-in invocations. RUSH_DAEMON_AUTO_START overrides." + }, + "watch": { + "type": "boolean", + "default": false, + "description": "Retain host observation of warm project files between requests; never schedule builds. Root/config guards and request-time input reconciliation remain active when false. RUSH_DAEMON_WATCH overrides." + }, + "usePersistentIpcRunners": { + "type": "boolean", + "default": false, + "description": "Enable explicit operationSettings[].daemonIpc Node launchers for incremental daemon builds. Never enables watch mode or unrequested execution. RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS overrides." + }, + "queueTimeoutSeconds": { + "type": "number", + "minimum": 0, + "maximum": 2147483.647, + "default": 30, + "description": "Maximum admission queue wait. RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS overrides." + }, + "warmIdleTimeoutSeconds": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 2147483.647, + "default": 300, + "description": "Idle resource expiration for an attached warm set. RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS overrides." + }, + "warmMemoryBudgetMB": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "default": 512, + "description": "Best-effort sampled RSS budget, not a hard process/tree memory ceiling. Active/protected work is exempt. RUSH_DAEMON_WARM_MEMORY_BUDGET_MB overrides." + }, + "warmSetMaxProjects": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "default": 20, + "description": "Best-effort retained project limit for an attached warm set. RUSH_DAEMON_WARM_SET_MAX_PROJECTS overrides." + }, + "autoWarmByTelemetry": { + "type": "boolean", + "default": false, + "description": "Prefer measured time-saved * frequency / resident-memory retention over LRU. Never starts unrequested scripts. RUSH_DAEMON_AUTO_WARM_BY_TELEMETRY overrides." + } + } + }, "telemetryEnabled": { "description": "Indicates whether telemetry data should be collected and stored in the Rush temp folder during Rush runs.", "type": "boolean" diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts new file mode 100644 index 0000000000..6bb8740f96 --- /dev/null +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// IMPORTANT: This file is bundled into install-run-rush.js and must use only Node.js built-ins. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILogger, LogPrivacyClassification } from '../utilities/npmrcUtilities'; +import { + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + BOOTSTRAP_PROTOCOL_MAJOR, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + encodeBootstrapEnvelope +} from './generated/BootstrapProtocol'; + +const TRUNCATION_NOTICE_RESERVE_BYTES: number = 512; +const BOOTSTRAP_HANDOFF_FILE_PREFIX: string = 'rush-reporter-bootstrap-'; +const BOOTSTRAP_HANDOFF_FILE_SUFFIX: string = '.ndjson'; +const SUPPORTED_REPORTERS: ReadonlySet = new Set([ + 'default', + 'ai', + 'json', + 'plaintext', + 'file', + 'legacy' +]); +const SUPPORTED_LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +type BootstrapStream = 'stdout' | 'stderr'; + +interface IBootstrapEventInput { + readonly type: string; + readonly privacy: 'public' | 'local-sensitive'; + readonly payload: unknown; +} + +interface IBufferedBootstrapEntry { + readonly line: string; + readonly bytes: number; + readonly required: boolean; + readonly fallbackWrite?: IFallbackWrite; +} + +interface IFallbackWrite { + readonly stream: BootstrapStream; + readonly text: string; +} + +export interface IInstallRunRushBootstrapOptions { + readonly argv: readonly string[]; + readonly env: Record; + readonly rushJsonFolder: string; + readonly rushVersion: string; + readonly bootstrapVersion: string; + readonly commandName: 'rush' | 'rush-pnpm' | 'rushx'; + readonly quiet: boolean; + readonly stdout?: (text: string) => void; + readonly stderr?: (text: string) => void; + readonly handoffDirectory?: string; + readonly maxBytes?: number; + readonly now?: () => string; + readonly randomUUID?: () => string; +} + +export interface IInstallRunRushBootstrap { + readonly enabled: boolean; + readonly logger: ILogger; + readonly externalOutputCaptureMaxBytes: number | undefined; + readonly externalOutputHandler: + | ((stream: BootstrapStream, text: string, wasRendered: boolean) => void) + | undefined; + readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined; + readonly externalOutputOverflowHandler: (() => void) | undefined; + readonly prepareToRun: (() => void) | undefined; +} + +function readFlagValues(argv: readonly string[], flag: string, strict: boolean): string[] { + const result: string[] = []; + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + if (argument === '--') { + break; + } + let value: string | undefined; + if (argument.startsWith(prefix)) { + value = argument.slice(prefix.length); + } else if (argument === flag) { + value = argv[index + 1]; + if (!value || value.startsWith('-')) { + if (strict) { + throw new Error(`${flag} requires a value.`); + } + continue; + } + index++; + } + + if (value !== undefined) { + if (!value) { + if (strict) { + throw new Error(`${flag} requires a value.`); + } + continue; + } + if (strict && result.length > 0) { + throw new Error(`${flag} may be specified only once.`); + } + result.push(value); + } + } + return result; +} + +function repositoryUsesRushReporter(rushJsonFolder: string): boolean { + const experimentsPath: string = path.join(rushJsonFolder, 'common', 'config', 'rush', 'experiments.json'); + let contents: string; + try { + contents = fs.readFileSync(experimentsPath, 'utf8'); + } catch (error) { + const code: unknown = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return false; + } + throw error; + } + + const matches: RegExpMatchArray[] = [ + ...stripJsonComments(contents).matchAll(/"useRushReporter"\s*:\s*(true|false)/g) + ]; + return matches.length > 0 && matches[matches.length - 1][1] === 'true'; +} + +function stripJsonComments(text: string): string { + let result: string = ''; + let inString: boolean = false; + let escaped: boolean = false; + let lineComment: boolean = false; + let blockComment: boolean = false; + + for (let index: number = 0; index < text.length; index++) { + const character: string = text[index]; + const nextCharacter: string | undefined = text[index + 1]; + if (lineComment) { + if (character === '\n' || character === '\r') { + lineComment = false; + result += character; + } + continue; + } + if (blockComment) { + if (character === '*' && nextCharacter === '/') { + blockComment = false; + index++; + } else if (character === '\n' || character === '\r') { + result += character; + } + continue; + } + if (inString) { + result += character; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (character === '/' && nextCharacter === '/') { + lineComment = true; + index++; + } else if (character === '/' && nextCharacter === '*') { + blockComment = true; + index++; + } else { + result += character; + } + } + return result; +} + +interface IParsedVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[] | undefined; +} + +function parseVersion(version: string): IParsedVersion | undefined { + const match: RegExpMatchArray | null = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split('.') + }; +} + +function comparePrerelease( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): number { + if (!left) { + return right ? 1 : 0; + } + if (!right) { + return -1; + } + const length: number = Math.max(left.length, right.length); + for (let index: number = 0; index < length; index++) { + const leftPart: string | undefined = left[index]; + const rightPart: string | undefined = right[index]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + const leftNumeric: boolean = /^\d+$/.test(leftPart); + const rightNumeric: boolean = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) { + return Number(leftPart) - Number(rightPart); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function supportsBootstrapHandoff(rushVersion: string, bootstrapVersion: string): boolean { + const rush: IParsedVersion | undefined = parseVersion(rushVersion); + const bootstrap: IParsedVersion | undefined = parseVersion(bootstrapVersion); + if (!rush || !bootstrap) { + return false; + } + for (let index: number = 0; index < rush.core.length; index++) { + if (rush.core[index] !== bootstrap.core[index]) { + return rush.core[index] > bootstrap.core[index]; + } + } + return comparePrerelease(rush.prerelease, bootstrap.prerelease) >= 0; +} + +function* chunkUtf8Text(text: string, maxChunkBytes: number): Iterable { + let chunkStart: number = 0; + let chunkBytes: number = 0; + let offset: number = 0; + + while (offset < text.length) { + const codePoint: number = text.codePointAt(offset)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (chunkBytes > 0 && chunkBytes + codePointBytes > maxChunkBytes) { + yield text.slice(chunkStart, offset); + chunkStart = offset; + chunkBytes = 0; + } + chunkBytes += codePointBytes; + offset += codeUnits; + } + + if (chunkStart < text.length) { + yield text.slice(chunkStart); + } +} + +class InstallRunRushBootstrap implements IInstallRunRushBootstrap { + public readonly enabled: boolean = true; + public readonly logger: ILogger; + public readonly externalOutputCaptureMaxBytes: number; + public readonly externalOutputHandler: ( + stream: BootstrapStream, + text: string, + wasRendered: boolean + ) => void; + public readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }>; + public readonly externalOutputOverflowHandler: () => void; + public readonly prepareToRun: () => void; + + private readonly _entries: IBufferedBootstrapEntry[]; + private readonly _env: Record; + private readonly _stdout: (text: string) => void; + private readonly _stderr: (text: string) => void; + private readonly _handoffDirectory: string; + private readonly _maxBytes: number; + private readonly _now: () => string; + private readonly _randomUUID: () => string; + private readonly _sessionId: string; + private readonly _sourceVersion: string; + private readonly _entryLimit: number; + private readonly _fallbackStdoutStream: BootstrapStream; + private _usedBytes: number; + private _nextSequence: number; + private _nextEventNumber: number; + private _droppedReplaceable: number; + private _droppedRequired: number; + private _failureFlushed: boolean; + + public constructor(options: IInstallRunRushBootstrapOptions, liveStdout: boolean) { + this._entries = []; + this._env = options.env; + this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + this._stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); + this._maxBytes = options.maxBytes ?? BOOTSTRAP_BUFFER_MAX_BYTES; + this._now = options.now ?? (() => new Date().toISOString()); + this._randomUUID = options.randomUUID ?? (() => crypto.randomUUID()); + this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; + this._sourceVersion = options.bootstrapVersion; + this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + this._fallbackStdoutStream = liveStdout ? 'stdout' : 'stderr'; + if (this._entryLimit <= 0) { + throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); + } + this._usedBytes = 0; + this._nextSequence = 1; + this._nextEventNumber = 1; + this._droppedReplaceable = 0; + this._droppedRequired = 0; + this._failureFlushed = false; + this.externalOutputCaptureMaxBytes = this._maxBytes; + this.externalOutputLiveStreams = { stdout: liveStdout, stderr: true }; + this._addEvent({ + type: 'sessionStarted', + privacy: 'public', + payload: { rushVersion: options.rushVersion } + }); + + this.logger = { + info: (text: string, privacy: LogPrivacyClassification = 'public') => { + this._addEvent( + { + type: 'activityChanged', + privacy, + payload: { kind: 'bootstrap', text } + }, + { stream: this._fallbackStdoutStream, text: `${text}\n` } + ); + }, + error: (text: string) => { + const droppedRequiredBefore: number = this._droppedRequired; + this._addExternalOutput('stderr', `${text}\n`, false); + this._flushFailureOutput(); + if (this._droppedRequired > droppedRequiredBefore) { + this._stderr(`${text}\n`); + } + }, + warning: (text: string) => { + this._stderr(`${text}\n`); + } + }; + this.externalOutputHandler = (stream: BootstrapStream, text: string, wasRendered: boolean) => { + this._addExternalOutput(stream, text, wasRendered); + }; + this.externalOutputOverflowHandler = () => { + this._droppedRequired++; + }; + this.prepareToRun = () => { + this._writeHandoff(); + }; + } + + private _addEvent(event: IBootstrapEventInput, fallbackWrite?: IFallbackWrite): void { + const required: boolean = event.type !== 'activityChanged'; + const line: string = encodeBootstrapEnvelope({ + eventId: `boot_${this._nextEventNumber++}`, + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: event.privacy, + required, + type: event.type, + payload: event.payload + }); + const bytes: number = Buffer.byteLength(line, 'utf8') + 1; + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + return; + } + + if (!required) { + this._droppedReplaceable++; + return; + } + + for ( + let index: number = 0; + this._usedBytes + bytes > this._entryLimit && index < this._entries.length; + + ) { + const entry: IBufferedBootstrapEntry = this._entries[index]; + if (entry.required) { + index++; + } else { + this._entries.splice(index, 1); + this._usedBytes -= entry.bytes; + this._droppedReplaceable++; + } + } + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + } else { + this._droppedRequired++; + } + } + + private _addExternalOutput(stream: BootstrapStream, text: string, wasRendered: boolean): void { + if (!text) { + return; + } + for (const chunk of chunkUtf8Text(text, BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES)) { + this._addEvent( + { + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } + }, + wasRendered + ? undefined + : { stream: stream === 'stdout' ? this._fallbackStdoutStream : stream, text: chunk } + ); + } + } + + private _flushFailureOutput(): void { + if (this._failureFlushed) { + return; + } + this._failureFlushed = true; + for (const entry of this._entries) { + const write: IFallbackWrite | undefined = entry.fallbackWrite; + if (write) { + (write.stream === 'stdout' ? this._stdout : this._stderr)(write.text); + } + } + } + + private _writeHandoff(): void { + const serialized: string = this._serializeEvents(); + const nonce: string = this._randomUUID(); + const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${process.pid}-${nonce}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`; + const handoffPath: string = path.join(this._handoffDirectory, fileName); + fs.mkdirSync(this._handoffDirectory, { recursive: true }); + fs.writeFileSync(handoffPath, `${JSON.stringify({ kind: 'bootstrapHandoff', nonce })}\n${serialized}`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }); + if (process.platform !== 'win32') { + fs.chmodSync(handoffPath, 0o600); + } + this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + } + + private _serializeEvents(): string { + const truncated: boolean = this._droppedReplaceable + this._droppedRequired > 0; + if (truncated) { + const notice: string = encodeBootstrapEnvelope({ + eventId: 'boot_bufferTruncated', + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: 'public', + required: true, + type: 'extension', + payload: { + name: BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + droppedReplaceable: this._droppedReplaceable, + droppedOther: 0, + droppedRequired: this._droppedRequired, + failed: this._droppedRequired > 0 + } + }); + if (Buffer.byteLength(notice, 'utf8') + 1 > TRUNCATION_NOTICE_RESERVE_BYTES) { + throw new Error('The bootstrap truncation notice exceeded its reserved capacity.'); + } + this._entries.push({ + line: notice, + bytes: Buffer.byteLength(notice, 'utf8') + 1, + required: true + }); + } + + if (this._droppedRequired > 0) { + throw new Error( + `The Rush reporter bootstrap buffer exceeded ${this._maxBytes} bytes and could not preserve ` + + `${this._droppedRequired} required event(s).` + ); + } + + return this._entries.length > 0 + ? `${this._entries.map((entry: IBufferedBootstrapEntry) => entry.line).join('\n')}\n` + : ''; + } +} + +function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { + const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + const warning: (text: string) => void = (text: string) => stderr(`${text}\n`); + return { + enabled: false, + // Legacy mode cannot create npm captures because it exposes no external output handler. + // Keep warning routing available so future diagnostic finalization remains stderr-only. + logger: options.quiet + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`), warning } + : { + info: (text: string) => stdout(`${text}\n`), + error: (text: string) => stderr(`${text}\n`), + warning + }, + externalOutputHandler: undefined, + externalOutputCaptureMaxBytes: undefined, + externalOutputLiveStreams: undefined, + externalOutputOverflowHandler: undefined, + prepareToRun: undefined + }; +} + +export function createInstallRunRushBootstrap( + options: IInstallRunRushBootstrapOptions +): IInstallRunRushBootstrap { + if (BOOTSTRAP_PROTOCOL_MAJOR < 1) { + throw new Error('The generated Rush reporter bootstrap protocol is invalid.'); + } + delete options.env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete options.env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + + if (options.commandName !== 'rush') { + return createLegacyBootstrap(options); + } + + const environmentReporter: string | undefined = options.env.RUSH_REPORTER?.trim().toLowerCase(); + if (environmentReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const repositoryOptIn: boolean = repositoryUsesRushReporter(options.rushJsonFolder); + const reporterControlsOwned: boolean = + repositoryOptIn || + readFlagValues(options.argv, '--reporter', false).some((value: string) => SUPPORTED_REPORTERS.has(value)); + if (!reporterControlsOwned) { + return createLegacyBootstrap(options); + } + + const explicitReporter: string | undefined = readFlagValues(options.argv, '--reporter', true)[0]; + if (explicitReporter !== undefined && !SUPPORTED_REPORTERS.has(explicitReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(explicitReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + if (explicitReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const logLevelProbe: string[] = readFlagValues(options.argv, '--log-level', false); + const logLevelOwned: boolean = + explicitReporter !== undefined || + (logLevelProbe.length > 0 && logLevelProbe.every((value: string) => SUPPORTED_LOG_LEVELS.has(value))); + const explicitLogLevel: string | undefined = logLevelOwned + ? readFlagValues(options.argv, '--log-level', true)[0] + : undefined; + if (explicitLogLevel !== undefined && !SUPPORTED_LOG_LEVELS.has(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + + const explicitOptIn: boolean = explicitReporter !== undefined; + + if (!supportsBootstrapHandoff(options.rushVersion, options.bootstrapVersion)) { + if (explicitOptIn) { + throw new Error( + `Rush version ${options.rushVersion} does not support the reporter bootstrap requested by ` + + `${JSON.stringify(`--reporter=${explicitReporter}`)}. Update the repository Rush version or ` + + 'use --reporter=legacy.' + ); + } + return createLegacyBootstrap(options); + } + + const separatorIndex: number = options.argv.indexOf('--'); + const commandArgs: readonly string[] = + separatorIndex < 0 ? options.argv : options.argv.slice(0, separatorIndex); + const stdoutReserved: boolean = + explicitReporter === 'json' || + explicitReporter === 'ai' || + explicitReporter === 'file' || + commandArgs.includes('--json'); + return new InstallRunRushBootstrap(options, !stdoutReserved); +} diff --git a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts index 97bfe28545..82c6a58867 100644 --- a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts +++ b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts @@ -70,3 +70,48 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string payload: input.payload === undefined ? {} : input.payload }); } + +/** + * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). + * + * @beta + */ +export const BOOTSTRAP_BUFFER_MAX_BYTES: number = 1024 * 1024; + +/** + * The maximum size of a single raw external-output chunk, in bytes (64 KiB). + * + * @beta + */ +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number = 64 * 1024; + +/** + * The private environment variable used to hand the bootstrap NDJSON file path + * to the installed frontend. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF' = + '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + +/** + * The private environment variable carrying the one-time nonce that must match + * the handoff file's header line. + * + * @remarks + * The nonce proves the handoff file was written by the same bootstrap process + * that set the environment variable: a stale or foreign handoff file (same + * temp directory, different invocation) is rejected rather than replayed. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE' = + '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + +/** + * The namespaced extension event name that describes bootstrap buffer truncation. + * + * @beta + */ +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = + 'rush.reporter.buffer-truncated'; diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index 1bb7b29d0c..3416cd583c 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -7,14 +7,13 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import type { ILogger } from '../utilities/npmrcUtilities'; +import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap } from './InstallRunRushBootstrap'; import { BOOTSTRAP_PROTOCOL_MAJOR, encodeBootstrapEnvelope } from './generated/BootstrapProtocol'; -const { - installAndRun, - findRushJsonFolder, - RUSH_JSON_FILENAME, - runWithErrorAndStatusCode -}: typeof import('./install-run') = __non_webpack_require__('./install-run'); +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME }: typeof import('./install-run') = + __non_webpack_require__('./install-run'); + +declare const RUSH_LIB_VERSION_FOR_BOOTSTRAP: string; const PACKAGE_NAME: string = '@microsoft/rush'; const RUSH_PREVIEW_VERSION: string = 'RUSH_PREVIEW_VERSION'; @@ -28,11 +27,13 @@ function _validateBundledBootstrapProtocol(): void { } } -function _getRushVersion(logger: ILogger): string { +function _getRushVersion(): { readonly version: string; readonly sourceMessage?: string } { const rushPreviewVersion: string | undefined = process.env[RUSH_PREVIEW_VERSION]; if (rushPreviewVersion !== undefined) { - logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); - return rushPreviewVersion; + return { + version: rushPreviewVersion, + sourceMessage: `Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}` + }; } const rushJsonFolder: string = findRushJsonFolder(); @@ -44,7 +45,7 @@ function _getRushVersion(logger: ILogger): string { const rushJsonMatches: string[] = rushJsonContents.match( /\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/ )!; - return rushJsonMatches[1]; + return { version: rushJsonMatches[1] }; } catch (e) { throw new Error( `Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + @@ -54,7 +55,7 @@ function _getRushVersion(logger: ILogger): string { } } -function _getBin(scriptName: string): string { +function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { switch (scriptName.toLowerCase()) { case 'install-run-rush-pnpm.js': return 'rush-pnpm'; @@ -66,8 +67,6 @@ function _getBin(scriptName: string): string { } function _run(): void { - _validateBundledBootstrapProtocol(); - const [ nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, @@ -77,7 +76,7 @@ function _run(): void { // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the // appropriate binary inside the rush package to run const scriptName: string = path.basename(scriptPath); - const bin: string = _getBin(scriptName); + const bin: 'rush' | 'rush-pnpm' | 'rushx' = _getBin(scriptName); if (!nodePath || !scriptPath) { throw new Error('Unexpected exception: could not detect node path or script path'); } @@ -88,7 +87,9 @@ function _run(): void { let quiet: boolean = quietModeEnvValue === '1' || quietModeEnvValue === 'true'; for (const arg of packageBinArgs) { - if (arg === '-q' || arg === '--quiet') { + if (arg === '--') { + break; + } else if (arg === '-q' || arg === '--quiet') { // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress // any normal informational/diagnostic information printed during startup. // @@ -115,23 +116,61 @@ function _run(): void { process.exit(1); } - const logger: ILogger = quiet - ? { info: () => {}, error: console.error } - : { info: console.log, error: console.error }; - - runWithErrorAndStatusCode(logger, () => { - const version: string = _getRushVersion(logger); - logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + let bootstrap: IInstallRunRushBootstrap | undefined; + process.exitCode = 1; + try { + _validateBundledBootstrapProtocol(); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); + bootstrap = createInstallRunRushBootstrap({ + argv: packageBinArgs, + env: process.env, + rushJsonFolder, + rushVersion: rushVersion.version, + bootstrapVersion: RUSH_LIB_VERSION_FOR_BOOTSTRAP, + commandName: bin, + quiet + }); + const logger: ILogger = bootstrap.logger; + if (rushVersion.sourceMessage) { + logger.info(rushVersion.sourceMessage); + } + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${rushVersion.version}`); const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { logger.info( - `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.` + `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' ); } - return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); - }); + process.exitCode = installAndRun( + logger, + PACKAGE_NAME, + rushVersion.version, + bin, + packageBinArgs, + lockFilePath, + { + onExternalOutput: bootstrap.externalOutputHandler, + onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, + externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + externalOutputLiveStreams: bootstrap.externalOutputLiveStreams, + prepareToRun: bootstrap.prepareToRun + } + ); + } catch (error) { + const logger: ILogger = + bootstrap?.logger ?? + (quiet + ? { info: () => {}, error: (text: string) => console.error(text) } + : { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }); + logger.error(`\n\n${String(error)}\n`); + } } _run(); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 7f56856648..47fb3c5756 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -7,6 +7,7 @@ import * as childProcess from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import type { IPackageJson } from '@rushstack/node-core-library'; @@ -20,6 +21,77 @@ const INSTALL_RUN_LOCKFILE_PATH_VARIABLE: 'INSTALL_RUN_LOCKFILE_PATH' = 'INSTALL const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; +let _externalOutputCaptureId: number = 0; +export const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const { StringDecoder } = require('node:string_decoder'); +const [ + command, + argsJson, + capturePath, + useShell, + maxBytesText, + renderStdoutText, + renderStderrText +] = process.argv.slice(1); +const child = childProcess.spawn(command, JSON.parse(argsJson), { + cwd: process.cwd(), + env: process.env, + shell: useShell === '1', + windowsVerbatimArguments: false, + stdio: ['inherit', 'pipe', 'pipe'] +}); +const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }; +const maxBytes = Number(maxBytesText); +let capturedBytes = 0; +let overflowed = false; +const renderedStreams = { stdout: renderStdoutText === '1', stderr: renderStderrText === '1' }; +function capture(stream, text) { + if (!text || overflowed) { + return; + } + const record = JSON.stringify({ stream, text, wasRendered: renderedStreams[stream] }) + '\\n'; + const recordBytes = Buffer.byteLength(record); + if (capturedBytes + recordBytes <= maxBytes) { + fs.appendFileSync(capturePath, record); + capturedBytes += recordBytes; + } else { + overflowed = true; + fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); + } +} +function forwardAndCapture(stream, chunk) { + if (renderedStreams[stream]) { + (stream === 'stdout' ? process.stdout : process.stderr).write(chunk); + } + capture(stream, decoders[stream].write(chunk)); +} +child.stdout.on('data', (chunk) => forwardAndCapture('stdout', chunk)); +child.stderr.on('data', (chunk) => forwardAndCapture('stderr', chunk)); +child.on('error', (error) => { + process.stderr.write(String(error) + '\\n'); + process.exitCode = 1; +}); +child.on('close', (code, signal) => { + capture('stdout', decoders.stdout.end()); + capture('stderr', decoders.stderr.end()); + if (signal) { + process.stderr.write('npm was terminated by signal: ' + signal + '\\n'); + process.exitCode = 1; + } else { + process.exitCode = code === null ? 1 : code; + } +}); +`; + +export interface IInstallAndRunOptions { + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void; + readonly onExternalOutputOverflow?: () => void; + readonly externalOutputCaptureMaxBytes?: number; + readonly externalOutputLiveStreams?: Readonly<{ stdout: boolean; stderr: boolean }>; + readonly prepareToRun?: () => void; +} /** * Parse a package specifier (in the form of name\@version) into name and version parts. @@ -352,22 +424,156 @@ function _installPackage( packageInstallFolder: string, name: string, version: string, - npmCommand: 'install' | 'ci' + npmCommand: 'install' | 'ci', + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void) | undefined, + onExternalOutputOverflow: (() => void) | undefined, + externalOutputCaptureMaxBytes: number | undefined, + externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined ): void { + let capturePath: string | undefined; try { logger.info(`Installing ${name}...`); - _runNpmConfirmSuccess( - [npmCommand], - { - stdio: 'inherit', - cwd: packageInstallFolder, - env: process.env - }, - `npm ${npmCommand}` - ); - logger.info(`Successfully installed ${name}@${version}`); + if (onExternalOutput) { + capturePath = path.join( + packageInstallFolder, + `.install-run-output-${process.pid}-${_externalOutputCaptureId++}.log` + ); + fs.closeSync(fs.openSync(capturePath, 'wx', 0o600)); + } + if (capturePath) { + _runNpmWithCaptureConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + capturePath, + externalOutputCaptureMaxBytes ?? 1024 * 1024, + externalOutputLiveStreams ?? { stdout: true, stderr: true }, + `npm ${npmCommand}` + ); + } else { + _runNpmConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + `npm ${npmCommand}` + ); + } } catch (e) { throw new Error(`Unable to install package: ${e}`); + } finally { + if (capturePath !== undefined) { + finalizeCapturedNpmOutput(capturePath, logger, onExternalOutput!, onExternalOutputOverflow); + } + } + logger.info(`Successfully installed ${name}@${version}`); +} + +function _reportCaptureDamage(logger: ILogger, capturePath: string, detail: string): void { + const message: string = `Warning: npm output capture ${JSON.stringify(capturePath)} ${detail}`; + try { + if (logger.warning) { + logger.warning(message, 'local-sensitive'); + } else { + logger.error(message, 'local-sensitive'); + } + } catch { + try { + process.stderr.write(`${message}\n`); + } catch { + // Capture diagnostics are best-effort and must not change the install result. + } + } +} + +export function finalizeCapturedNpmOutput( + capturePath: string, + logger: ILogger, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, + onExternalOutputOverflow: (() => void) | undefined +): void { + let firstDamageDetail: string | undefined; + let damageCount: number = 0; + try { + _readCapturedNpmOutput(capturePath, onExternalOutput, onExternalOutputOverflow, (detail: string) => { + firstDamageDetail ??= detail; + damageCount++; + }); + } catch (error) { + firstDamageDetail ??= `could not be read: ${String(error)}.`; + damageCount++; + } + if (firstDamageDetail) { + const additionalDamage: string = + damageCount > 1 ? ` ${damageCount - 1} additional capture issue(s) were discarded.` : ''; + _reportCaptureDamage(logger, capturePath, `${firstDamageDetail}${additionalDamage}`); + } + + try { + _deleteFile(capturePath); + } catch (error) { + _reportCaptureDamage(logger, capturePath, `could not be deleted: ${String(error)}.`); + } +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, + onExternalOutputOverflow: (() => void) | undefined, + onCaptureDamage: (detail: string) => void +): void { + const fileDescriptor: number = fs.openSync(capturePath, 'r'); + const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); + const decoder: StringDecoder = new StringDecoder('utf8'); + let pending: string = ''; + try { + for (;;) { + const bytesRead: number = fs.readSync(fileDescriptor, buffer, 0, buffer.length, null); + if (bytesRead === 0) { + break; + } + pending += decoder.write(buffer.subarray(0, bytesRead)); + let newlineIndex: number; + while ((newlineIndex = pending.indexOf('\n')) >= 0) { + const line: string = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (line) { + let record: { + stream?: unknown; + text?: unknown; + wasRendered?: unknown; + overflow?: unknown; + }; + try { + record = JSON.parse(line); + } catch (error) { + onCaptureDamage(`contains a corrupt record that was discarded: ${String(error)}.`); + continue; + } + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else if ( + (record.stream === 'stdout' || record.stream === 'stderr') && + typeof record.text === 'string' + ) { + onExternalOutput(record.stream, record.text, record.wasRendered === true); + } else { + onCaptureDamage('contains an invalid record that was discarded.'); + } + } + } + } + pending += decoder.end(); + if (pending.trim()) { + onCaptureDamage('ended with a partial record that was discarded.'); + } + } finally { + fs.closeSync(fileDescriptor); } } @@ -417,7 +623,44 @@ function _runNpmConfirmSuccess( } else { result = childProcess.spawnSync(command, args, options); } + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} + +function _runNpmWithCaptureConfirmSuccess( + args: string[], + options: childProcess.SpawnSyncOptions, + capturePath: string, + captureMaxBytes: number, + liveStreams: Readonly<{ stdout: boolean; stderr: boolean }>, + commandNameForLogging: string +): childProcess.SpawnSyncReturns { + const npmPath: string = getNpmPath(); + const command: string = IS_WINDOWS ? _buildShellCommand(npmPath, args) : npmPath; + const commandArgs: string[] = IS_WINDOWS ? [] : args; + const result: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + command, + JSON.stringify(commandArgs), + capturePath, + IS_WINDOWS ? '1' : '0', + String(captureMaxBytes), + liveStreams.stdout ? '1' : '0', + liveStreams.stderr ? '1' : '0' + ], + options + ); + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} +function _throwIfSpawnFailed( + result: childProcess.SpawnSyncReturns, + commandNameForLogging: string +): void { if (result.status !== 0) { if (!result.status) { // Is status null or undefined? @@ -432,8 +675,6 @@ function _runNpmConfirmSuccess( throw new Error(`"${commandNameForLogging}" returned error code ${result.status}`); } } - - return result; } export function installAndRun( @@ -442,7 +683,8 @@ export function installAndRun( packageVersion: string, packageBinName: string, packageBinArgs: string[], - lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE] + lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE], + options: IInstallAndRunOptions = {} ): number { const rushJsonFolder: string = findRushJsonFolder(); const rushCommonFolder: string = path.join(rushJsonFolder, 'common'); @@ -470,13 +712,27 @@ export function installAndRun( _createPackageJson(packageInstallFolder, packageName, packageVersion); const installCommand: 'install' | 'ci' = lockFilePath ? 'ci' : 'install'; - _installPackage(logger, packageInstallFolder, packageName, packageVersion, installCommand); + _installPackage( + logger, + packageInstallFolder, + packageName, + packageVersion, + installCommand, + options.onExternalOutput, + options.onExternalOutputOverflow, + options.externalOutputCaptureMaxBytes, + options.externalOutputLiveStreams + ); _writeFlagFile(packageInstallFolder); } - const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const invocation: string = options.onExternalOutput + ? packageBinName + : `${packageBinName} ${packageBinArgs.join(' ')}`; + const statusMessage: string = `Invoking "${invocation}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + options.prepareToRun?.(); const binPath: string = _getBinPath(packageInstallFolder, packageBinName); const binFolderPath: string = path.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); @@ -553,7 +809,10 @@ function _run(): void { process.exit(1); } - const logger: ILogger = { info: console.log, error: console.error }; + const logger: ILogger = { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }; runWithErrorAndStatusCode(logger, () => { const rushJsonFolder: string = findRushJsonFolder(); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts new file mode 100644 index 0000000000..3209d7e110 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -0,0 +1,518 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { syncNpmrc } from '../../utilities/npmrcUtilities'; +import { + createInstallRunRushBootstrap, + type IInstallRunRushBootstrap, + type IInstallRunRushBootstrapOptions +} from '../InstallRunRushBootstrap'; +import { + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR +} from '../generated/BootstrapProtocol'; +import { finalizeCapturedNpmOutput } from '../install-run'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +function makeOptions( + directory: string, + overrides: Partial = {} +): { + readonly options: IInstallRunRushBootstrapOptions; + readonly env: Record; + readonly stdout: string[]; + readonly stderr: string[]; +} { + const env: Record = {}; + const stdout: string[] = []; + const stderr: string[] = []; + return { + env, + stdout, + stderr, + options: { + argv: ['build'], + env, + rushJsonFolder: directory, + rushVersion: '5.178.1', + bootstrapVersion: '5.178.1', + commandName: 'rush', + quiet: false, + stdout: (text: string) => stdout.push(text), + stderr: (text: string) => stderr.push(text), + handoffDirectory: directory, + now: () => '2026-08-28T00:00:00.000Z', + randomUUID: (() => { + let index: number = 0; + return () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}`; + })(), + ...overrides + } + }; +} + +function readHandoff(env: Record): { + readonly path: string; + readonly records: Record[]; +} { + const handoffPath: string | undefined = env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + if (!handoffPath) { + throw new Error('Expected a bootstrap handoff path.'); + } + const records: Record[] = fs + .readFileSync(handoffPath, 'utf8') + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + return { path: handoffPath, records }; +} + +describe(createInstallRunRushBootstrap.name, () => { + it.each([ + { argv: ['custom', '--reporter'] }, + { argv: ['custom', '--reporter='] }, + { argv: ['custom', '--reporter=custom'] }, + { argv: ['custom', '--reporter', '--log-level=custom'] }, + { argv: ['custom', '--log-level'] }, + { argv: ['custom', '--log-level=custom'] }, + { argv: ['custom', '--reporter=one', '--reporter=two'] }, + { argv: ['custom', '--reporter', '--', '--reporter=json'] }, + { argv: ['custom', '--reporter=legacy', '--log-level'] } + ])('preserves command-owned reporter controls without opt-in: $argv', async ({ argv }) => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { argv }); + expect(createInstallRunRushBootstrap(options).enabled).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + }); + }); + + it.each([ + { argv: ['build', '--reporter=json', '--reporter=custom'] }, + { argv: ['build', '--reporter=custom', '--reporter=json'] }, + { argv: ['build', '--reporter=json', '--log-level'] }, + { argv: ['build', '--reporter=json', '--log-level=custom'] } + ])('validates controls once reporter ownership is established: $argv', async ({ argv }) => { + await withTempDir(async (directory: string) => { + expect(() => createInstallRunRushBootstrap(makeOptions(directory, { argv }).options)).toThrow(); + }); + }); + + it.each([{ argv: ['custom', '--log-level'] }, { argv: ['custom', '--log-level=custom'] }])( + 'preserves command-owned log-level flags under repository opt-in: $argv', + async ({ argv }) => { + await withTempDir(async (directory: string) => { + const configFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(configFolder, { recursive: true }); + await fs.promises.writeFile(path.join(configFolder, 'experiments.json'), '{"useRushReporter":true}'); + expect(createInstallRunRushBootstrap(makeOptions(directory, { argv }).options).enabled).toBe(true); + }); + } + ); + + it.each([ + { argv: ['build', '--reporter=file'], repositoryOptIn: false }, + { argv: ['list', '--json', '--reporter=file'], repositoryOptIn: false }, + { argv: ['list', '--json'], repositoryOptIn: true } + ])('keeps bootstrap output out of exclusive stdout: $argv', async ({ argv, repositoryOptIn }) => { + await withTempDir(async (directory: string) => { + if (repositoryOptIn) { + const configFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(configFolder, { recursive: true }); + await fs.promises.writeFile(path.join(configFolder, 'experiments.json'), '{"useRushReporter":true}'); + } + const { options, env, stdout, stderr } = makeOptions(directory, { argv }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('installing Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm stdout\n', false); + bootstrap.prepareToRun?.(); + + expect(bootstrap.externalOutputLiveStreams).toEqual({ stdout: false, stderr: true }); + expect(readHandoff(env).records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'npm stdout\n' } + }) + ]) + ); + bootstrap.logger.error('bootstrap failed'); + expect(stdout).toEqual([]); + expect(stderr.join('')).toBe('installing Rush\nnpm stdout\nbootstrap failed\n'); + }); + }); + + it('preserves direct legacy bootstrap output without an opt-in', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory); + env.RUSH_REPORTER = 'unsupported-automatic-value'; + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('legacy startup'); + bootstrap.prepareToRun?.(); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['legacy startup\n']); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + }); + }); + + it.each([false, true])( + 'routes future capture warnings to stderr in legacy mode when quiet is %s', + async (quiet: boolean) => { + await withTempDir(async (directory: string) => { + const { options, env, stdout, stderr } = makeOptions(directory, { quiet }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + const capturePath: string = path.join(directory, 'legacy-partial-capture.ndjson'); + await fs.promises.writeFile(capturePath, '{"stream":"stdout","text":"partial'); + + expect(bootstrap.enabled).toBe(false); + expect(bootstrap.externalOutputHandler).toBeUndefined(); + expect(() => + finalizeCapturedNpmOutput(capturePath, bootstrap.logger, () => {}, undefined) + ).not.toThrow(); + + expect(stderr).toHaveLength(1); + expect(stderr[0]).toContain('ended with a partial record that was discarded'); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(fs.existsSync(capturePath)).toBe(false); + }); + } + ); + + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory, { + argv: ['build', '--reporter=json', '--log-level=debug'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('resolving Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n', false); + bootstrap.logger.info('invoking Rush'); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + expect(bootstrap.enabled).toBe(true); + expect(bootstrap.externalOutputLiveStreams).toEqual({ stdout: false, stderr: true }); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( + (handoff.records[0] as { nonce?: string }).nonce + ); + expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ + 'sessionStarted', + 'activityChanged', + 'externalOutput', + 'activityChanged' + ]); + expect((handoff.records[3].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + expect((handoff.records[3].payload as { wasRendered?: boolean }).wasRendered).toBeUndefined(); + if (process.platform !== 'win32') { + expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); + } + }); + }); + + it('does not publish the working directory or full argv as public bootstrap data', async () => { + await withTempDir(async (directory: string) => { + const secretArgument: string = '--token=bootstrap-secret-value'; + const secretCwd: string = path.join(directory, 'secret-worktree-name'); + const cwdSpy: jest.SpyInstance = jest.spyOn(process, 'cwd').mockReturnValue(secretCwd); + try { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json', secretArgument] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const serialized: string = fs.readFileSync(handoff.path, 'utf8'); + expect(serialized).not.toContain(secretArgument); + expect(serialized).not.toContain(secretCwd); + expect(handoff.records[1]).toMatchObject({ + privacy: 'public', + type: 'sessionStarted', + payload: { rushVersion: '5.178.1' } + }); + expect(handoff.records).toHaveLength(2); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + + it('classifies path-bearing installation activity as local-sensitive', async () => { + await withTempDir(async (directory: string) => { + const sourceFolder: string = path.join(directory, 'sentinel-source-npmrc'); + const targetFolder: string = path.join(directory, 'sentinel-target-npmrc'); + const lockFilePath: string = path.join(directory, 'sentinel-lockfile', 'package-lock.json'); + await fs.promises.mkdir(sourceFolder, { recursive: true }); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('Installing @microsoft/rush...'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.logger.info( + `Found INSTALL_RUN_RUSH_LOCKFILE_PATH="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' + ); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.prepareToRun?.(); + + const events: Record[] = readHandoff(env).records.slice(1); + const activityEvents: Record[] = events.filter( + (event: Record) => event.type === 'activityChanged' + ); + expect( + activityEvents.find( + (event: Record) => + (event.payload as { text?: string }).text === 'Installing @microsoft/rush...' + ) + ).toMatchObject({ privacy: 'public' }); + + for (const sentinelPath of [sourceFolder, targetFolder, lockFilePath]) { + const matchingEvents: Record[] = activityEvents.filter( + (event: Record) => + (event.payload as { text?: string }).text?.includes(sentinelPath) === true + ); + expect(matchingEvents.length).toBeGreaterThan(0); + expect( + matchingEvents.every((event: Record) => event.privacy === 'local-sensitive') + ).toBe(true); + } + expect( + activityEvents + .filter((event: Record) => event.privacy === 'public') + .map((event: Record) => (event.payload as { text?: string }).text) + .join('\n') + ).not.toContain(directory); + }); + }); + + it('stops parsing reporter controls at the pass-through separator', async () => { + await withTempDir(async (directory: string) => { + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(false); + + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(true); + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=plaintext', '--', '--json'] + }).options + ).externalOutputLiveStreams + ).toEqual({ stdout: true, stderr: true }); + }); + }); + + it('uses repository opt-in but safely falls back for an old frontend', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + '{ "useRushReporter": true }\n' + ); + const { options, stdout } = makeOptions(directory, { rushVersion: '5.177.0' }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('old frontend startup'); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['old frontend startup\n']); + }); + }); + + it('ignores a commented hypothetical repository opt-in', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + [ + '{', + ' // "useRushReporter": true,', + ' "exampleUrl": "https://example.test/*not-a-comment*/"', + '}' + ].join('\n') + ); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap( + makeOptions(directory).options + ); + + expect(bootstrap.enabled).toBe(false); + }); + }); + + it('does not treat an older prerelease of the bootstrap version as compatible', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.178.1-dev.1', + bootstrapVersion: '5.178.1-dev.10' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('fails unsupported explicit requests and explicit requests for an old frontend', async () => { + await withTempDir(async (directory: string) => { + const configFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(configFolder, { recursive: true }); + await fs.promises.writeFile(path.join(configFolder, 'experiments.json'), '{"useRushReporter":true}'); + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { argv: ['build', '--reporter=unknown'] }).options + ) + ).toThrow(/Unsupported reporter/); + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.177.0' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('honors the legacy emergency override before validating reporter controls', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=unknown', '--log-level=invalid'] + }); + env.RUSH_REPORTER = ' LEGACY '; + + expect(createInstallRunRushBootstrap(options).enabled).toBe(false); + }); + }); + + it('truncates replaceable startup status with a required marker', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 50; index++) { + bootstrap.logger.info(`status ${index} ${'x'.repeat(80)}`); + } + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const eventRecords: Record[] = handoff.records.slice(1); + const marker: Record = eventRecords[eventRecords.length - 1]; + expect(marker.type).toBe('extension'); + expect((marker.payload as { name: string }).name).toBe(BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME); + expect((marker.payload as { droppedReplaceable: number }).droppedReplaceable).toBeGreaterThan(0); + expect( + Buffer.byteLength(fs.readFileSync(handoff.path, 'utf8').split('\n').slice(1).join('\n'), 'utf8') + ).toBeLessThanOrEqual(1800); + }); + }); + + it('fails instead of dropping required external output', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000), true); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + bootstrap.logger.error('bootstrap failed'); + + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(stderr.join('')).toContain('bootstrap failed'); + expect(stderr.join('')).not.toContain('xxx'); + }); + }); + + it.each(['json', 'ai', 'file'])('keeps %s reporter failure fallback off stdout', async (reporter) => { + await withTempDir(async (directory: string) => { + const { options, stdout, stderr } = makeOptions(directory, { + argv: ['build', `--reporter=${reporter}`] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('installing Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm stdout\n', false); + bootstrap.logger.error('bootstrap failed'); + + expect(stdout).toEqual([]); + expect(stderr.join('')).toBe('installing Rush\nnpm stdout\nbootstrap failed\n'); + }); + }); + + it('keeps capture-damage warnings outside required handoff accounting', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1200 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 20; index++) { + bootstrap.logger.warning?.( + `Warning: npm output capture "${path.join(directory, `capture-${index}.ndjson`)}" was corrupt.`, + 'local-sensitive' + ); + } + + expect(() => bootstrap.prepareToRun?.()).not.toThrow(); + expect(readHandoff(env).records).toHaveLength(2); + expect(stderr).toHaveLength(20); + }); + }); + + it('fails when the npm capture reports overflow before replay', async () => { + await withTempDir(async (directory: string) => { + const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputOverflowHandler?.(); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + }); + }); +}); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts new file mode 100644 index 0000000000..2b3e65e699 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILogger, LogPrivacyClassification } from '../../utilities/npmrcUtilities'; +import { finalizeCapturedNpmOutput, NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-script-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +describe('install-run script integration', () => { + it('tees npm output live to the matching streams while capturing ordered records once', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const childScript: string = [ + "process.stdout.write('stdout 1\\n');", + "setTimeout(() => process.stderr.write('stderr 1\\n'), 25);", + "setTimeout(() => process.stdout.write('stdout 2\\n'), 50);" + ].join(''); + const wrapper: childProcess.ChildProcessWithoutNullStreams = childProcess.spawn( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify(['-e', childScript]), + capturePath, + '0', + String(1024 * 1024), + '1', + '1' + ], + { cwd: directory } + ); + + let stdoutText: string = ''; + let stderrText: string = ''; + let sawLiveOutputBeforeClose: boolean = false; + let closed: boolean = false; + wrapper.stdout.on('data', (chunk: Buffer) => { + stdoutText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + wrapper.stderr.on('data', (chunk: Buffer) => { + stderrText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + wrapper.on('error', reject); + wrapper.on('close', (code: number | null) => { + closed = true; + resolve(code); + }); + }); + + expect(exitCode).toBe(0); + expect(sawLiveOutputBeforeClose).toBe(true); + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + }); + + it('keeps machine-reporter stdout structured while stderr remains live', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const wrapper: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify([ + '-e', + "process.stdout.write('stdout\\n'); setTimeout(() => process.stderr.write('stderr\\n'), 25);" + ]), + capturePath, + '0', + String(1024 * 1024), + '0', + '1' + ], + { cwd: directory, encoding: 'utf8' } + ); + + expect(wrapper.status).toBe(0); + expect(wrapper.stdout).toBe(''); + expect(wrapper.stderr).toBe('stderr\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout\n', wasRendered: false }, + { stream: 'stderr', text: 'stderr\n', wasRendered: true } + ]); + }); + }); + + it('discards a partial capture record without failing a successful install', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'partial-capture.ndjson'); + await fs.promises.writeFile( + capturePath, + `${JSON.stringify({ stream: 'stdout', text: 'complete\n', wasRendered: true })}\n` + + '{"stream":"stderr","text":"partial' + ); + const output: Array<{ stream: 'stdout' | 'stderr'; text: string; wasRendered: boolean }> = []; + const warnings: Array<{ text: string; privacy: LogPrivacyClassification | undefined }> = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string, privacy?: LogPrivacyClassification) => { + warnings.push({ text, privacy }); + } + }; + const overflow: jest.Mock = jest.fn(); + + expect(() => + finalizeCapturedNpmOutput( + capturePath, + logger, + (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => { + output.push({ stream, text, wasRendered }); + }, + overflow + ) + ).not.toThrow(); + + expect(output).toEqual([{ stream: 'stdout', text: 'complete\n', wasRendered: true }]); + expect(overflow).not.toHaveBeenCalled(); + expect(warnings).toEqual([ + { + text: expect.stringContaining('ended with a partial record that was discarded'), + privacy: 'local-sensitive' + } + ]); + expect(fs.existsSync(capturePath)).toBe(false); + }); + }); + + it('never replaces the npm failure when capture finalization is damaged', async () => { + await withTempDir(async (directory: string) => { + const npmError: Error = new Error('npm install failed'); + const warnings: string[] = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string) => warnings.push(text) + }; + let caught: unknown; + try { + try { + throw npmError; + } finally { + finalizeCapturedNpmOutput(directory, logger, () => {}, undefined); + } + } catch (error) { + caught = error; + } + + expect(caught).toBe(npmError); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.join('\n')).toContain('could not be'); + }); + }); + + it('reports missing and invalid rush.json errors without an unhandled stack', async () => { + await withTempDir(async (directory: string) => { + const builtScriptPath: string = path.resolve(__dirname, '../../../dist/scripts/install-run-rush.js'); + const scriptPath: string = path.join(directory, 'install-run-rush.js'); + await fs.promises.copyFile(builtScriptPath, scriptPath); + await fs.promises.copyFile( + path.resolve(__dirname, '../../../dist/scripts/install-run.js'), + path.join(directory, 'install-run.js') + ); + + const missingResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(missingResult.status).toBe(1); + expect(missingResult.stderr).toContain('Error: Unable to find rush.json.'); + expect(missingResult.stderr).not.toMatch(/\n\s+at /); + + await fs.promises.writeFile(path.join(directory, 'rush.json'), '{ "rushVersion": false }\n'); + const invalidResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(invalidResult.status).toBe(1); + expect(invalidResult.stderr).toContain( + 'Error: Unable to determine the required version of Rush from rush.json' + ); + expect(invalidResult.stderr).not.toMatch(/\n\s+at /); + }); + }); +}); diff --git a/libraries/rush-lib/src/utilities/Utilities.ts b/libraries/rush-lib/src/utilities/Utilities.ts index 430a04deca..13334a13ec 100644 --- a/libraries/rush-lib/src/utilities/Utilities.ts +++ b/libraries/rush-lib/src/utilities/Utilities.ts @@ -75,9 +75,9 @@ export interface IInstallPackageInDirectoryOptions { export interface ILifecycleCommandOptions { /** - * The rush configuration, if the command is running in a rush repo. + * Rush configuration paths used to prepare the lifecycle environment, if running in a Rush repo. */ - rushConfiguration: RushConfiguration | undefined; + rushConfiguration: Pick | undefined; /** * Working directory for running the command @@ -113,6 +113,20 @@ export interface ILifecycleCommandOptions { * If true, wire up SubprocessTerminator to the child process. */ connectSubprocessTerminator?: boolean; + + /** + * Additional private environment variables for inherited child channels. + * + * @internal + */ + additionalEnvironment?: IEnvironment; + + /** + * An explicit stdio plan for private inherited child channels. + * + * @internal + */ + stdio?: child_process.StdioOptions; } export interface IEnvironmentPathOptions { @@ -491,11 +505,16 @@ export class Utilities { */ public static executeLifecycleCommandAsync( command: string, - options: ILifecycleCommandOptions + options: ILifecycleCommandOptions, + spawn: ( + command: string, + args: ReadonlyArray, + options: child_process.SpawnOptions + ) => child_process.ChildProcess = child_process.spawn ): child_process.ChildProcess { const child: child_process.ChildProcess = _executeLifecycleCommandInternal( command, - child_process.spawn, + spawn, options ); if (options.connectSubprocessTerminator) { @@ -624,16 +643,21 @@ export class Utilities { } /** @internal */ - public static _convertCommandAndArgsToShell(command: string, isWindows?: boolean): ICommandAndArgs; - public static _convertCommandAndArgsToShell(options: ICommandAndArgs, isWindows?: boolean): ICommandAndArgs; + public static _convertCommandAndArgsToShell( + command: string, isWindows?: boolean, environment?: IEnvironment + ): ICommandAndArgs; + public static _convertCommandAndArgsToShell( + options: ICommandAndArgs, isWindows?: boolean, environment?: IEnvironment + ): ICommandAndArgs; public static _convertCommandAndArgsToShell( options: ICommandAndArgs | string, - isWindows: boolean = IS_WINDOWS + isWindows: boolean = IS_WINDOWS, + environment: IEnvironment = process.env ): ICommandAndArgs { let shellCommand: string; let commandFlags: string[]; if (isWindows) { - shellCommand = process.env.comspec || 'cmd'; + shellCommand = environment.comspec || environment.COMSPEC || 'cmd'; commandFlags = ['/d', '/s', '/c']; } else { shellCommand = 'sh'; @@ -678,7 +702,9 @@ function _executeLifecycleCommandInternal( workingDirectory, handleOutput, ipc, - connectSubprocessTerminator + connectSubprocessTerminator, + additionalEnvironment, + stdio: explicitStdio } = options; const environment: IEnvironment = _createEnvironmentForRushCommand({ initCwd, @@ -691,10 +717,15 @@ function _executeLifecycleCommandInternal( } }); - const stdio: child_process.StdioOptions = handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]; + let stdio: child_process.StdioOptions = + explicitStdio ?? (handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2]); if (ipc) { - stdio.push('ipc'); + if (!Array.isArray(stdio)) { + throw new Error('An IPC lifecycle command requires an array stdio configuration.'); + } + stdio = [...stdio, 'ipc']; } + Object.assign(environment, additionalEnvironment); const spawnOptions: child_process.SpawnOptions = { cwd: workingDirectory, @@ -706,11 +737,16 @@ function _executeLifecycleCommandInternal( Object.assign(spawnOptions, SubprocessTerminator.RECOMMENDED_OPTIONS); } - const { command, args } = Utilities._convertCommandAndArgsToShell(commandAndArgs); + const { command, args } = Utilities._convertCommandAndArgsToShell( + commandAndArgs, IS_WINDOWS, initialEnvironment ? environment : undefined + ); if (IS_WINDOWS) { const shellCommand: string = [command, ...args].join(' '); - return spawnFunction(shellCommand, [], { ...spawnOptions, shell: true }); + return spawnFunction(shellCommand, [], { + ...spawnOptions, + shell: initialEnvironment ? environment.COMSPEC || environment.comspec || 'cmd.exe' : true + }); } else { return spawnFunction(command, args, spawnOptions); } diff --git a/libraries/rush-lib/src/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 6544dd7268..e2d2dd9ba9 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -6,9 +6,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +export type LogPrivacyClassification = 'public' | 'local-sensitive'; + export interface ILogger { - info: (string: string) => void; - error: (string: string) => void; + info: (text: string, privacy?: LogPrivacyClassification) => void; + error: (text: string, privacy?: LogPrivacyClassification) => void; + warning?: (text: string, privacy?: LogPrivacyClassification) => void; } /** @@ -590,8 +593,8 @@ interface INpmrcTrimOptions { function _copyAndTrimNpmrcFile(options: INpmrcTrimOptions): string { const { logger, sourceNpmrcPath, targetNpmrcPath } = options; - logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose - logger.info(` --> "${targetNpmrcPath}"`); + logger.info(`Transforming ${sourceNpmrcPath}`, 'local-sensitive'); // Verbose + logger.info(` --> "${targetNpmrcPath}"`, 'local-sensitive'); const combinedNpmrc: string = _trimNpmrcFile(options); @@ -643,9 +646,9 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { useNpmrcPublish, logger = { // eslint-disable-next-line no-console - info: console.log, + info: (text: string) => console.log(text), // eslint-disable-next-line no-console - error: console.error + error: (text: string) => console.error(text) }, createIfMissing = false } = options; @@ -669,7 +672,7 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { }); } else if (fs.existsSync(targetNpmrcPath)) { // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target - logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + logger.info(`Deleting ${targetNpmrcPath}`, 'local-sensitive'); // Verbose fs.unlinkSync(targetNpmrcPath); } } catch (e) { diff --git a/libraries/rush-lib/src/utilities/resolvePhasedCommandCwd.ts b/libraries/rush-lib/src/utilities/resolvePhasedCommandCwd.ts new file mode 100644 index 0000000000..9b234e5d7a --- /dev/null +++ b/libraries/rush-lib/src/utilities/resolvePhasedCommandCwd.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { realpath, stat } from 'node:fs/promises'; +import * as path from 'node:path'; + +import { Path } from '@rushstack/node-core-library'; + +/** + * Confines a request using physical paths, then expresses it in the loaded configuration's namespace + * so native project selectors can use the same project paths as RushConfiguration. + */ +export async function resolvePhasedCommandCwdAsync(cwd: string, rushJsonFolder: string): Promise { + const [physicalCwd, physicalRoot]: [string, string] = await Promise.all([ + realpath(cwd), + realpath(rushJsonFolder) + ]); + if (!Path.isUnderOrEqual(physicalCwd, physicalRoot)) { + throw new Error('The command working directory must be inside the daemon workspace.'); + } + if (!(await stat(physicalCwd)).isDirectory()) { + throw new Error('The command working directory must resolve to an existing directory.'); + } + return path.resolve(rushJsonFolder, path.relative(physicalRoot, physicalCwd)); +} diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index 0ee88363c8..a3ca0697ef 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,10 +1,43 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + import { FileSystem } from '@rushstack/node-core-library'; + import { getNpmrcEnvironmentVariables, syncNpmrc, trimNpmrcFileLines } from '../npmrcUtilities'; describe('npmrcUtilities', () => { + it('does not print privacy metadata through the default console logger', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'npmrc-logger-test-')); + const sourceFolder: string = path.join(directory, 'source'); + const targetFolder: string = path.join(directory, 'target'); + const logSpy: jest.SpyInstance = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + await fs.promises.mkdir(sourceFolder); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + + expect(logSpy.mock.calls.every((call: unknown[]) => call.length === 1)).toBe(true); + expect(logSpy.mock.calls.flat().join('\n')).not.toContain('local-sensitive'); + } finally { + logSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + describe(trimNpmrcFileLines.name, () => { it('collects project settings with environment variables that PNPM ignores', () => { const environmentVariableSettingNames: Set = new Set(); diff --git a/libraries/rush-lib/src/utilities/test/resolvePhasedCommandCwd.test.ts b/libraries/rush-lib/src/utilities/test/resolvePhasedCommandCwd.test.ts new file mode 100644 index 0000000000..5f4c4afa21 --- /dev/null +++ b/libraries/rush-lib/src/utilities/test/resolvePhasedCommandCwd.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { FileSystem } from '@rushstack/node-core-library'; + +import { resolvePhasedCommandCwdAsync } from '../resolvePhasedCommandCwd'; + +describe(resolvePhasedCommandCwdAsync.name, () => { + let folder: string; + let root: string; + let alias: string; + let project: string; + + beforeEach(async () => { + folder = fs.mkdtempSync(path.join(os.tmpdir(), 'rush-native-cwd-')); + fs.mkdirSync(path.join(folder, 'workspace', 'projects', 'a', 'nested'), { recursive: true }); + root = fs.realpathSync.native(path.join(folder, 'workspace')); + project = path.join(root, 'projects', 'a'); + alias = path.join(folder, 'workspace-alias'); + await FileSystem.createSymbolicLinkJunctionAsync({ linkTargetPath: root, newLinkPath: alias }); + }); + + afterEach(() => { + fs.rmSync(folder, { recursive: true, force: true }); + }); + + it('accepts a workspace alias even when its spelling is outside the canonical root', async () => { + expect(await resolvePhasedCommandCwdAsync(alias, root)).toBe(root); + expect(await resolvePhasedCommandCwdAsync(path.join(alias, 'projects', 'a', 'nested'), root)).toBe( + path.join(project, 'nested') + ); + }); + + it('retains the configuration namespace when RushConfiguration itself was loaded through an alias', async () => { + expect(await resolvePhasedCommandCwdAsync(path.join(project, 'nested'), alias)).toBe( + path.join(alias, 'projects', 'a', 'nested') + ); + }); + + it('rechecks a previously accepted alias after its target changes', async () => { + expect(await resolvePhasedCommandCwdAsync(alias, root)).toBe(root); + fs.unlinkSync(alias); + const outside: string = path.join(folder, 'outside'); + fs.mkdirSync(outside); + await FileSystem.createSymbolicLinkJunctionAsync({ linkTargetPath: outside, newLinkPath: alias }); + await expect(resolvePhasedCommandCwdAsync(alias, root)).rejects.toThrow('inside the daemon workspace'); + }); + + it('resolves an in-workspace alias to the physical project path used for native selection', async () => { + const projectAlias: string = path.join(root, 'project-alias'); + await FileSystem.createSymbolicLinkJunctionAsync({ linkTargetPath: project, newLinkPath: projectAlias }); + expect(await resolvePhasedCommandCwdAsync(path.join(projectAlias, 'nested'), root)).toBe( + path.join(project, 'nested') + ); + }); + + it('rejects a symlink escape even though the request is lexically inside the workspace', async () => { + const outside: string = path.join(folder, 'outside'); + fs.mkdirSync(outside); + const escape: string = path.join(root, 'escape'); + await FileSystem.createSymbolicLinkJunctionAsync({ linkTargetPath: outside, newLinkPath: escape }); + await expect(resolvePhasedCommandCwdAsync(escape, root)).rejects.toThrow('inside the daemon workspace'); + await expect(resolvePhasedCommandCwdAsync(path.join(alias, 'escape'), root)).rejects.toThrow( + 'inside the daemon workspace' + ); + }); + + it('rejects sibling prefixes, missing paths and files rather than falling back to lexical containment', async () => { + const sibling: string = `${root}-sibling`; + fs.mkdirSync(sibling); + await expect(resolvePhasedCommandCwdAsync(sibling, root)).rejects.toThrow('inside the daemon workspace'); + await expect(resolvePhasedCommandCwdAsync(path.join(root, 'missing'), root)).rejects.toMatchObject({ + code: 'ENOENT' + }); + const filename: string = path.join(project, 'file.txt'); + fs.writeFileSync(filename, ''); + await expect(resolvePhasedCommandCwdAsync(filename, root)).rejects.toThrow('existing directory'); + }); + + if (process.platform === 'linux') { + it('does not equate distinct case-sensitive Linux directories', async () => { + const other: string = path.join(folder, 'Workspace'); + fs.mkdirSync(other); + await expect(resolvePhasedCommandCwdAsync(other, root)).rejects.toThrow('inside the daemon workspace'); + }); + } + + if (process.platform === 'win32') { + it('accepts Windows path casing aliases without changing the configuration spelling', async () => { + expect(await resolvePhasedCommandCwdAsync(path.join(project, 'nested').toUpperCase(), root)).toBe( + path.join(project, 'nested') + ); + }); + } +}); diff --git a/libraries/rush-lib/webpack.config.js b/libraries/rush-lib/webpack.config.js index 6952beba9d..fd758205ac 100644 --- a/libraries/rush-lib/webpack.config.js +++ b/libraries/rush-lib/webpack.config.js @@ -116,32 +116,39 @@ module.exports = () => { } } ), - generateConfiguration({ - [PathConstants.pnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.subspacePnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushxScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, - ...SCRIPT_ENTRY_OPTIONS + generateConfiguration( + { + [PathConstants.pnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.subspacePnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushxScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushPnpmScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, + ...SCRIPT_ENTRY_OPTIONS + } }, - [PathConstants.installRunRushPnpmScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, - ...SCRIPT_ENTRY_OPTIONS - } - }) + [ + new webpack.DefinePlugin({ + RUSH_LIB_VERSION_FOR_BOOTSTRAP: JSON.stringify(packageJson.version) + }) + ] + ) ]; return configurations; diff --git a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap index 80fc60cee1..489e2f4841 100644 --- a/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap +++ b/libraries/rush-sdk/src/test/__snapshots__/script.test.ts.snap @@ -38,6 +38,9 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH 'PackageJsonEditor', 'PackageManager', 'PackageManagerOptionsConfigurationBase', + 'PhasedCommandEngine', + 'PhasedCommandEngineBusyError', + 'PhasedCommandEngineConfigurationChangedError', 'PhasedCommandHooks', 'PnpmOptionsConfiguration', 'ProjectChangeAnalyzer', @@ -51,11 +54,14 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH 'RushProjectConfiguration', 'RushSession', 'RushUserConfiguration', + 'RushXCommand', 'Subspace', 'SubspacesConfiguration', 'VersionPolicy', 'VersionPolicyConfiguration', 'VersionPolicyDefinitionName', + 'WorkspaceInputChangeTier', + 'WorkspaceRuntimeFingerprintCache', 'YarnOptionsConfiguration', '_FlagFile', '_OperationBuildCache', @@ -64,8 +70,13 @@ Loaded @microsoft/rush-lib from process.env._RUSH_LIB_PATH '_RushGlobalFolder', '_RushInternals', '_rushSdk_loadInternalModule', + 'captureProjectConfigurationFingerprintAsync', + 'captureWorkspaceInputFingerprintAsync', + 'classifyWorkspaceInputChange', 'createRushDiagnostic', - 'parseReporterExtensionEventName' + 'daemonEnvironmentVariables', + 'parseReporterExtensionEventName', + 'resolveDaemonConfiguration' ]" `; diff --git a/libraries/rush-terminal-renderer/README.md b/libraries/rush-terminal-renderer/README.md index 79013483c7..dc5e4a123a 100644 --- a/libraries/rush-terminal-renderer/README.md +++ b/libraries/rush-terminal-renderer/README.md @@ -4,6 +4,11 @@ The CLI client's **presentation layer** for the Rush daemon (`rushd`): +Operation text decoding preserves UTF-8 fragments independently for each operation +and stdout/stderr stream. Closing an operation flushes any incomplete final text. +Global activity preserves its stderr designation and normalizes existing newlines +without adding an extra blank line. + - **Reporter host** — drives event renderers from the daemon's `0x05` event stream; the host interface mirrors `@rushstack/reporter`'s `IReporter` so its `default`/`ai`/`plaintext` reporters drop in unchanged. diff --git a/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts index aba20f2e7e..a673b309dd 100644 --- a/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts +++ b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { - DaemonVerbosity, - IDaemonEventEnvelope -} from '@rushstack/rush-daemon-protocol'; +import type { DaemonVerbosity, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; import { TerminalChunkKind } from '@rushstack/terminal'; import type { IDaemonRenderer } from './DaemonRenderer'; @@ -23,8 +20,6 @@ function toChunkKind(stream: 'stdout' | 'stderr'): TerminalChunkKind { return stream === 'stderr' ? TerminalChunkKind.Stderr : TerminalChunkKind.Stdout; } -const CHUNK_DECODER: InstanceType = new TextDecoder('utf8', { fatal: false }); - /** * The CLI client's presentation host: routes decoded daemon frames to the * per-operation collator and to the event renderer. @@ -69,10 +64,7 @@ export class DaemonRendererHost { // filtering, without mutating the shared stream. return; } - this.#streams.writeChunk(operationId, { - kind: toChunkKind(stream), - text: CHUNK_DECODER.decode(chunk) - }); + this.#streams.writeBytes(operationId, toChunkKind(stream), chunk); } /** Flushes and closes the renderer. */ diff --git a/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts index 3f1abaac36..7e86bb4743 100644 --- a/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts +++ b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts @@ -6,9 +6,12 @@ import { EOL } from 'node:os'; import type { IDaemonActivityPayload, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; import type { IDaemonRenderer, IDaemonRendererContext } from './DaemonRenderer'; -import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; +import type { DaemonRenderStream, IDaemonRendererTerminal } from './DaemonRendererTerminal'; const RENDERER_NAME: string = 'legacy-collated'; +const NEWLINE: string = '\n'; +const EMPTY: string = ''; +const NEWLINES: RegExp = /\r?\n/g; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -18,6 +21,10 @@ function isActivityPayload(payload: unknown): payload is IDaemonActivityPayload return isRecord(payload) && typeof payload.text === 'string'; } +function activityStream(stream: DaemonRenderStream | undefined): DaemonRenderStream { + return stream === 'stderr' ? 'stderr' : 'stdout'; +} + /** * The default renderer: byte-faithful with the legacy in-process terminal * output for the same event stream. Per-operation collation and headers are @@ -44,14 +51,14 @@ export class LegacyCollatedRenderer implements IDaemonRenderer { if (event.type !== 'activityChanged' || !isActivityPayload(event.payload)) { return; } - this.#writeLine(event.payload.text); + this.#writeLine(event.payload.text, activityStream(event.payload.stream)); } - #writeLine(text: string): void { + #writeLine(text: string, stream: DaemonRenderStream): void { // Emit the client's OS newline, matching the newline normalization the // collated pipeline applies (TextRewriterTransform OsDefault) so global // status lines and collated blocks are consistent on every platform. - this.#terminal?.write(`${text}${EOL}`, 'stdout'); + this.#terminal?.write(`${text.replace(NEWLINES, EOL)}${text.endsWith(NEWLINE) ? EMPTY : EOL}`, stream); } /** {@inheritDoc IDaemonRenderer.flushAsync} */ diff --git a/libraries/rush-terminal-renderer/src/OperationStreamHeader.ts b/libraries/rush-terminal-renderer/src/OperationStreamHeader.ts new file mode 100644 index 0000000000..e3c2372bf3 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/OperationStreamHeader.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonOperationHeaderPayload } from '@rushstack/rush-daemon-protocol'; +import type { CollatedTerminal, CollatedWriter } from '@rushstack/stream-collator'; + +import type { OperationHeaderTracker } from './OperationHeaderTracker'; +import { formatDaemonOperationHeader } from './RendererHeader'; + +const EMPTY_LINE: string = ''; + +export function writeOperationStreamHeader( + writer: CollatedWriter | undefined, + headers: OperationHeaderTracker, + terminal: CollatedTerminal, + quiet: boolean +): void { + if (!writer) return; + const counters: IDaemonOperationHeaderPayload = headers.takeOperationHeader(writer.taskName); + const header: string = formatDaemonOperationHeader( + writer.taskName, counters.completedOperations, counters.totalOperations + ); + terminal.writeStdoutLine(`\n${header}`); + if (!quiet) terminal.writeStdoutLine(EMPTY_LINE); +} diff --git a/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts b/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts index af739d09f5..d082910e94 100644 --- a/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts +++ b/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts @@ -6,10 +6,11 @@ import type { IDaemonOperationHeaderPayload } from '@rushstack/rush-daemon-proto import { CollatedTerminal, StreamCollator } from '@rushstack/stream-collator'; import type { CollatedWriter } from '@rushstack/stream-collator'; import { TextRewriterTransform } from '@rushstack/terminal'; -import type { ITerminalChunk, TerminalWritable } from '@rushstack/terminal'; +import type { ITerminalChunk, TerminalChunkKind, TerminalWritable } from '@rushstack/terminal'; import { OperationHeaderTracker } from './OperationHeaderTracker'; -import { formatDaemonOperationHeader } from './RendererHeader'; +import { writeOperationStreamHeader } from './OperationStreamHeader'; +import { OperationTextDecoder } from './OperationTextDecoder'; /** Options for {@link OperationStreamRegistry}. @beta */ export interface IOperationStreamRegistryOptions { @@ -33,10 +34,9 @@ export class OperationStreamRegistry { readonly #collatedTerminal: CollatedTerminal; readonly #headers: OperationHeaderTracker = new OperationHeaderTracker(); readonly #writers: Map = new Map(); - readonly #quiet: boolean; + readonly #decoder: OperationTextDecoder = new OperationTextDecoder(); public constructor(options: IOperationStreamRegistryOptions) { - this.#quiet = options.quiet; const transform: TextRewriterTransform = new TextRewriterTransform({ destination: options.destination, normalizeNewlines: NewlineKind.OsDefault, @@ -45,7 +45,8 @@ export class OperationStreamRegistry { this.#collatedTerminal = new CollatedTerminal(transform); this.#collator = new StreamCollator({ destination: transform, - onWriterActive: (writer: CollatedWriter | undefined) => this.#onWriterActive(writer) + onWriterActive: (writer: CollatedWriter | undefined) => + writeOperationStreamHeader(writer, this.#headers, this.#collatedTerminal, options.quiet) }); } @@ -59,6 +60,11 @@ export class OperationStreamRegistry { this.#headers.setOperationHeader(header); } + /** Decodes an operation's byte stream without corrupting split UTF-8 characters. */ + public writeBytes(operationId: string, kind: TerminalChunkKind, bytes: Uint8Array): void { + this.writeChunk(operationId, this.#decoder.decode(operationId, kind, bytes)); + } + /** Writes one raw chunk to the operation's collated stream. */ public writeChunk(operationId: string, chunk: ITerminalChunk): void { let writer: CollatedWriter | undefined = this.#writers.get(operationId); @@ -71,27 +77,10 @@ export class OperationStreamRegistry { /** Closes the operation's stream, flushing its collated output. */ public closeOperation(operationId: string): void { + this.#decoder.flush(operationId, (chunk) => this.writeChunk(operationId, chunk)); const writer: CollatedWriter | undefined = this.#writers.get(operationId); if (writer !== undefined && writer.isOpen) { writer.close(); } } - - #onWriterActive(writer: CollatedWriter | undefined): void { - if (writer === undefined) { - return; - } - const counters: IDaemonOperationHeaderPayload = this.#headers.takeOperationHeader( - writer.taskName - ); - const header: string = formatDaemonOperationHeader( - writer.taskName, - counters.completedOperations, - counters.totalOperations - ); - this.#collatedTerminal.writeStdoutLine(`\n${header}`); - if (!this.#quiet) { - this.#collatedTerminal.writeStdoutLine(''); - } - } } diff --git a/libraries/rush-terminal-renderer/src/OperationTextDecoder.ts b/libraries/rush-terminal-renderer/src/OperationTextDecoder.ts new file mode 100644 index 0000000000..5a6b74165b --- /dev/null +++ b/libraries/rush-terminal-renderer/src/OperationTextDecoder.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ITerminalChunk, TerminalChunkKind } from '@rushstack/terminal'; + +type Utf8Decoder = InstanceType; +type StreamDecoders = Map; + +const UTF8: string = 'utf8'; + +/** Keeps fragmented text independent across operation IDs and output streams. */ +export class OperationTextDecoder { + readonly #operations: Map = new Map(); + + public decode(operationId: string, kind: TerminalChunkKind, bytes: Uint8Array): ITerminalChunk { + return { kind, text: this.#getDecoder(operationId, kind).decode(bytes, { stream: true }) }; + } + + public flush(operationId: string, write: (chunk: ITerminalChunk) => void): void { + for (const [kind, decoder] of this.#takeDecoders(operationId)) { + const text: string = decoder.decode(); + if (text) write({ kind, text }); + } + } + + #getDecoder(operationId: string, kind: TerminalChunkKind): Utf8Decoder { + let streams: StreamDecoders | undefined = this.#operations.get(operationId); + if (!streams) { + streams = new Map(); + this.#operations.set(operationId, streams); + } + let decoder: Utf8Decoder | undefined = streams.get(kind); + if (!decoder) { + decoder = new TextDecoder(UTF8); + streams.set(kind, decoder); + } + return decoder; + } + + #takeDecoders(operationId: string): StreamDecoders { + const streams: StreamDecoders = this.#operations.get(operationId) ?? new Map(); + this.#operations.delete(operationId); + return streams; + } +} diff --git a/libraries/rush-terminal-renderer/src/test/OperationTextDecoder.test.ts b/libraries/rush-terminal-renderer/src/test/OperationTextDecoder.test.ts new file mode 100644 index 0000000000..e7f5c81e65 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/OperationTextDecoder.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TerminalChunkKind } from '@rushstack/terminal'; +import type { ITerminalChunk } from '@rushstack/terminal'; + +import { OperationTextDecoder } from '../OperationTextDecoder'; + +const OP_A: string = 'a'; +const OP_B: string = 'b'; +const EMPTY: string = ''; +const EURO: string = '\u20ac'; +const PI: string = '\u03c0'; +const LAMBDA: string = '\u03bb'; +const REPLACEMENT: string = '\ufffd'; +const START: number = 0; +const SPLIT: number = 1; +const ENCODER: InstanceType = new TextEncoder(); + +it('keeps split UTF-8 independent across operations and streams', () => { + const decoder: OperationTextDecoder = new OperationTextDecoder(); + const euro: Uint8Array = ENCODER.encode(EURO); + const pi: Uint8Array = ENCODER.encode(PI); + const lambda: Uint8Array = ENCODER.encode(LAMBDA); + expect(decoder.decode(OP_A, TerminalChunkKind.Stdout, euro.subarray(START, SPLIT)).text).toBe(EMPTY); + expect(decoder.decode(OP_B, TerminalChunkKind.Stdout, lambda.subarray(START, SPLIT)).text).toBe(EMPTY); + expect(decoder.decode(OP_A, TerminalChunkKind.Stderr, pi.subarray(START, SPLIT)).text).toBe(EMPTY); + expect(decoder.decode(OP_A, TerminalChunkKind.Stdout, euro.subarray(SPLIT)).text).toBe(EURO); + expect(decoder.decode(OP_A, TerminalChunkKind.Stderr, pi.subarray(SPLIT)).text).toBe(PI); + expect(decoder.decode(OP_B, TerminalChunkKind.Stdout, lambda.subarray(SPLIT)).text).toBe(LAMBDA); +}); + +it('flushes incomplete text once and releases the operation decoder state', () => { + const decoder: OperationTextDecoder = new OperationTextDecoder(); + const chunks: ITerminalChunk[] = []; + decoder.decode(OP_A, TerminalChunkKind.Stderr, ENCODER.encode(EURO).subarray(START, SPLIT)); + decoder.flush(OP_A, (chunk: ITerminalChunk) => { chunks.push(chunk); }); + decoder.flush(OP_A, (chunk: ITerminalChunk) => { chunks.push(chunk); }); + expect(chunks).toEqual([{ kind: TerminalChunkKind.Stderr, text: REPLACEMENT }]); + expect(decoder.decode(OP_A, TerminalChunkKind.Stdout, ENCODER.encode(EURO)).text).toBe(EURO); +}); diff --git a/rush.json b/rush.json index 985396fe79..dededd5958 100644 --- a/rush.json +++ b/rush.json @@ -1295,6 +1295,18 @@ "reviewCategory": "libraries", "shouldPublish": true }, + { + "packageName": "@rushstack/rush-client-core", + "projectFolder": "libraries/rush-client-core", + "reviewCategory": "libraries", + "shouldPublish": true + }, + { + "packageName": "@rushstack/rush-cli-client", + "projectFolder": "apps/rush-cli-client", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/package-deps-hash", "projectFolder": "libraries/package-deps-hash",