Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/rush-cli-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ rounded down to milliseconds. These controls are mutually exclusive and are
consumed before forwarding, never appended to a project script. Arguments after
`--` remain literal script arguments.

The queue timeout is measured from when the daemon receives the request. An
explicit `--no-wait`, `--wait-timeout`, `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS`, or
`daemon.queueTimeoutSeconds` in `rush.json` bounds the entire wait: waiting for
workspace admission and waiting for a running build that the request could not
join. The built-in 30-second default bounds only workspace admission (for example,
waiting for a command that needs exclusive access). With the default, a build that
arrives while a compatible build is already running waits for it to finish and then
runs, instead of failing after 30 seconds. On a timeout, the client exits with
code 1 and says how to wait longer.

Admission controls also apply to experimental graph requests, but not
`start|stop|restart|status|logs`. They affect daemon admission only; native fallback
retains native command behavior. Waiting positions are shown on interactive stderr,
Expand Down Expand Up @@ -169,7 +179,7 @@ keys and unknown `RUSH_DAEMON*` variables fail validation.
| `enabled` | `RUSH_DAEMON` | false | Client routing |
| `autoStart` | `RUSH_DAEMON_AUTO_START` | true | Only after opt-in |
| `idleTimeoutSeconds` | `RUSH_DAEMON_IDLE_TIMEOUT_SECONDS` | 900 | Host idle shutdown after request/output/cleanup drain |
| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Sent through existing admission contract |
| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Admission wait limit. The default does not bound waiting behind a running compatible build; an explicit value does |
| `watch` | `RUSH_DAEMON_WATCH` | false | Persistent host observation of requested warm projects; false keeps root/config guards only. Never schedules builds |
| `usePersistentIpcRunners` | `RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS` | false | Enables explicit per-operation `daemonIpc` Node launchers for unsharded incremental daemon builds |
| `warmIdleTimeoutSeconds` | `RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS` | 300 | Idle runner, project-watcher and retained-result eviction |
Expand Down
38 changes: 38 additions & 0 deletions apps/rush-cli-client/src/ClientAdmissionControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS,
validateDaemonRequestAdmissionOptions,
type DaemonRequestAdmissionErrorCode,
type IDaemonRequestAdmissionOptions
} from '@rushstack/rush-daemon-protocol';

Expand Down Expand Up @@ -54,3 +55,40 @@ export function parseClientAdmissionControls(argv: ReadonlyArray<string>): IClie
: { waitTimeoutMs };
return { argv: remaining, admission };
}

export interface IConfiguredAdmissionOptions {
readonly queueTimeoutSeconds: number;
/** True when the timeout came from rush.json or RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS rather than the default. */
readonly explicit: boolean;
}

/**
* Converts the configured queue timeout into admission options for a request without `--no-wait`/`--wait-timeout`.
* A timeout that comes only from the built-in default is marked so that the daemon applies it to workspace
* admission but not to waiting behind a running compatible build.
*/
export function getConfiguredAdmission(options: IConfiguredAdmissionOptions): IDaemonRequestAdmissionOptions {
const waitTimeoutMs: number = Math.floor(options.queueTimeoutSeconds * 1000);
return options.explicit ? { waitTimeoutMs } : { waitTimeoutMs, waitTimeoutIsDefault: true };
}

/** Explains a daemon admission failure and how to wait longer. */
export function formatAdmissionFailure(
code: DaemonRequestAdmissionErrorCode,
admission: IDaemonRequestAdmissionOptions | undefined
): string {
const prefix: string = `rush-client: daemon admission failed (${code})`;
if (code === 'no-wait') {
return `${prefix}: another daemon request is using this workspace and --no-wait was specified.\n`;
}
if (code === 'wait-timeout') {
const seconds: string =
admission?.waitTimeoutMs === undefined ? '' : ` after ${admission.waitTimeoutMs / 1000}s`;
return (
`${prefix}: timed out${seconds} waiting for another daemon request in this workspace to finish ` +
'(a command that needs exclusive access, or a running build). ' +
'To wait longer, use --wait-timeout <seconds> or set RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS.\n'
);
}
return `${prefix}.\n`;
}
12 changes: 10 additions & 2 deletions apps/rush-cli-client/src/launchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { MinimalRushConfiguration } from '@microsoft/rush/lib/MinimalRushConfigu
import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher';

import { executeDaemonCommandAsync } from './daemonCommands';
import { formatAdmissionFailure, getConfiguredAdmission } from './ClientAdmissionControls';
import { ClientOperationRenderer } from './ClientOperationRenderer';
import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions';
import { selectClientRoute, type IClientRoute } from './routing';
Expand Down Expand Up @@ -91,7 +92,14 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
columns: process.stdout.columns,
acceptsStdin: true
},
admission: route.admission ?? { waitTimeoutMs: Math.floor(config.queueTimeoutSeconds * 1000) }
admission:
route.admission ??
getConfiguredAdmission({
queueTimeoutSeconds: config.queueTimeoutSeconds,
explicit:
workspace?.daemon?.queueTimeoutSeconds !== undefined ||
environment[daemonEnvironmentVariables.queueTimeoutSeconds] !== undefined
})
});
let connection: IConnectOrStartDaemonOptions;
let client: DaemonClient;
Expand Down Expand Up @@ -190,7 +198,7 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
if (outcome.result.admissionErrorCode) {
await writeStreamAsync(
process.stderr,
Buffer.from(`rush-client: daemon admission failed (${outcome.result.admissionErrorCode}).\n`)
Buffer.from(formatAdmissionFailure(outcome.result.admissionErrorCode, request.admission))
);
}
} else if (outcome.kind === 'rejected') {
Expand Down
32 changes: 31 additions & 1 deletion apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { parseClientAdmissionControls } from '../ClientAdmissionControls';
import {
formatAdmissionFailure,
getConfiguredAdmission,
parseClientAdmissionControls
} from '../ClientAdmissionControls';
import { selectClientRoute } from '../routing';

describe(parseClientAdmissionControls.name, () => {
Expand Down Expand Up @@ -55,3 +59,29 @@ describe(parseClientAdmissionControls.name, () => {
});
});
});

describe(getConfiguredAdmission.name, () => {
it('marks the built-in default so it does not bound waiting behind a compatible build', () => {
expect(getConfiguredAdmission({ queueTimeoutSeconds: 30, explicit: false })).toEqual({
waitTimeoutMs: 30000,
waitTimeoutIsDefault: true
});
});

it('keeps an explicitly configured timeout as one absolute deadline', () => {
expect(getConfiguredAdmission({ queueTimeoutSeconds: 1.5, explicit: true })).toEqual({ waitTimeoutMs: 1500 });
});
});

describe(formatAdmissionFailure.name, () => {
it('explains a wait timeout and how to wait longer', () => {
const message: string = formatAdmissionFailure('wait-timeout', { waitTimeoutMs: 5000 });
expect(message).toContain('daemon admission failed (wait-timeout): timed out after 5s waiting for');
expect(message).toContain('--wait-timeout <seconds>');
expect(message).toContain('RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS');
});

it('explains a no-wait failure', () => {
expect(formatAdmissionFailure('no-wait', { noWait: true })).toContain('--no-wait was specified');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-cli-client",
"comment": "Wait for a running compatible build instead of failing after the default 30-second queue timeout, and explain admission failures with how to wait longer.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-cli-client",
"email": "selarkin@microsoft.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon-protocol",
"comment": "Add the optional `waitTimeoutIsDefault` admission option so a daemon can distinguish a client-default queue timeout from an explicit one.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-daemon-protocol",
"email": "selarkin@microsoft.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon",
"comment": "Do not let the default queue timeout fail a late compatible shared build that is waiting for a running build; explicit wait limits still apply, and timeout errors say what the request waited for.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "selarkin@microsoft.com"
}
1 change: 1 addition & 0 deletions common/reviews/api/rush-daemon-protocol.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export interface IDaemonRawModeChangedMessage {
// @beta
export interface IDaemonRequestAdmissionOptions {
readonly noWait?: boolean;
readonly waitTimeoutIsDefault?: boolean;
readonly waitTimeoutMs?: number;
}

Expand Down
12 changes: 9 additions & 3 deletions libraries/rush-daemon-protocol/src/DaemonRequestAdmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export type DaemonRequestAdmissionErrorCode = 'aborted' | 'no-wait' | 'wait-time
export interface IDaemonRequestAdmissionOptions {
/** Fail immediately when the request cannot be admitted. */
readonly noWait?: boolean;
/**
* True when `waitTimeoutMs` is a client default rather than an explicit user choice. A default timeout bounds
* workspace admission only, not waiting behind running compatible shared builds.
*/
readonly waitTimeoutIsDefault?: boolean;
/** Maximum queue wait in milliseconds. Omission means no timeout. */
readonly waitTimeoutMs?: number;
}
Expand All @@ -33,7 +38,8 @@ export function validateDaemonRequestAdmissionOptions(
return;
}
validateAdmissionRecord(options);
validateNoWait(options.noWait);
validateBoolean(options.noWait, 'noWait');
validateBoolean(options.waitTimeoutIsDefault, 'waitTimeoutIsDefault');
validateWaitTimeout(options.waitTimeoutMs);
}

Expand All @@ -43,9 +49,9 @@ function validateAdmissionRecord(options: IDaemonRequestAdmissionOptions): void
}
}

function validateNoWait(value: unknown): void {
function validateBoolean(value: unknown, name: string): void {
if (value !== undefined && typeof value !== 'boolean') {
throw new TypeError('Daemon request admission noWait must be a boolean.');
throw new TypeError(`Daemon request admission ${name} must be a boolean.`);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function validateRequestAdmission(value: unknown): void {
if (value === undefined) return;
const admission: Record<string, unknown> = requireRecord(value, 'requestStart payload.admission');
validateOptionalBoolean(admission.noWait, 'Request admission noWait');
validateOptionalBoolean(admission.waitTimeoutIsDefault, 'Request admission waitTimeoutIsDefault');
validateWaitTimeout(admission.waitTimeoutMs);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ describe(validateDaemonRequestAdmissionOptions.name, () => {
expect(() =>
validateDaemonRequestAdmissionOptions({
noWait: true,
waitTimeoutIsDefault: true,
waitTimeoutMs: MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS
})
).not.toThrow();
});

it.each([
[{ noWait: 'yes' }, 'noWait'],
[{ waitTimeoutIsDefault: 'yes' }, 'waitTimeoutIsDefault'],
[{ waitTimeoutMs: -1 }, 'waitTimeoutMs'],
[{ waitTimeoutMs: 1.5 }, 'waitTimeoutMs'],
[
Expand Down
9 changes: 6 additions & 3 deletions libraries/rush-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,9 +431,12 @@ the router validates both, reconciles retained invalidations, applies the select
and runs at most one scheduled iteration. A workspace-wide `RequestScheduler` admits phased and global routes using
the static built-in command policy (`SHARED-BUILD`, `SHARED-READ`, or `EXCLUSIVE`); custom-origin commands and unknown
built-in names fail closed to `EXCLUSIVE`, including plugin replacements of built-in names. Queued clients receive
ordered, one-based position controls and can request fail-fast or bounded waiting. One absolute deadline and progress
channel cover both workspace admission and the temporary phased graph-execution gate. Cancellation, disconnect, or
queue-output failure removes queued work before it can execute.
ordered, one-based position controls and can request fail-fast or bounded waiting. One progress channel covers both
workspace admission and the temporary phased graph-execution gate. An explicit `noWait` or `waitTimeoutMs` is one
absolute deadline for both waits. When the client marks `waitTimeoutMs` as its default (`waitTimeoutIsDefault`), the
deadline bounds workspace admission only: a `SHARED-BUILD` request that arrives after the current batch has closed waits
on the graph-execution gate without a deadline, because it is queued only behind running compatible shared builds, and
then runs in the next batch. Cancellation, disconnect, or queue-output failure removes queued work before it can execute.
A requesting client receives only its enabled dependency closure's WS1 raw chunks and structured events through
backpressured, ordered callbacks, followed exactly once by a typed final command result after all preceding output
drains. The result translates only that client's operation subset to Rush's success, warning, failure, or abort exit
Expand Down
2 changes: 1 addition & 1 deletion libraries/rush-daemon/src/PhasedRequestRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ class PhasedRequestBatchCoordinator {
request.exclusivityClass === RequestExclusivityClass.SharedBuild
? RequestExclusivityClass.SharedBuild
: RequestExclusivityClass.Exclusive;
const graphWaitLease: IRequestLease = await admissionController.acquireAsync(
const graphWaitLease: IRequestLease = await admissionController.acquireGraphExecutionAsync(
this.#graphExecutionScheduler,
graphExclusivityClass
);
Expand Down
50 changes: 45 additions & 5 deletions libraries/rush-daemon/src/WorkspaceRequestAdmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
import {
type IRequestLease,
type IRequestSchedulerAcquireOptions,
type RequestExclusivityClass,
RequestExclusivityClass,
RequestScheduler,
RequestSchedulerError,
RequestSchedulerErrorCode
Expand Down Expand Up @@ -123,9 +123,48 @@ export class RequestAdmissionController {
}
}

/** Waits for workspace admission, bounded by the request's absolute admission deadline. */
public async acquireAsync(
scheduler: RequestScheduler,
exclusivityClass: RequestExclusivityClass
): Promise<IRequestLease> {
return await this.#acquireAsync(
scheduler,
exclusivityClass,
this.#getRemainingWaitTimeoutMs(),
'workspace admission'
);
}

/**
* Waits for the per-graph execution gate after workspace admission.
*
* @remarks
* A shared-build request that reaches this gate is only waiting behind running compatible shared builds, which is
* progress rather than contention. A client-default timeout therefore does not apply to that wait; an explicit
* `noWait` or `waitTimeoutMs` still applies, using the same absolute deadline as workspace admission.
*/
public async acquireGraphExecutionAsync(
scheduler: RequestScheduler,
exclusivityClass: RequestExclusivityClass
): Promise<IRequestLease> {
const waitTimeoutMs: number | undefined =
exclusivityClass === RequestExclusivityClass.SharedBuild && this.#admission?.waitTimeoutIsDefault
? undefined
: this.#getRemainingWaitTimeoutMs();
return await this.#acquireAsync(
scheduler,
exclusivityClass,
waitTimeoutMs,
'the running build of the workspace operation graph'
);
}

async #acquireAsync(
scheduler: RequestScheduler,
exclusivityClass: RequestExclusivityClass,
waitTimeoutMs: number | undefined,
waitingFor: string
): Promise<IRequestLease> {
const writer: QueuePositionWriter | undefined = this.#writer;
let lease: IRequestLease | undefined;
Expand All @@ -135,7 +174,7 @@ export class RequestAdmissionController {
exclusivityClass,
noWait: this.#admission?.noWait,
onQueuePositionChanged: writer ? (position: number) => writer.enqueue(position) : undefined,
waitTimeoutMs: this.#getRemainingWaitTimeoutMs()
waitTimeoutMs
});
await writer?.flushAsync();
if (this.#abortController.signal.aborted) {
Expand All @@ -148,7 +187,7 @@ export class RequestAdmissionController {
} catch (error) {
lease?.release();
await writer?.flushAsync();
throw this.#getReportedError(error);
throw this.#getReportedError(error, waitingFor);
}
}

Expand All @@ -167,7 +206,7 @@ export class RequestAdmissionController {
return this.#deadlineMs === undefined ? undefined : Math.max(0, this.#deadlineMs - Date.now());
}

#getReportedError(error: unknown): unknown {
#getReportedError(error: unknown, waitingFor: string): unknown {
const waitTimeoutMs: number | undefined = this.#admission?.waitTimeoutMs;
if (
waitTimeoutMs !== undefined &&
Expand All @@ -176,7 +215,8 @@ export class RequestAdmissionController {
) {
return new RequestSchedulerError(
RequestSchedulerErrorCode.WaitTimeout,
`The request was not admitted within ${waitTimeoutMs}ms.`
`The request was not admitted within ${waitTimeoutMs}ms while waiting for ${waitingFor}. ` +
'Use --wait-timeout <seconds> or RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS to wait longer.'
);
}
return error;
Expand Down
Loading
Loading