diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 920ae96235..34753e80af 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -14,4 +14,12 @@ 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 reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 8d29eac6af..8ecee361a3 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,163 @@ 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 restoreOldEngineOutput: (() => 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 (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + restoreOldEngineOutput = _observeOldEngineOutput(options, Rush.version); + } + + 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) { + restoreOldEngineOutput?.(); + throw error; } } } +function _observeOldEngineOutput(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.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 044a060d6b..41637f52d4 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -163,7 +163,13 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr eventSink: reporterHost.sink, sessionId }, - reporterCloseAsync + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterStdoutIsMachineReadable: + reporterHost.selection.reporter === 'ai' || + reporterHost.selection.reporter === 'json' || + reporterHost.selection.outputs.some((output) => output.target === 'stdout'), + reporterSelectionReason: reporterHost.selection.reason }; try { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 8ca6b07f9b..e11cee9548 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,8 +23,12 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type IBootstrapReplayResult, type ReporterLogLevel, - type ReporterName + type ReporterName, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '@rushstack/rush-reporter'; export interface IRushReporterOutputStream { @@ -44,6 +48,9 @@ export interface IRushReporterHostOptions { readonly repositoryOptIn?: boolean; readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; } export interface IRushReporterSelection { @@ -58,7 +65,8 @@ export interface IRushReporterSelection { | 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' - | 'pre-major legacy default'; + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } export interface IInitializedRushReporterHost { @@ -66,6 +74,8 @@ export interface IInitializedRushReporterHost { readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -112,6 +122,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; @@ -622,58 +666,137 @@ 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 }); - - if (selection.enabled) { - const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); - if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' - }); + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + columns: 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, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; + + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + const filteredReporter: IReporter = new LogLevelReporter(primaryReporter, selection.logLevel); + host.manager.addReporter( + selection.reporter === 'default' || selection.reporter === 'plaintext' + ? new VisibleBootstrapOutputFilterReporter(filteredReporter) + : filteredReporter, + { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + } + ); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + 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 } + ); + } } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' ) { - host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); - } - - 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 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, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); } - } - await host.manager.initializeAsync(); - let closePromise: Promise | undefined; - return { - host, - sink: host.getSink(), - selection, - closeAsync: (timeoutMs?: number) => { - closePromise ??= host.manager.closeAsync(timeoutMs); - return closePromise; - } - }; + return { + host, + sink, + selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; + } catch (error) { + const [disposal]: PromiseSettledResult[] = await Promise.allSettled([ + host.manager._disposeInitializedReportersAsync() + ]); + 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/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 0000000000..9d33bb842b --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,526 @@ +// 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', () => { + const manager: ReporterManager = new ReporterManager(); + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + 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('keeps old-engine stdout structured for machine reporters', 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 = ''; + 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[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy stdout\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporter: { eventSink: manager, sessionId: 'test-session' }, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterStdoutIsMachineReadable: 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(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 ddf0e04605..0c2fc062d5 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -10,6 +10,7 @@ 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 +24,7 @@ import { launchRushFrontendAsync, type IRushFrontendProcessLifecycle } from '../ import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; import { initializeRushReporterHostAsync, + resolveRushReporterSelection, type IInitializedRushReporterHost, type IRushReporterSelection } from '../RushReporterHost'; @@ -41,6 +43,8 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'legacy', logLevel: 'normal', @@ -69,6 +73,8 @@ async function createEnabledHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -106,6 +112,8 @@ async function createPhaseHangingHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -179,6 +187,85 @@ function emitCommandStarted(sink: IReporterEventSink): void { } describe(launchRushFrontendAsync.name, () => { + it('retains the frontend version in startup envelopes after native-private parent alignment', async () => { + const host: ReporterHost = new ReporterHost({ env: {} }); + await host.manager.initializeAsync(); + const emitSpy: jest.SpiedFunction = jest.spyOn(host.manager, 'emit'); + const markerSpy: jest.SpiedFunction = jest + .spyOn(rushLib._FlagFile.prototype, 'isValidAsync') + .mockResolvedValue(false); + const stopBeforeInstall: Error = new Error('stop before package installation'); + const lockSpy: jest.SpiedFunction = jest + .spyOn(LockFile, 'acquireAsync') + .mockRejectedValue(stopBeforeInstall); + try { + await expect( + new RushVersionSelector('5.178.1-native').ensureRushVersionInstalledAsync('5.177.0', undefined, { + isManaged: false, + reporter: { eventSink: host.getSink(), sessionId: 'startup-session' }, + reporterCloseAsync: () => host.manager.closeAsync(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }) + ).rejects.toBe(stopBeforeInstall); + expect(emitSpy.mock.calls.map(([event]) => event.source)).toEqual([ + { packageName: '@microsoft/rush', packageVersion: '5.178.1-native' }, + { packageName: '@microsoft/rush', packageVersion: '5.178.1-native' } + ]); + } finally { + emitSpy.mockRestore(); + markerSpy.mockRestore(); + lockSpy.mockRestore(); + await host.manager.closeAsync(); + } + }); + + it.each([ + { reporter: 'file', output: undefined, machineStdout: false }, + { reporter: 'json', output: undefined, machineStdout: true }, + { reporter: 'file', output: 'json://stdout', machineStdout: true }, + { reporter: 'file', output: 'file://stdout', machineStdout: true }, + { reporter: 'file', output: 'json://stderr', machineStdout: false }, + { reporter: 'file', output: 'json://./stdout', machineStdout: false } + ])('preserves legacy-engine stdout ownership for $reporter / $output', async (testCase) => { + const originalArgv: string[] = process.argv; + process.argv = ['node', 'rush', 'build', `--reporter=${testCase.reporter}`]; + if (testCase.output) { + process.argv.push(`--output=${testCase.output}`); + } + 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); + } finally { + process.argv = originalArgv; + } + }); + it('creates the authoritative host before invoking the bundled rush-lib and passes only its sink', async () => { const order: string[] = []; let receivedOptions: IRushFrontendLaunchOptions | undefined; diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index d67e4da365..ebbbac3d3b 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -5,7 +5,14 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import type { IReporter, IReporterEventSink } from '@rushstack/rush-reporter'; +import { + BootstrapEventBuffer, + ReporterManager, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -322,6 +329,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', () => { @@ -528,6 +538,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 = ''; @@ -549,6 +626,47 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('preserves the initialization error after cleanup and emergency reporting fail', async () => { + const originalError: Error = new Error('original initialization failure'); + const close: jest.Mock = jest.fn(async () => { + throw new Error('cleanup failure'); + }); + const reporter: IReporter = { + name: 'partially-initialized', + initializeAsync: async () => { + 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); + } finally { + initializeSpy.mockRestore(); + } + }); + it('hands callers a typed sink while leaving no-opt-in output unchanged', async () => { let output: string = ''; const stdout: IRushReporterOutputStream = { @@ -600,4 +718,331 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + 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=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + 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' } + }); + 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: '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 }); + } + }); + + 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; + } + }, + 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 = {}; + 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: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + 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 = {}; + 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: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + 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/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/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/@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/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-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/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2da..c8113a3489 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -330,14 +330,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 @@ -1239,7 +1246,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 @@ -1366,6 +1373,7 @@ export type ReporterExtensionEventName = `${string}.${string}` & { export class ReporterHost { constructor(options?: IReporterHostOptions); cleanAbandonedHandoffFilesAsync(): Promise; + discardBootstrapHandoffAsync(): Promise; getSink(): IReporterEventSink; get manager(): ReporterManager; replayBootstrapHandoffAsync(): Promise; @@ -1387,6 +1395,8 @@ export class ReporterManager implements IReporterEventSink { constructor(options?: IReporterManagerOptions); addReporter(reporter: IReporter, options?: IReporterRegistrationOptions): void; closeAsync(timeoutMs?: number): Promise; + // @internal + _disposeInitializedReportersAsync(): Promise; emit(event: IReporterEmitEventInput): string; flushAsync(timeoutMs?: number): Promise; getPendingEventCount(): number; diff --git a/libraries/reporter/README.md b/libraries/reporter/README.md index b326abb9f1..ac82d7db3c 100644 --- a/libraries/reporter/README.md +++ b/libraries/reporter/README.md @@ -4,6 +4,12 @@ Canonical event protocol, reporter manager, and built-in reporters for Rush. This package is released as a public beta. Exported contracts may change before the stable release. +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. + ## Links - [CHANGELOG.md](https://github.com/microsoft/rushstack/blob/main/libraries/reporter/CHANGELOG.md) - Find out 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/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 0b6acdd378..61edd08cc3 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -11,10 +11,7 @@ import type { ReporterEventType } from '../events/ReporterEventType'; import type { IReporterEventSink } from '../producers/IReporterEventSink'; import { REPORTER_EVENT_TYPES } from '../events/ReporterEventType'; import { ReporterManager } from '../manager/ReporterManager'; -import { - REPORTER_PROTOCOL_VERSION, - isReporterProtocolCompatible -} from '../protocol/ReporterProtocol'; +import { REPORTER_PROTOCOL_VERSION, isReporterProtocolCompatible } from '../protocol/ReporterProtocol'; import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR @@ -33,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}. * @@ -101,7 +109,36 @@ export interface IBootstrapReplayResult { * The reason no events were replayed, when a handoff path was present. * `nonce-mismatch` means the file failed authentication and was rejected. */ - 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'; + + /** + * 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 { @@ -143,6 +180,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. @@ -247,28 +304,29 @@ export class ReporterHost { let skippedEventCount: number = discardedRecordCount; for (const event of events) { const protocolVersion: IReporterProtocolVersion | undefined = getProtocolVersion(event); - if ( - protocolVersion && - !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion) - ) { + if (protocolVersion && !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, 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++; @@ -306,7 +364,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 */ @@ -319,22 +406,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/index.ts b/libraries/reporter/src/index.ts index fcff5af94f..7cba171977 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -138,7 +138,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 { diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 657e88c17a..d5b56a98da 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -89,12 +89,14 @@ interface IReporterEntry { readonly reporter: IReporter; readonly destination: string | undefined; readonly required: boolean; + initializationStarted: boolean; disabled: boolean; failureNotified: boolean; readonly queue: IReporterEventEnvelope[]; draining: boolean; drainPromise: Promise; lifecyclePromise: Promise; + closePromise: Promise | undefined; } /** @@ -121,6 +123,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 +172,14 @@ export class ReporterManager implements IReporterEventSink { reporter, destination, required: options.required ?? false, + initializationStarted: false, disabled: false, failureNotified: false, queue: [], draining: false, drainPromise: Promise.resolve(), - lifecyclePromise: Promise.resolve() + lifecyclePromise: Promise.resolve(), + closePromise: undefined }); } @@ -191,11 +196,53 @@ export class ReporterManager implements IReporterEventSink { protocolVersion: this._protocolVersion, destination: entry.destination }; + 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(): Promise { + this._disposalPromise ??= (async () => { + const results: PromiseSettledResult[] = await Promise.allSettled( + this._entries + .filter((entry: IReporterEntry) => entry.initializationStarted) + .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; + }) + ); + const errors: unknown[] = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result: PromiseRejectedResult) => result.reason); + if (errors.length > 0) { + throw new AggregateError( + errors, + `Reporter initialization cleanup failed: ${errors.map((error: unknown) => String(error)).join('; ')}` + ); + } + })(); + return this._disposalPromise; + } + /** * Publishes an in-process event, assigning its `eventId`, `sequence`, and * `timestamp`, and returns the assigned `eventId`. @@ -266,7 +313,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); @@ -285,7 +332,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); @@ -304,7 +351,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; @@ -314,6 +361,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) { @@ -352,10 +411,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 { @@ -367,8 +430,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; @@ -383,6 +449,17 @@ export class ReporterManager implements IReporterEventSink { } } + 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, error: Error): void { if (entry.required) { if (!this._fatalError) { diff --git a/libraries/reporter/src/reporters/PlaintextReporter.ts b/libraries/reporter/src/reporters/PlaintextReporter.ts index 0508f15bcc..e3cae1183f 100644 --- a/libraries/reporter/src/reporters/PlaintextReporter.ts +++ b/libraries/reporter/src/reporters/PlaintextReporter.ts @@ -217,7 +217,14 @@ export class PlaintextReporter implements IReporter { return; } const operationId: string | undefined = event.scope?.operationId; - const text: string = (event.payload as { text?: string }).text ?? ''; + const payload: { text?: string; wasRendered?: boolean } = event.payload as { + text?: string; + wasRendered?: boolean; + }; + if (payload.wasRendered === true) { + return; + } + const text: string = payload.text ?? ''; const record: IOperationRecord | undefined = operationId !== undefined ? this._operations.get(operationId) : undefined; if (record) { 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/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 582f88a8c2..bac94c010d 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -66,6 +66,148 @@ function makeInput( } describe('ReporterManager ordering and assignment', () => { + 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('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 +250,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'); @@ -152,9 +312,10 @@ describe('ReporterManager ordering and assignment', () => { manager.ingestForeignEnvelope(foreign); await manager.flushAsync(); - const byIdentity: [string, string][] = reporter.reported.map( - (e: IReporterEventEnvelope) => [e.sessionId, e.eventId] - ); + const byIdentity: [string, string][] = reporter.reported.map((e: IReporterEventEnvelope) => [ + e.sessionId, + e.eventId + ]); expect(byIdentity).toEqual([ ['sess', 'evt_1'], ['child', 'evt_1'] diff --git a/libraries/reporter/src/test/PlaintextReporter.test.ts b/libraries/reporter/src/test/PlaintextReporter.test.ts index aa97c7e474..62e43c795b 100644 --- a/libraries/reporter/src/test/PlaintextReporter.test.ts +++ b/libraries/reporter/src/test/PlaintextReporter.test.ts @@ -83,6 +83,15 @@ describe('PlaintextReporter', () => { expect(capture.getOutput()).toMatchSnapshot(); }); + 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( diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 208c458b76..f05beaa481 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); }); }); @@ -267,10 +299,7 @@ describe('ReporterHost handoff replay', () => { const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); await manager.flushAsync(); expect(result).toMatchObject({ replayed: true, eventCount: 2, skippedEventCount: 1 }); - expect(reporter.reported.map((event) => event.type)).toEqual([ - 'sessionStarted', - 'diagnosticEmitted' - ]); + expect(reporter.reported.map((event) => event.type)).toEqual(['sessionStarted', 'diagnosticEmitted']); }); }); @@ -301,7 +330,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 +398,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 +435,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/rush-lib/src/api/Rush.ts b/libraries/rush-lib/src/api/Rush.ts index e75815484d..cdd58fd493 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/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts new file mode 100644 index 0000000000..c1dc3eb09e --- /dev/null +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -0,0 +1,591 @@ +// 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 readSingleFlagValue(argv: readonly string[], flag: string): string | undefined { + let result: string | undefined; + 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('-')) { + throw new Error(`${flag} requires a value.`); + } + index++; + } + + if (value !== undefined) { + if (!value) { + throw new Error(`${flag} requires a value.`); + } + if (result !== undefined) { + throw new Error(`${flag} may be specified only once.`); + } + result = 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 explicitReporter: string | undefined = readSingleFlagValue(options.argv, '--reporter'); + const explicitLogLevel: string | undefined = readSingleFlagValue(options.argv, '--log-level'); + 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 (explicitLogLevel !== undefined && !SUPPORTED_LOG_LEVELS.has(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + + if (explicitReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const repositoryOptIn: boolean = repositoryUsesRushReporter(options.rushJsonFolder); + const explicitOptIn: boolean = explicitReporter !== undefined; + if (!explicitOptIn && !repositoryOptIn) { + return createLegacyBootstrap(options); + } + + 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); + } + + return new InstallRunRushBootstrap(options, explicitReporter !== 'json' && explicitReporter !== 'ai'); +} 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..3dfa051b0a --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -0,0 +1,435 @@ +// 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('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); + }); + }); + + 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) => { + 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('keeps machine-reporter failure fallback off stdout', async () => { + await withTempDir(async (directory: string) => { + const { options, stdout, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + 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/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/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/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;