From b23d32afd6978dd9bd378024b376ebd5e57ad3a1 Mon Sep 17 00:00:00 2001 From: Sean Larkin <3408176+TheLarkInn@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:31:05 +0000 Subject: [PATCH 1/3] [rush-client-core, rush-daemon-transport] Fix the tests that fail in the publish pipeline The publish pipeline sets FORCE_COLOR=1, which GitHub CI does not, and runs on a busy agent. Every publish run since microsoft/rushstack#6046 merged failed in "rush retest": - rush-daemon-transport: a test parsed a PID that console.log had colored. Write it with process.stdout.write. - rush-client-core: findLoggedFatalError kept the colored stack frames of Node's crash report in the error message. Ignore ANSI escape sequences, and run the crash report tests with FORCE_COLOR=0 and FORCE_COLOR=1. - rush-client-core: a test's "sleep 0" could exit before its shell became "sleep 600", and the shell reaped it. Use "sleep 1". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d3da49f-95f3-4291-9026-0b0f8e38675c --- ...nn-fix-publish-tests_2026-10-02-17-30.json | 11 +++ ...nn-fix-publish-tests_2026-10-02-17-30.json | 11 +++ .../rush-client-core/src/DaemonDisconnect.ts | 12 ++- .../src/test/DaemonDisconnect.test.ts | 84 ++++++++++--------- .../connectOrStartDaemonLiveOwner.test.ts | 5 +- .../test/DaemonOperationGroupRecorder.test.ts | 3 +- 6 files changed, 80 insertions(+), 46 deletions(-) create mode 100644 common/changes/@rushstack/rush-client-core/thelarkinn-fix-publish-tests_2026-10-02-17-30.json create mode 100644 common/changes/@rushstack/rush-daemon-transport/thelarkinn-fix-publish-tests_2026-10-02-17-30.json diff --git a/common/changes/@rushstack/rush-client-core/thelarkinn-fix-publish-tests_2026-10-02-17-30.json b/common/changes/@rushstack/rush-client-core/thelarkinn-fix-publish-tests_2026-10-02-17-30.json new file mode 100644 index 0000000000..de32bcc880 --- /dev/null +++ b/common/changes/@rushstack/rush-client-core/thelarkinn-fix-publish-tests_2026-10-02-17-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-client-core", + "comment": "Fix the error reported for a crashed daemon, which included the stack of its crash report when FORCE_COLOR was set.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-client-core", + "email": "3408176+TheLarkInn@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-daemon-transport/thelarkinn-fix-publish-tests_2026-10-02-17-30.json b/common/changes/@rushstack/rush-daemon-transport/thelarkinn-fix-publish-tests_2026-10-02-17-30.json new file mode 100644 index 0000000000..f81b509435 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-transport/thelarkinn-fix-publish-tests_2026-10-02-17-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-transport", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/rush-daemon-transport", + "email": "3408176+TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-client-core/src/DaemonDisconnect.ts b/libraries/rush-client-core/src/DaemonDisconnect.ts index b41e50db8b..d5105ccaec 100644 --- a/libraries/rush-client-core/src/DaemonDisconnect.ts +++ b/libraries/rush-client-core/src/DaemonDisconnect.ts @@ -35,6 +35,8 @@ const SOURCE_ARROW: RegExp = /^\s*\^[\^~]*\s*$/; const STACK_FRAME: RegExp = /^\s+at /; const TRACE_UNCAUGHT_HINT: string = '(Use `node --trace-uncaught'; const CONTROL_CHARACTERS: RegExp = /\p{Cc}/gu; +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_SEQUENCE: RegExp = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/gu; /** The daemon process that serves a request, and the size of its launcher log when the request was sent. */ export interface IServingDaemon { @@ -122,12 +124,14 @@ export async function explainLostConnectionAsync( /** * Returns the first fatal error in launcher log lines: V8's `FATAL ERROR:` line, or the message of Node's report * of an uncaught exception (its location, source line and caret, the error with its stack, then the Node.js - * version). Returns undefined when the first report does not have that shape. + * version). Returns undefined when the first report does not have that shape. ANSI escape sequences are ignored: + * with FORCE_COLOR, Node.js colors the report although the log is not a terminal. */ export function findLoggedFatalError(lines: ReadonlyArray): string | undefined { - for (let index: number = 0; index < lines.length; index++) { - if (V8_FATAL_ERROR.test(lines[index])) return lines[index].trim(); - if (NODE_REPORT_TRAILER.test(lines[index])) return findUncaughtErrorMessage(lines, index); + const plainLines: string[] = lines.map((line: string) => line.replace(ANSI_ESCAPE_SEQUENCE, '')); + for (let index: number = 0; index < plainLines.length; index++) { + if (V8_FATAL_ERROR.test(plainLines[index])) return plainLines[index].trim(); + if (NODE_REPORT_TRAILER.test(plainLines[index])) return findUncaughtErrorMessage(plainLines, index); } return undefined; } diff --git a/libraries/rush-client-core/src/test/DaemonDisconnect.test.ts b/libraries/rush-client-core/src/test/DaemonDisconnect.test.ts index d391be745e..0eac1754be 100644 --- a/libraries/rush-client-core/src/test/DaemonDisconnect.test.ts +++ b/libraries/rush-client-core/src/test/DaemonDisconnect.test.ts @@ -41,32 +41,7 @@ import { withUnreapedChildAsync } from './UnreapedChildProcess'; const linuxIt: typeof it = process.platform === 'linux' ? it : it.skip; const posixIt: typeof it = process.platform === 'win32' ? it.skip : it; -/** The lines that this Node.js version writes to stderr when `script` fails with an uncaught error. */ -function getCrashReport(script: string): string[] { - const { status, stderr } = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); - expect(status).not.toBe(0); - return stderr.split(/\r?\n/); -} - describe(findLoggedFatalError.name, () => { - it('returns the message of an uncaught error without its stack', () => { - const report: string[] = getCrashReport("setImmediate(() => { throw new Error('first\\nsecond'); })"); - expect(findLoggedFatalError(['rushd started', ...report])).toBe('Error: first second'); - }); - - it('returns the message of an unhandled rejection', () => { - expect(findLoggedFatalError(getCrashReport("Promise.reject(new TypeError('rejected'))"))).toBe( - 'TypeError: rejected' - ); - expect(findLoggedFatalError(getCrashReport("Promise.reject('reason')"))).toMatch( - /^UnhandledPromiseRejection: .* "reason"\.$/ - ); - }); - - it('returns a thrown value that is not an error', () => { - expect(findLoggedFatalError(getCrashReport("throw 'a plain string'"))).toBe('a plain string'); - }); - it("returns V8's fatal error line", () => { const lines: string[] = [ '<--- JS stacktrace --->', @@ -79,21 +54,52 @@ describe(findLoggedFatalError.name, () => { ); }); - it('returns the first report', () => { - const lines: string[] = [ - ...getCrashReport("throw new Error('first')"), - ...getCrashReport("throw new Error('second')") - ]; - expect(findLoggedFatalError(lines)).toBe('Error: first'); - }); + // CI services often set FORCE_COLOR, with which Node.js colors its report although stderr is not a terminal. + describe.each(['0', '1'])('with FORCE_COLOR=%s', (forceColor: string) => { + /** The lines that this Node.js version writes to stderr when `script` fails with an uncaught error. */ + function getCrashReport(script: string): string[] { + const { status, stderr } = spawnSync(process.execPath, ['-e', script], { + encoding: 'utf8', + env: { ...process.env, FORCE_COLOR: forceColor } + }); + expect(status).not.toBe(0); + return stderr.split(/\r?\n/); + } + + it('returns the message of an uncaught error without its stack', () => { + const report: string[] = getCrashReport("setImmediate(() => { throw new Error('first\\nsecond'); })"); + expect(findLoggedFatalError(['rushd started', ...report])).toBe('Error: first second'); + }); + + it('returns the message of an unhandled rejection', () => { + expect(findLoggedFatalError(getCrashReport("Promise.reject(new TypeError('rejected'))"))).toBe( + 'TypeError: rejected' + ); + expect(findLoggedFatalError(getCrashReport("Promise.reject('reason')"))).toMatch( + /^UnhandledPromiseRejection: .* "reason"\.$/ + ); + }); + + it('returns a thrown value that is not an error', () => { + expect(findLoggedFatalError(getCrashReport("throw 'a plain string'"))).toBe('a plain string'); + }); - it('returns undefined without a complete report', () => { - const report: string[] = getCrashReport("throw new Error('boom')"); - const trailer: number = report.findIndex((line) => line.startsWith('Node.js v')); - expect(trailer).toBeGreaterThan(0); - expect(findLoggedFatalError(report.slice(0, trailer))).toBeUndefined(); - expect(findLoggedFatalError(report.slice(3))).toBeUndefined(); - expect(findLoggedFatalError(['rushd started', 'Node.js v22.0.0'])).toBeUndefined(); + it('returns the first report', () => { + const lines: string[] = [ + ...getCrashReport("throw new Error('first')"), + ...getCrashReport("throw new Error('second')") + ]; + expect(findLoggedFatalError(lines)).toBe('Error: first'); + }); + + it('returns undefined without a complete report', () => { + const report: string[] = getCrashReport("throw new Error('boom')"); + const trailer: number = report.findIndex((line) => line.startsWith('Node.js v')); + expect(trailer).toBeGreaterThan(0); + expect(findLoggedFatalError(report.slice(0, trailer))).toBeUndefined(); + expect(findLoggedFatalError(report.slice(3))).toBeUndefined(); + expect(findLoggedFatalError(['rushd started', 'Node.js v22.0.0'])).toBeUndefined(); + }); }); }); diff --git a/libraries/rush-client-core/src/test/connectOrStartDaemonLiveOwner.test.ts b/libraries/rush-client-core/src/test/connectOrStartDaemonLiveOwner.test.ts index ae017ea368..fbb6d87030 100644 --- a/libraries/rush-client-core/src/test/connectOrStartDaemonLiveOwner.test.ts +++ b/libraries/rush-client-core/src/test/connectOrStartDaemonLiveOwner.test.ts @@ -370,8 +370,9 @@ function unlinkSockets(folder: string): void { }, 30000); it('names the parent that has not reaped an owner that exited', async () => { - // "sleep 0" exits at once, and the shell, now "sleep 600", never reaps it. - const parent: ChildProcess = await startChildAsync('sh', ['-c', 'sleep 0 & echo $!; exec sleep 600']); + // The shell, once it is "sleep 600", never reaps "sleep 1". A child that exits before that exec, as "sleep 0" + // can on a busy machine, is reaped by the shell. + const parent: ChildProcess = await startChildAsync('sh', ['-c', 'sleep 1 & echo $!; exec sleep 600']); const [output] = await once(parent.stdout!, 'data'); const zombie: number = Number(String(output)); await waitForStateAsync(zombie, 'Z'); diff --git a/libraries/rush-daemon-transport/src/test/DaemonOperationGroupRecorder.test.ts b/libraries/rush-daemon-transport/src/test/DaemonOperationGroupRecorder.test.ts index 2f0373e1b9..8573548b87 100644 --- a/libraries/rush-daemon-transport/src/test/DaemonOperationGroupRecorder.test.ts +++ b/libraries/rush-daemon-transport/src/test/DaemonOperationGroupRecorder.test.ts @@ -18,6 +18,7 @@ import { createTestDaemonPaths } from './TestDaemonFixture'; const linuxIt: jest.It = process.platform === 'linux' ? it : it.skip; const SLEEP_ARGS: string[] = ['-e', 'setTimeout(() => {}, 30000)']; +// Writes the PID as plain text: with FORCE_COLOR, which CI services often set, console.log colors a number. const HOLD_STDIO_ARGS: string[] = [ '-e', ` @@ -25,7 +26,7 @@ const HOLD_STDIO_ARGS: string[] = [ const child = spawn(process.execPath, ${JSON.stringify(SLEEP_ARGS)}, { stdio: ['ignore', 'inherit', 'inherit'] }); - console.log(child.pid); + process.stdout.write(String(child.pid)); process.exit(0); ` ]; From 54de7fff41d77b41660b37a8d42232884ab2d5d1 Mon Sep 17 00:00:00 2001 From: Sean Larkin <3408176+TheLarkInn@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:08:23 +0000 Subject: [PATCH 2/3] [rush-client-core, rush-daemon-transport] Wait until a spawned test process runs before reading /proc Just after the 'spawn' event, a child can still be in execve, before /proc shows its environment or command line. libuv reports a spawn when the child's close-on-exec signal pipe reaches EOF. If the parent closes its own end of that pipe after the child's exec has closed the child's end, the parent's close is the last one, so EOF comes at once while the child is still in exec. A loaded CI runner can delay the parent that long: OperationGroupMarker.test.ts failed on Node.js v24 (ubuntu) in https://github.com/microsoft/rushstack/actions/runs/37041623922/job/110952865222. - OperationGroupMarker and NativeLockHolder: the child prints once it runs, and the test waits for that. "names only the PID of a holder whose command it cannot shorten" now reads a real command line, instead of passing on an empty one. - OperationGroupSpawnMarkReap and OperationMarkerInheritance: sleep cannot print, so the test polls until /proc shows the marker. Without the wait, "never signals a process with the marker that started over 1 s after the spawn mark" could pass only because the environment was not readable yet. - Move the /proc/uptime reader to UptimeTicksFixture.ts, to keep OperationGroupSpawnMarkReap.test.ts within the package's 100-line limit. An LD_PRELOAD shim that delays the parent's close forces the race. On Node.js 22 and 24, reading right after 'spawn' missed the environment in 151 of 180 runs with a node child and 145 of 180 with sleep; the new waits missed it in none. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d3da49f-95f3-4291-9026-0b0f8e38675c --- .../src/test/NativeLockHolder.test.ts | 26 ++++++++----- .../src/test/OperationGroupMarker.test.ts | 6 ++- .../test/OperationGroupSpawnMarkReap.test.ts | 39 +++++++++---------- .../test/OperationMarkerInheritance.test.ts | 14 ++++--- .../src/test/UptimeTicksFixture.ts | 17 ++++++++ 5 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 libraries/rush-daemon-transport/src/test/UptimeTicksFixture.ts diff --git a/libraries/rush-client-core/src/test/NativeLockHolder.test.ts b/libraries/rush-client-core/src/test/NativeLockHolder.test.ts index 0bd0fab08c..4962b41f89 100644 --- a/libraries/rush-client-core/src/test/NativeLockHolder.test.ts +++ b/libraries/rush-client-core/src/test/NativeLockHolder.test.ts @@ -39,15 +39,24 @@ function writeLockFile( fs.utimesSync(filePath, time, time); } +/** Says that it runs, then runs until its stdin ends. */ +const PROGRAM_SCRIPT: string = + "process.stdout.write('started');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"; + +/** Starts `node ` to run {@link PROGRAM_SCRIPT}, and waits until it runs. */ +async function startNodeAsync(args: string[]): Promise { + const child: ChildProcess = spawn(process.execPath, args, { stdio: ['pipe', 'pipe', 'ignore'] }); + await once(child, 'spawn'); + // Just after the spawn event, the process can still be in exec, before /proc shows its command line. + await once(child.stdout!, 'data'); + return child; +} + /** Starts a process that looks like ` ` to /proc, and runs until its stdin ends. */ async function startProgramAsync(folder: string, program: string, args: string[]): Promise { const script: string = path.join(folder, program); - fs.writeFileSync(script, "process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"); - const child: ChildProcess = spawn(process.execPath, [script, ...args], { - stdio: ['pipe', 'ignore', 'ignore'] - }); - await once(child, 'spawn'); - return child; + fs.writeFileSync(script, PROGRAM_SCRIPT); + return await startNodeAsync([script, ...args]); } async function stopProgramAsync(child: ChildProcess): Promise { @@ -196,10 +205,7 @@ describe(findNativeLockHolder.name, () => { linuxIt('names only the PID of a holder whose command it cannot shorten', async () => { const folder: string = createLockFolder('no-command'); - const holder: ChildProcess = spawn(process.execPath, ['-e', 'process.stdin.resume()'], { - stdio: ['pipe', 'ignore', 'ignore'] - }); - await once(holder, 'spawn'); + const holder: ChildProcess = await startNodeAsync(['-e', PROGRAM_SCRIPT]); children.push(holder); writeLockFile(folder, holder.pid!); diff --git a/libraries/rush-daemon-transport/src/test/OperationGroupMarker.test.ts b/libraries/rush-daemon-transport/src/test/OperationGroupMarker.test.ts index 98e11d53c7..0e82545931 100644 --- a/libraries/rush-daemon-transport/src/test/OperationGroupMarker.test.ts +++ b/libraries/rush-daemon-transport/src/test/OperationGroupMarker.test.ts @@ -16,7 +16,7 @@ import type { IStartedProcess } from './ProcessWaitFixture'; import { createTestDaemonPaths } from './TestDaemonFixture'; const linuxIt: jest.It = process.platform === 'linux' ? it : it.skip; -const SLEEP_ARGS: string[] = ['-e', 'setTimeout(() => {}, 30000)']; +const SLEEP_ARGS: string[] = ['-e', "process.stdout.write('started'); setTimeout(() => {}, 30000)"]; const NAME: string = 'RUSHD_TEST_ENVIRONMENT_ENTRY'; const VALUE: string = '/tmp/rushd-1000/key.pid.json.groups-4242'; const FOREIGN_FOLDER: string = '/elsewhere.groups-4242'; @@ -49,11 +49,13 @@ linuxIt('marks the processes started while it records, and removes only its own linuxIt('finds only an exact entry of the environment that a process started with', async () => { const child: ChildProcess = spawn(process.execPath, SLEEP_ARGS, { env: { [NAME]: VALUE }, - stdio: 'ignore' + stdio: ['ignore', 'pipe', 'ignore'] }); await once(child, 'spawn'); const pid: number = Number(child.pid); started = identifyStarted([pid]); + // Just after the spawn event, the process can still be in exec, before /proc shows its environment. + await once(child.stdout!, 'data'); expect(hasEnvironmentEntry(pid, ENTRY)).toBe(true); for (const entry of [`${NAME}=${FOREIGN_FOLDER}`, `${NAME}=`, NAME, `${ENTRY}/`, VALUE]) { expect(hasEnvironmentEntry(pid, entry)).toBe(false); diff --git a/libraries/rush-daemon-transport/src/test/OperationGroupSpawnMarkReap.test.ts b/libraries/rush-daemon-transport/src/test/OperationGroupSpawnMarkReap.test.ts index 6ab1302438..2a30a18e4b 100644 --- a/libraries/rush-daemon-transport/src/test/OperationGroupSpawnMarkReap.test.ts +++ b/libraries/rush-daemon-transport/src/test/OperationGroupSpawnMarkReap.test.ts @@ -7,10 +7,11 @@ import { once } from 'node:events'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { DAEMON_OPERATION_GROUPS_ENV_VAR } from '../DaemonOperationGroupMarker'; +import { DAEMON_OPERATION_GROUPS_ENV_VAR, getOperationGroupsMarker } from '../DaemonOperationGroupMarker'; import { reapDeadDaemonOperationGroupsAsync } from '../DaemonOperationGroupReaper'; import { getOperationGroupsFolder } from '../DaemonOperationGroups'; import { POSIX_PROCESS_GROUP_OPS } from '../DaemonProcessGroup'; +import { hasEnvironmentEntry } from '../DaemonProcessStat'; import type { IDaemonOrphanReaperOptions } from '../DaemonReapOptions'; import type { IDaemonOrphanReap } from '../DaemonReclaimOptions'; @@ -18,19 +19,14 @@ import { DEAD_PID } from './OrphanReaperFixture'; import { identifyStarted, isAlive, killStillRunning, waitUntilAsync } from './ProcessWaitFixture'; import type { IStartedProcess } from './ProcessWaitFixture'; import { createTestDaemonPaths } from './TestDaemonFixture'; +import { readTicks } from './UptimeTicksFixture'; const linuxIt: jest.It = process.platform === 'linux' ? it : it.skip; -const PROC_UPTIME: string = '/proc/uptime'; -const UTF8: BufferEncoding = 'utf8'; -const DECIMAL_POINT: string = '.'; -const FRACTION_START: number = 0; -const HUNDREDTHS_DIGITS: number = 2; -const TICKS_PER_SECOND: number = 100; // Twice the 1 s after a mark in which a reap considers a process as started by the marked spawn. const LONG_BEFORE_TICKS: number = 200; const NO_TICKS: number = 0; const GRACE_MS: number = 500; -// Enough for the 5 s wait for the sleeper to be gone, so a failure shows what the reap did. +// Enough for the 5 s waits for the sleeper to start and to be gone, so a failure shows what the reap did. const TEST_TIMEOUT_MS: number = 15000; const SLEEP_SECONDS: string = '30'; const OPTIONS: IDaemonOrphanReaperOptions = { @@ -50,18 +46,15 @@ afterEach(() => { started = []; }); -// The clock ticks since boot, read with this test's own parser. -function readTicks(): number { - const [seconds, fraction] = fs.readFileSync(PROC_UPTIME, UTF8).split(DECIMAL_POINT); - return Number(seconds) * TICKS_PER_SECOND + Number(fraction.slice(FRACTION_START, HUNDREDTHS_DIGITS)); -} - async function spawnMarkedSleeperAsync(folder: string): Promise { const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, [DAEMON_OPERATION_GROUPS_ENV_VAR]: folder }; const sleeper: ChildProcess = spawn('sleep', [SLEEP_SECONDS], { detached: true, stdio: 'ignore', env }); await once(sleeper, 'spawn'); - started = identifyStarted([Number(sleeper.pid)]); - return Number(sleeper.pid); + const pid: number = Number(sleeper.pid); + started = identifyStarted([pid]); + // Just after the spawn event, the sleeper can still be in exec, before /proc shows its environment. + expect(await waitUntilAsync(() => hasEnvironmentEntry(pid, getOperationGroupsMarker(folder)))).toBe(true); + return pid; } // Marks a spawn `ticksBefore` ticks ago in the dead daemon's folder, starts a detached sleeper with the daemon's @@ -84,11 +77,15 @@ async function reapAfterMarkAsync(ticksBefore: number): Promise { return { sleeper, outcome, reaped }; } -linuxIt('reaps an unrecorded detached process with the marker that started just after a spawn mark', async () => { - const { sleeper, outcome, reaped } = await reapAfterMarkAsync(NO_TICKS); - const gone: boolean = await waitUntilAsync(() => !isAlive(sleeper)); - expect({ outcome, reaped, gone }).toEqual({ outcome: 'terminated', reaped: [[sleeper]], gone: true }); -}, TEST_TIMEOUT_MS); +linuxIt( + 'reaps an unrecorded detached process with the marker that started just after a spawn mark', + async () => { + const { sleeper, outcome, reaped } = await reapAfterMarkAsync(NO_TICKS); + const gone: boolean = await waitUntilAsync(() => !isAlive(sleeper)); + expect({ outcome, reaped, gone }).toEqual({ outcome: 'terminated', reaped: [[sleeper]], gone: true }); + }, + TEST_TIMEOUT_MS +); linuxIt('never signals a process with the marker that started over 1 s after the spawn mark', async () => { const { sleeper, outcome, reaped } = await reapAfterMarkAsync(LONG_BEFORE_TICKS); diff --git a/libraries/rush-daemon-transport/src/test/OperationMarkerInheritance.test.ts b/libraries/rush-daemon-transport/src/test/OperationMarkerInheritance.test.ts index caf5a5d3e0..cc7282b4d5 100644 --- a/libraries/rush-daemon-transport/src/test/OperationMarkerInheritance.test.ts +++ b/libraries/rush-daemon-transport/src/test/OperationMarkerInheritance.test.ts @@ -8,11 +8,13 @@ import { hasEnvironmentEntry } from '../DaemonProcessStat'; import { killFakeDaemonAsync, startFakeDaemonAsync } from './FakeOperationDaemonFixture'; import type { IFakeDaemon } from './FakeOperationDaemonFixture'; -import { identifyStarted, killStillRunning } from './ProcessWaitFixture'; +import { identifyStarted, killStillRunning, waitUntilAsync } from './ProcessWaitFixture'; import type { IStartedProcess } from './ProcessWaitFixture'; import { createTestDaemonPaths } from './TestDaemonFixture'; const linuxIt: jest.It = process.platform === 'linux' ? it : it.skip; +// Enough for the 5 s wait for the marker, so a failure shows which process lacks it. +const TEST_TIMEOUT_MS: number = 15000; let started: IStartedProcess[] = []; afterEach(() => { @@ -32,10 +34,12 @@ linuxIt( const otherMarker: string = getOperationGroupsMarker( getOperationGroupsFolder(paths.lockfilePath, process.pid) ); - expect(fake.pids.map((pid: number) => hasEnvironmentEntry(pid, marker))).toEqual( - fake.pids.map(() => true) - ); + const hasMarker = (pid: number): boolean => hasEnvironmentEntry(pid, marker); + // A grandchild can still be in exec, before /proc shows its environment. + await waitUntilAsync(() => fake.pids.every(hasMarker)); + expect(fake.pids.map(hasMarker)).toEqual(fake.pids.map(() => true)); expect(fake.pids.some((pid: number) => hasEnvironmentEntry(pid, otherMarker))).toBe(false); await killFakeDaemonAsync(fake); - } + }, + TEST_TIMEOUT_MS ); diff --git a/libraries/rush-daemon-transport/src/test/UptimeTicksFixture.ts b/libraries/rush-daemon-transport/src/test/UptimeTicksFixture.ts new file mode 100644 index 0000000000..2c83b7bb26 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/UptimeTicksFixture.ts @@ -0,0 +1,17 @@ +// 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'; + +const PROC_UPTIME: string = '/proc/uptime'; +const UTF8: BufferEncoding = 'utf8'; +const DECIMAL_POINT: string = '.'; +const FRACTION_START: number = 0; +const HUNDREDTHS_DIGITS: number = 2; +const TICKS_PER_SECOND: number = 100; + +/** The clock ticks since boot, read with the tests' own parser. */ +export function readTicks(): number { + const [seconds, fraction] = fs.readFileSync(PROC_UPTIME, UTF8).split(DECIMAL_POINT); + return Number(seconds) * TICKS_PER_SECOND + Number(fraction.slice(FRACTION_START, HUNDREDTHS_DIGITS)); +} From 225745e7b6bcaefc32bf28830a8d999539f6dfb8 Mon Sep 17 00:00:00 2001 From: Sean Larkin <3408176+TheLarkInn@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:51:58 +0000 Subject: [PATCH 3/3] [rush-lib] Give the test that aborts tar more time than Jest's default timeout "kills tar, and deletes its partial archive and the sealed files, when aborted" ran under Jest's default timeout of 5 s. On Windows, the test's stand-in for the clone copies all 2 GB of its sparse file, and the test then waits up to 10 s for the partial archive and 5 s for tar to exit. It timed out on Node.js v26 (windows) in this PR's CI: https://github.com/microsoft/rushstack/actions/runs/37052175171/job/110987993537 In the previous run, where it passed, the file's tests took 5.0 and 5.3 s on Windows and 1.8 s on Linux. The test now has a timeout of 30 s. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d3da49f-95f3-4291-9026-0b0f8e38675c --- ...thelarkinn-fix-publish-tests_2026-10-02-19-50.json | 11 +++++++++++ .../test/OperationBuildCacheDeferredWrites.test.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 common/changes/@microsoft/rush/thelarkinn-fix-publish-tests_2026-10-02-19-50.json diff --git a/common/changes/@microsoft/rush/thelarkinn-fix-publish-tests_2026-10-02-19-50.json b/common/changes/@microsoft/rush/thelarkinn-fix-publish-tests_2026-10-02-19-50.json new file mode 100644 index 0000000000..41fa6e17d4 --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-fix-publish-tests_2026-10-02-19-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/rush", + "email": "3408176+TheLarkInn@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCacheDeferredWrites.test.ts b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCacheDeferredWrites.test.ts index fa3e8c2056..493650d404 100644 --- a/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCacheDeferredWrites.test.ts +++ b/libraries/rush-lib/src/logic/buildCache/test/OperationBuildCacheDeferredWrites.test.ts @@ -267,6 +267,8 @@ describe('OperationBuildCache with deferred cache entry writes', () => { expect(logLines).toEqual([]); }); + // On Windows the clone copies all 2 GB. With the waits below, of up to 10 s for the partial archive and 5 s for tar + // to exit, that can take longer than Jest's default timeout of 5 s. it('kills tar, and deletes its partial archive and the sealed files, when aborted', async () => { // A sparse file takes no space, but tar reads 2 GB of zeros from it and gzip compresses them, which takes // seconds. @@ -298,5 +300,5 @@ describe('OperationBuildCache with deferred cache entry writes', () => { expect(logLines).toEqual([ `Dropped the build cache entry ${CACHE_ID} for acme-wizard (build), which was being written.` ]); - }); + }, 30000); });