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
16 changes: 14 additions & 2 deletions apps/rush-cli-client/src/daemonCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,17 @@ export async function executeDaemonCommandAsync(options: IDaemonCommandOptions):
}
try {
if (command === 'stop') {
await client.shutdownAsync();
const { activeRequests } = await client.shutdownAsync();
if (activeRequests) {
await writeStreamAsync(
process.stderr,
Buffer.from(
`rush-client: the daemon was running ${activeRequests} request(s); they were cancelled.\n`
)
);
}
const cancelled: { cancelledRequests?: number } =
activeRequests === undefined ? {} : { cancelledRequests: activeRequests };
if (options.argv[1] === '--force') {
// Wait for the acknowledged daemon to release its listener and record, then clear leftovers
// such as an abandoned startup reservation in the same invocation.
Expand All @@ -129,13 +139,15 @@ export async function executeDaemonCommandAsync(options: IDaemonCommandOptions):
await writeStatusAsync({
state: 'shutdownAccepted',
socketPath: connectionOptions.paths.socketPath,
...cancelled,
removedPaths
});
return;
}
await writeStatusAsync({
state: 'shutdownAccepted',
socketPath: connectionOptions.paths.socketPath
socketPath: connectionOptions.paths.socketPath,
...cancelled
});
return;
}
Expand Down
6 changes: 5 additions & 1 deletion apps/rush-cli-client/src/launchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { formatAdmissionFailure, getConfiguredAdmission } from './ClientAdmissio
import { ClientOperationRenderer } from './ClientOperationRenderer';
import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions';
import { selectClientRoute, type IClientRoute } from './routing';
import { getResultDiagnostic } from './resultDiagnostics';
import { writeStreamAsync } from './writeStreamAsync';
import {
getBundledRushVersion,
Expand Down Expand Up @@ -206,7 +207,10 @@ export async function launchClientAsync(rushx: boolean): Promise<void> {
}
if (outcome.kind === 'result') {
process.exitCode = outcome.result.exitCode;
if (outcome.result.admissionErrorCode) {
const diagnostic: string | undefined = getResultDiagnostic(outcome.result);
if (diagnostic) {
await writeStreamAsync(process.stderr, Buffer.from(diagnostic));
} else if (outcome.result.admissionErrorCode) {
await writeStreamAsync(
process.stderr,
Buffer.from(formatAdmissionFailure(outcome.result.admissionErrorCode, request.admission))
Expand Down
25 changes: 25 additions & 0 deletions apps/rush-cli-client/src/resultDiagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { IDaemonCommandResult } from '@rushstack/rush-daemon-protocol';

/**
* Returns the stderr line that explains a failed daemon result, if any.
*
* @remarks
* A non-zero result's error message (for example, a daemon shutdown that aborted the request) is the only
* place the daemon reports failures that are not attributed to an operation, so it must not be dropped.
* Returns `undefined` for `no-wait` and `wait-timeout` admission failures, which `formatAdmissionFailure`
* explains.
*/
export function getResultDiagnostic(
result: Pick<IDaemonCommandResult, 'admissionErrorCode' | 'errorMessage' | 'exitCode'>
): string | undefined {
// A request aborted while waiting for admission carries the reason (such as a daemon shutdown) in its
// error message; other admission failures are explained by `formatAdmissionFailure`.
if (result.admissionErrorCode !== undefined && result.admissionErrorCode !== 'aborted') return undefined;
if (result.exitCode !== 0 && result.errorMessage) {
return `rush-client: ${result.errorMessage}\n`;
}
return undefined;
}
1 change: 1 addition & 0 deletions apps/rush-cli-client/src/test/launchClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ describe('standalone rushx fallback', () => {
expect(JSON.parse(result.stdout)).toEqual({
state: 'shutdownAccepted',
socketPath: paths.socketPath,
cancelledRequests: 0,
removedPaths: [reservation]
});
expect(fs.existsSync(reservation)).toBe(false);
Expand Down
36 changes: 36 additions & 0 deletions apps/rush-cli-client/src/test/resultDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { getResultDiagnostic } from '../resultDiagnostics';

describe(getResultDiagnostic.name, () => {
it('prints the error message of a failed result', () => {
expect(
getResultDiagnostic({
exitCode: 1,
errorMessage: 'The Rush daemon was shut down (idle timeout) while this request was running.'
})
).toBe('rush-client: The Rush daemon was shut down (idle timeout) while this request was running.\n');
});

it('prefers the reason of a request aborted while waiting for admission', () => {
expect(
getResultDiagnostic({ exitCode: 1, admissionErrorCode: 'aborted', errorMessage: 'daemon shut down' })
).toBe('rush-client: daemon shut down\n');
});

it('leaves no-wait and wait-timeout admission failures to the admission formatter', () => {
expect(
getResultDiagnostic({ exitCode: 1, admissionErrorCode: 'wait-timeout', errorMessage: 'x' })
).toBeUndefined();
expect(
getResultDiagnostic({ exitCode: 1, admissionErrorCode: 'no-wait', errorMessage: 'x' })
).toBeUndefined();
expect(getResultDiagnostic({ exitCode: 1, admissionErrorCode: 'aborted' })).toBeUndefined();
});

it('stays silent for successful results and failures without a message', () => {
expect(getResultDiagnostic({ exitCode: 0, errorMessage: 'ignored' })).toBeUndefined();
expect(getResultDiagnostic({ exitCode: 1 })).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-cli-client",
"comment": "Print the error message of a failed daemon result (for example, a daemon shutdown that cancelled the build), and report cancelled requests from \"rush-client daemon stop\".",
"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-client-core",
"comment": "Return the daemon shutdown acknowledgement, including its optional active request count, from DaemonClient.shutdownAsync().",
"type": "patch"
}
],
"packageName": "@rushstack/rush-client-core",
"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 an optional \"activeRequests\" count to the shutdownAck control message; advertised as protocol minor 11 (`DAEMON_SHUTDOWN_ACTIVE_REQUESTS_PROTOCOL_MINOR`); older peers omit or ignore it.",
"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": "Report warm input-snapshot failures (with the engine diagnostics) to the failing request instead of replaying them into the next request, abort requests interrupted by a daemon shutdown with a typed DaemonShutdownError that names its initiator, and report running requests in the shutdown acknowledgement.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "selarkin@microsoft.com"
}
3 changes: 2 additions & 1 deletion common/reviews/api/rush-client-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IDaemonPongMessage } from '@rushstack/rush-daemon-protocol';
import { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol';
import { IDaemonRequestEnvelope } from '@rushstack/rush-daemon-protocol';
import { IDaemonRequestRejectedMessage } from '@rushstack/rush-daemon-protocol';
import { IDaemonShutdownAckMessage } from '@rushstack/rush-daemon-protocol';
import type { Readable } from 'node:stream';

// @beta
Expand All @@ -29,7 +30,7 @@ export class DaemonClient {
static connectAsync(options: IDaemonClientConnectOptions): Promise<DaemonClient>;
executeAsync(options: IDaemonClientExecuteOptions): Promise<DaemonClientOutcome>;
get protocolVersion(): IDaemonProtocolVersion;
shutdownAsync(timeoutMs?: number): Promise<void>;
shutdownAsync(timeoutMs?: number): Promise<IDaemonShutdownAckMessage['payload']>;
get status(): Promise<IDaemonPongMessage['payload']>;
}

Expand Down
7 changes: 6 additions & 1 deletion common/reviews/api/rush-daemon-protocol.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export const DAEMON_REQUEST_ADMISSION_PROTOCOL_MINOR: number;
// @beta
export const DAEMON_REQUEST_LIFECYCLE_PROTOCOL_MINOR: number;

// @beta
export const DAEMON_SHUTDOWN_ACTIVE_REQUESTS_PROTOCOL_MINOR: number;

// @beta
export const DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR: number;

Expand Down Expand Up @@ -592,7 +595,9 @@ export interface IDaemonShutdownAckMessage {
// (undocumented)
readonly kind: 'shutdownAck';
// (undocumented)
readonly payload: Record<string, never>;
readonly payload: {
readonly activeRequests?: number;
};
}

// @beta
Expand Down
21 changes: 20 additions & 1 deletion common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ export class DaemonRequiresInProcessError extends Error {
readonly policy: IDaemonTerminalPolicyResult;
}

// @beta
export class DaemonShutdownError extends Error {
constructor(options: IDaemonShutdownErrorOptions);
// (undocumented)
readonly initiator: DaemonShutdownInitiator;
// (undocumented)
readonly signal: string | undefined;
}

// @beta
export type DaemonShutdownInitiator = 'controlClient' | 'signal' | 'idleTimeout' | 'restart' | 'host';

// @beta
export type DispatchWorkspaceRequestAsync = (options: IDispatchWorkspaceRequestOptions) => Promise<IDaemonCommandResult | undefined>;

Expand Down Expand Up @@ -177,6 +189,13 @@ export interface IDaemonRequestResolver {
readonly workspaceLifecycle?: IWorkspaceResolverLifecycle;
}

// @beta
export interface IDaemonShutdownErrorOptions {
// (undocumented)
readonly initiator: DaemonShutdownInitiator;
readonly signal?: string;
}

// @beta
export interface IDispatchWorkspaceRequestOptions {
// (undocumented)
Expand Down Expand Up @@ -723,7 +742,7 @@ export type ResolvedDaemonRequest = IResolvedDaemonPhasedRequest | IResolvedDaem

// @beta
export class RushDaemonHost {
closeAsync(): Promise<void>;
closeAsync(reason?: DaemonShutdownError): Promise<void>;
readonly closed: Promise<void>;
getWorkspaceSessionAsync(): Promise<IWorkspaceSession>;
// (undocumented)
Expand Down
10 changes: 8 additions & 2 deletions libraries/rush-client-core/src/DaemonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
type IDaemonPongMessage,
type IDaemonProtocolVersion,
type IDaemonRequestEnvelope,
type IDaemonRequestRejectedMessage
type IDaemonRequestRejectedMessage,
type IDaemonShutdownAckMessage
} from '@rushstack/rush-daemon-protocol';
import { connectDaemonAsync, type DaemonFrameConnection } from '@rushstack/rush-daemon-transport';

Expand Down Expand Up @@ -102,6 +103,7 @@ export class DaemonClient {
#result: IDeferred<DaemonClientOutcome> | undefined;
#shutdown: IDeferred<void> | undefined;
#shutdownAcknowledged: boolean = false;
#shutdownAck: IDaemonShutdownAckMessage['payload'] = {};
#execution: IDaemonClientExecuteOptions | undefined;
#finished: boolean = false;
#inputStarted: boolean = false;
Expand Down Expand Up @@ -186,8 +188,10 @@ export class DaemonClient {
* Requests shutdown on a fresh connection and waits for acknowledgement followed by EOF.
* @remarks This confirms acceptance and connection closure, not successful workspace cleanup.
* Requires protocol 0.6. The timeout defaults to 15000 milliseconds.
* @returns The acknowledgement, including the number of running requests the shutdown aborts when the
* daemon reports it.
*/
public async shutdownAsync(timeoutMs: number = 15000): Promise<void> {
public async shutdownAsync(timeoutMs: number = 15000): Promise<IDaemonShutdownAckMessage['payload']> {
if (this.#used) throw new Error('Create a fresh DaemonClient for shutdown.');
this.#used = true;
let timer: ReturnType<typeof setTimeout> | undefined;
Expand All @@ -206,6 +210,7 @@ export class DaemonClient {
);
}, timeoutMs);
await Promise.all([this.#shutdown.promise, this.#sendControlAsync({ kind: 'shutdown', payload: {} })]);
return this.#shutdownAck;
} finally {
clearTimeout(timer);
await this.closeAsync();
Expand Down Expand Up @@ -375,6 +380,7 @@ export class DaemonClient {
throw new DaemonProtocolError('malformedControlMessage', 'Unexpected shutdown acknowledgement.');
}
this.#shutdownAcknowledged = true;
this.#shutdownAck = message.payload;
return;
}
const execution: IDaemonClientExecuteOptions = this.#requireExecution();
Expand Down
7 changes: 4 additions & 3 deletions libraries/rush-client-core/src/test/DaemonClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,21 +521,22 @@ describe('DaemonClient', () => {
});
onRequest = async (message) => {
if (message.kind === 'shutdown') {
await sendAsync({ kind: 'shutdownAck', payload: {} });
await sendAsync({ kind: 'shutdownAck', payload: { activeRequests: 2 } });
acknowledged();
}
};
const client = await DaemonClient.connectAsync({ socketPath: address });
expect(client.protocolVersion.minor).toBeGreaterThanOrEqual(6);
let completed: boolean = false;
const shutdown: Promise<void> = client.shutdownAsync().then(() => {
const shutdown: Promise<unknown> = client.shutdownAsync().then((payload) => {
completed = true;
return payload;
});
await ack;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(completed).toBe(false);
await connection!.closeAsync();
await shutdown;
await expect(shutdown).resolves.toEqual({ activeRequests: 2 });
expect(completed).toBe(true);
expect(controls.filter((message) => message.kind === 'shutdown')).toHaveLength(1);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
validateRequestResultControl,
validateRequestStartControl
} from './RequestControlValidation';
import { validateShutdownAck } from './ShutdownAckValidation';
import { validateSubscribeControl } from './SubscribeControlValidation';
function fail(reason: string): never {
throw new DaemonProtocolError('malformedControlMessage', reason);
Expand Down Expand Up @@ -67,7 +68,7 @@ const VALIDATORS_BY_KIND: Record<string, ControlValidator> = {
requestRejected: validateRequestRejectedControl,
requestResult: validateRequestResultControl,
shutdown: noopValidator,
shutdownAck: noopValidator,
shutdownAck: validateShutdownAck,
stdinReady: validateRequestCancelControl,
stdinEnd: validateRequestCancelControl
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ export interface IDaemonShutdownMessage {
/** Acknowledges shutdown before the host closes connections and releases its endpoint. @beta */
export interface IDaemonShutdownAckMessage {
readonly kind: 'shutdownAck';
readonly payload: Record<string, never>;
readonly payload: {
/**
* Requests that were still running and will be aborted by this shutdown. Daemons older than
* `DAEMON_SHUTDOWN_ACTIVE_REQUESTS_PROTOCOL_MINOR` omit it.
*/
readonly activeRequests?: number;
Comment thread
TheLarkInn marked this conversation as resolved.
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const DAEMON_INVOCATION_KIND_PROTOCOL_MINOR: number = 8;
/** The first minor supporting native mutations and guaranteed pre-execution restart outcomes. @beta */
export const DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR: number = 10;

/** The first additive protocol minor whose shutdown acknowledgement reports the active request count. @beta */
export const DAEMON_SHUTDOWN_ACTIVE_REQUESTS_PROTOCOL_MINOR: number = 11;

/**
* A rushd wire protocol version.
*
Expand Down Expand Up @@ -58,7 +61,7 @@ export interface IDaemonProtocolVersion {
*/
export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = {
major: 0,
minor: DAEMON_WORKSPACE_RESTART_PROTOCOL_MINOR
minor: DAEMON_SHUTDOWN_ACTIVE_REQUESTS_PROTOCOL_MINOR
};

/**
Expand Down
17 changes: 17 additions & 0 deletions libraries/rush-daemon-protocol/src/ShutdownAckValidation.ts
Original file line number Diff line number Diff line change
@@ -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 { DaemonProtocolError } from './DaemonProtocolError';

const ZERO: number = 0;

/** Validates the optional active request count of a shutdown acknowledgement. @internal */
export function validateShutdownAck(payload: Record<string, unknown>): void {
const value: unknown = payload.activeRequests;
if (value === undefined || isNonnegativeInteger(value)) return;
throw new DaemonProtocolError('malformedControlMessage', 'Invalid shutdownAck field "activeRequests".');
}

function isNonnegativeInteger(value: unknown): boolean {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= ZERO;
}
Loading
Loading