diff --git a/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json b/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json new file mode 100644 index 0000000000..f75faa8561 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Include daemon and protocol version metadata in pong control messages.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon-protocol", + "email": "mojazayeri@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json new file mode 100644 index 0000000000..10842acf1e --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Add the rushd executable and workspace-keyed daemon host bootstrap with handshake, liveness, readiness, and clean shutdown lifecycle.", + "type": "minor" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "mojazayeri@users.noreply.github.com" +} diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 772dfea492..b8fdee53a1 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -52,7 +52,7 @@ }, { "name": "@rushstack/rush-daemon-transport", - "allowedCategories": [ "tests" ] + "allowedCategories": [ "libraries", "tests" ] }, { "name": "@rushstack/rush-serve-dashboard", diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index edeffd5d18..a0167a937a 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -4078,6 +4078,16 @@ importers: version: 9.37.0 ../../../libraries/rush-daemon: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../node-core-library + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../rush-daemon-protocol + '@rushstack/rush-daemon-transport': + specifier: workspace:* + version: link:../rush-daemon-transport devDependencies: '@rushstack/heft': specifier: workspace:* diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md index 54d3ad5bfe..37834b8ba5 100644 --- a/common/reviews/api/rush-daemon-protocol.api.md +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -292,6 +292,8 @@ export interface IDaemonPongMessage { readonly kind: 'pong'; // (undocumented) readonly payload: { + readonly daemonVersion?: string; + readonly protocolVersion?: IDaemonProtocolVersion; readonly uptimeMs: number; }; } diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md index e32d01c6bc..ef178340ec 100644 --- a/common/reviews/api/rush-daemon.api.md +++ b/common/reviews/api/rush-daemon.api.md @@ -6,6 +6,8 @@ /// +import type { IDaemonPaths } from '@rushstack/rush-daemon-transport'; + // @public export interface IRequestLease { // (undocumented) @@ -23,6 +25,21 @@ export interface IRequestSchedulerAcquireOptions { waitTimeoutMs?: number; } +// @beta +export interface IRushDaemonHostOptions { + readonly daemonVersion: string; + readonly onError?: (error: Error) => void; + readonly repoRoot: string; + readonly rushVersion: string; + readonly startupOptions?: Readonly>; +} + +// @beta +export interface IRushDaemonServeOptions extends IRushDaemonHostOptions { + readonly onReady?: (host: RushDaemonHost) => void | Promise; + readonly shutdownSignal?: AbortSignal; +} + // @public export enum RequestExclusivityClass { // (undocumented) @@ -57,6 +74,17 @@ export enum RequestSchedulerErrorCode { WaitTimeout = "WAIT_TIMEOUT" } +// @beta +export class RushDaemonHost { + closeAsync(): Promise; + // (undocumented) + readonly paths: IDaemonPaths; + static startAsync(options: IRushDaemonHostOptions): Promise; +} + +// @beta +export function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise; + // (No @packageDocumentation comment for this package) ``` diff --git a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts index 8f3f6dcfac..49d87c195a 100644 --- a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts +++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts @@ -5,7 +5,7 @@ import { isDaemonControlMessageKind } from './DaemonControlMessage'; import { DaemonProtocolError } from './DaemonProtocolError'; import { isDaemonVerbosity } from './DaemonVerbosity'; -/** Returns `true` when `value` is a plain object. @beta */ +/** Returns `true` when `value` is a non-null object usable as a control record. @beta */ export function isDaemonControlRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -45,6 +45,12 @@ function validateHelloAck(payload: Record): void { requireStringField(payload, 'sessionId'); } +function validatePong(payload: Record): void { + if (payload.daemonVersion !== undefined) requireStringField(payload, 'daemonVersion'); + if (payload.protocolVersion !== undefined) requireVersion(payload); + requireNumberField(payload, 'uptimeMs'); +} + function validateSubscribe(payload: Record): void { if (typeof payload.isTTY !== 'boolean') { fail('Subscribe message payload.isTTY must be a boolean.'); @@ -73,13 +79,11 @@ const VALIDATORS_BY_KIND: Record = { subscribe: validateSubscribe, unsubscribe: noopValidator, ping: noopValidator, - pong: (payload: Record) => requireNumberField(payload, 'uptimeMs'), + pong: validatePong, error: validateError }; -/** - * Structurally validates a parsed control message. - * +/** Structurally validates a parsed control message. * @throws {@link DaemonProtocolError} when the value is not a well-formed control message. * * @beta diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts index 36b592b5e3..c20a04a3a5 100644 --- a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import type { IDaemonClientCaps } from './DaemonClientCaps'; +import type { IDaemonPongMessage } from './DaemonPongMessage'; import type { DaemonProtocolErrorCode } from './DaemonProtocolError'; import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; @@ -41,12 +42,6 @@ export interface IDaemonPingMessage { readonly payload: DaemonEmptyPayload; } -/** The liveness reply. @beta */ -export interface IDaemonPongMessage { - readonly kind: 'pong'; - readonly payload: { readonly uptimeMs: number }; -} - /** A protocol error sent on the wire. @beta */ export interface IDaemonErrorMessage { readonly kind: 'error'; @@ -89,7 +84,7 @@ export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [ 'error' ] = ['hello', 'helloAck', 'subscribe', 'unsubscribe', 'ping', 'pong', 'error']; -/** The union of control message `kind` discriminants, derived from the list. @beta */ +/** The union of control message `kind` discriminants, derived from the runtime list. @beta */ export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; const CONTROL_KIND_SET: ReadonlySet = new Set(DAEMON_CONTROL_MESSAGE_KINDS); diff --git a/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts new file mode 100644 index 0000000000..0538bf9189 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; + +/** The liveness reply. @beta */ +export interface IDaemonPongMessage { + readonly kind: 'pong'; + readonly payload: { + /** The daemon implementation version, when reported by protocol 0.2 or newer. */ + readonly daemonVersion?: string; + /** The daemon wire protocol version, when reported by protocol 0.2 or newer. */ + readonly protocolVersion?: IDaemonProtocolVersion; + readonly uptimeMs: number; + }; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts index 1bc51b155f..e4053bdc79 100644 --- a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts @@ -34,7 +34,7 @@ export interface IDaemonProtocolVersion { */ export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = { major: 0, - minor: 1 + minor: 2 }; /** diff --git a/libraries/rush-daemon-protocol/src/index.ts b/libraries/rush-daemon-protocol/src/index.ts index 4de98d0bb1..4ef7a7107c 100644 --- a/libraries/rush-daemon-protocol/src/index.ts +++ b/libraries/rush-daemon-protocol/src/index.ts @@ -30,7 +30,8 @@ export type { IDaemonClientCaps } from './DaemonClientCaps'; export { DAEMON_CONTROL_MESSAGE_KINDS, isDaemonControlMessageKind } from './DaemonControlMessage'; export type { DaemonControlMessage, DaemonControlMessageKind, DaemonEmptyPayload } from './DaemonControlMessage'; export type { IDaemonErrorMessage, IDaemonHelloAckMessage, IDaemonHelloMessage } from './DaemonControlMessage'; -export type { IDaemonPingMessage, IDaemonPongMessage, IDaemonSubscribeMessage, IDaemonUnsubscribeMessage } from './DaemonControlMessage'; +export type { IDaemonPingMessage, IDaemonSubscribeMessage, IDaemonUnsubscribeMessage } from './DaemonControlMessage'; +export type { IDaemonPongMessage } from './DaemonPongMessage'; export { isDaemonControlRecord, validateDaemonControlMessage } from './ControlMessageValidation'; export { decodeDaemonControlMessage, encodeDaemonControlMessage } from './ControlFrameCodec'; export { decodeDaemonLogChunk, encodeDaemonLogChunk, type IDaemonLogChunk } from './LogFrameCodec'; diff --git a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts index 7a73101e74..11a854a0ba 100644 --- a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts +++ b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts @@ -9,6 +9,7 @@ import { captureProtocolError } from './TestVectors'; const UPTIME_MS: number = 42; const COLUMNS: number = 120; +const DAEMON_VERSION: string = '5.178.1'; const MESSAGES: readonly DaemonControlMessage[] = [ { kind: 'hello', payload: { protocolVersion: DAEMON_PROTOCOL_VERSION } }, @@ -17,6 +18,14 @@ const MESSAGES: readonly DaemonControlMessage[] = [ { kind: 'unsubscribe', payload: {} }, { kind: 'ping', payload: {} }, { kind: 'pong', payload: { uptimeMs: UPTIME_MS } }, + { + kind: 'pong', + payload: { + daemonVersion: DAEMON_VERSION, + protocolVersion: DAEMON_PROTOCOL_VERSION, + uptimeMs: UPTIME_MS + } + }, { kind: 'error', payload: { code: 'malformedPayload', message: 'bad' } } ]; diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index 5dba14abaf..3e895e2ee2 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -1,5 +1,8 @@ # @rushstack/rush-daemon -The long-lived Rush workspace daemon host. +The long-lived Rush workspace daemon host, including workspace-keyed listener bootstrap, +protocol handshake and liveness control, and explicit serve/shutdown lifecycle APIs. -This package is under active development and is not yet integrated with the Rush command line. +The package provides an opt-in `rushd` executable. Run it from a Rush workspace to start the host +for the nearest `rush.json`; it does not change the default behavior of `rush`, `rushx`, or +`rush-pnpm`. diff --git a/libraries/rush-daemon/package.json b/libraries/rush-daemon/package.json index ae76afb22b..7daacb9ae7 100644 --- a/libraries/rush-daemon/package.json +++ b/libraries/rush-daemon/package.json @@ -5,6 +5,9 @@ "main": "./lib-commonjs/index.js", "module": "./lib-esm/index.js", "types": "./dist/rush-daemon.d.ts", + "bin": { + "rushd": "./lib-commonjs/start.js" + }, "exports": { ".": { "types": "./dist/rush-daemon.d.ts", @@ -41,6 +44,11 @@ "_phase:build": "heft run --only build -- --clean", "_phase:test": "heft run --only test -- --clean" }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/rush-daemon-transport": "workspace:*" + }, "devDependencies": { "@rushstack/heft": "workspace:*", "eslint": "~9.37.0", diff --git a/libraries/rush-daemon/src/DaemonControlSession.ts b/libraries/rush-daemon/src/DaemonControlSession.ts new file mode 100644 index 0000000000..85c3821132 --- /dev/null +++ b/libraries/rush-daemon/src/DaemonControlSession.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { randomUUID } from 'node:crypto'; + +import { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + DaemonProtocolError, + decodeDaemonControlMessage, + encodeDaemonControlMessage, + negotiateDaemonHello +} from '@rushstack/rush-daemon-protocol'; +import type { + DaemonControlMessage, + IDaemonErrorMessage, + IDaemonFrame, + IDaemonPongMessage +} from '@rushstack/rush-daemon-protocol'; +import type { DaemonFrameConnection } from '@rushstack/rush-daemon-transport'; + +export interface IDaemonControlSessionOptions { + readonly daemonVersion: string; + readonly startedAtMs: number; + readonly onClosed: (session: DaemonControlSession, error: Error | undefined) => void; + readonly onError: (error: Error) => void; +} + +export class DaemonControlSession { + private readonly _connection: DaemonFrameConnection; + private readonly _options: IDaemonControlSessionOptions; + private _handshakeComplete: boolean = false; + private _sendQueue: Promise = Promise.resolve(); + + public constructor(connection: DaemonFrameConnection, options: IDaemonControlSessionOptions) { + this._connection = connection; + this._options = options; + connection.onFrame((frame: IDaemonFrame) => this._onFrame(frame)); + connection.onClosed((error: Error | undefined) => options.onClosed(this, error)); + } + + public closeAsync(): Promise { + return this._connection.closeAsync(); + } + + private _onFrame(frame: IDaemonFrame): void { + if (frame.kind !== DaemonFrameType.controlJson) { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'A daemon control connection only accepts control frames.' + ); + } + const message: DaemonControlMessage = decodeDaemonControlMessage(frame.payload); + if (!this._handshakeComplete) { + this._handleHello(message); + } else if (message.kind === 'ping') { + this._send(this._createPong()); + } else { + throw new DaemonProtocolError( + 'malformedControlMessage', + `Control message "${message.kind}" is not valid in this daemon host state.` + ); + } + } + + private _handleHello(message: DaemonControlMessage): void { + if (message.kind !== 'hello') { + throw new DaemonProtocolError( + 'malformedControlMessage', + 'The first control message on a connection must be hello.' + ); + } + const outcome: ReturnType = negotiateDaemonHello( + message, + DAEMON_PROTOCOL_VERSION, + randomUUID() + ); + if (outcome.accepted) { + this._handshakeComplete = true; + this._send(outcome.ack); + } else { + const errorMessage: IDaemonErrorMessage = { + kind: 'error', + payload: { code: outcome.error.code, message: outcome.error.message } + }; + this._send(errorMessage, true); + } + } + + private _createPong(): IDaemonPongMessage { + return { + kind: 'pong', + payload: { + daemonVersion: this._options.daemonVersion, + protocolVersion: DAEMON_PROTOCOL_VERSION, + uptimeMs: Date.now() - this._options.startedAtMs + } + }; + } + + private _send(message: DaemonControlMessage, closeAfterSend: boolean = false): void { + const frame: IDaemonFrame = { + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }; + this._sendQueue = this._sendQueue + .then(() => this._connection.sendFrameAsync(frame)) + .then(() => (closeAfterSend ? this._connection.closeAsync() : undefined)) + .catch((error: unknown) => this._handleSendErrorAsync(error)); + } + + private async _handleSendErrorAsync(error: unknown): Promise { + const normalizedError: Error = error instanceof Error ? error : new Error(String(error)); + this._options.onError(normalizedError); + await this._connection.closeAsync(); + } +} diff --git a/libraries/rush-daemon/src/RushDaemonCommandLine.ts b/libraries/rush-daemon/src/RushDaemonCommandLine.ts new file mode 100644 index 0000000000..097f933027 --- /dev/null +++ b/libraries/rush-daemon/src/RushDaemonCommandLine.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +import { FileSystem, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library'; +import type { IPackageJson } from '@rushstack/node-core-library'; + +import { serveRushDaemonAsync } from './serveRushDaemon'; + +const RUSH_JSON_FILENAME: string = 'rush.json'; + +export interface IRushDaemonWorkspace { + readonly repoRoot: string; + readonly rushVersion: string; +} + +export function resolveRushDaemonWorkspace(startingFolder: string): IRushDaemonWorkspace { + const rushJsonPath: string = findRushJsonPath(startingFolder); + const rushJson: { rushVersion?: unknown } = JsonFile.load(rushJsonPath); + if (typeof rushJson.rushVersion !== 'string') { + throw new Error(`The "rushVersion" field in "${rushJsonPath}" must be a string.`); + } + return { + repoRoot: path.dirname(rushJsonPath), + rushVersion: rushJson.rushVersion + }; +} + +export async function launchRushDaemonAsync(startingFolder: string = process.cwd()): Promise { + const workspace: IRushDaemonWorkspace = resolveRushDaemonWorkspace(startingFolder); + const packageJson: IPackageJson | undefined = + PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname); + if (!packageJson) { + throw new Error('Unable to determine the @rushstack/rush-daemon package version.'); + } + await serveRushDaemonAsync({ + daemonVersion: packageJson.version, + repoRoot: workspace.repoRoot, + rushVersion: workspace.rushVersion, + onError: (error: Error) => process.stderr.write(`${error.stack ?? error.message}\n`), + onReady: (host) => { + process.stdout.write(`rushd ready at ${host.paths.socketPath}\n`); + } + }); +} + +function findRushJsonPath(startingFolder: string): string { + let currentFolder: string = path.resolve(startingFolder); + while (true) { + const candidatePath: string = path.join(currentFolder, RUSH_JSON_FILENAME); + if (FileSystem.exists(candidatePath)) { + return candidatePath; + } + const parentFolder: string = path.dirname(currentFolder); + if (parentFolder === currentFolder) { + throw new Error(`Unable to find ${RUSH_JSON_FILENAME} in "${startingFolder}" or its parents.`); + } + currentFolder = parentFolder; + } +} diff --git a/libraries/rush-daemon/src/RushDaemonHost.ts b/libraries/rush-daemon/src/RushDaemonHost.ts new file mode 100644 index 0000000000..f3a9cd4251 --- /dev/null +++ b/libraries/rush-daemon/src/RushDaemonHost.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { realpath } from 'node:fs/promises'; + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import { + computeDaemonWorkspaceKey, + DaemonFrameListener, + resolveDaemonPathsFromProcess +} from '@rushstack/rush-daemon-transport'; +import type { + DaemonFrameConnection, + IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import { DaemonControlSession } from './DaemonControlSession'; + +/** + * Options for starting one workspace daemon host. + * + * @beta + */ +export interface IRushDaemonHostOptions { + /** The daemon implementation version reported by `pong`. */ + readonly daemonVersion: string; + /** Reports connection-level failures. */ + readonly onError?: (error: Error) => void; + /** The repository root containing rush.json. */ + readonly repoRoot: string; + /** The selected Rush version used to isolate the workspace transport. */ + readonly rushVersion: string; + /** Additional stable options that distinguish daemon instances. */ + readonly startupOptions?: Readonly>; +} + +/** + * A bound, workspace-keyed Rush daemon host. + * + * @beta + */ +export class RushDaemonHost { + private readonly _listener: DaemonFrameListener; + private readonly _sessions: Set; + private readonly _lifecycle: { closing: boolean }; + public readonly paths: IDaemonPaths; + private _closePromise: Promise | undefined; + + private constructor( + listener: DaemonFrameListener, + paths: IDaemonPaths, + sessions: Set, + lifecycle: { closing: boolean } + ) { + this._listener = listener; + this.paths = paths; + this._sessions = sessions; + this._lifecycle = lifecycle; + } + + /** Resolves only after the transport is bound and its lockfile has been written. */ + public static async startAsync(options: IRushDaemonHostOptions): Promise { + const canonicalRepoRoot: string = await realpath(options.repoRoot); + const workspaceKey: string = computeDaemonWorkspaceKey({ + canonicalRepoRoot, + rushVersion: options.rushVersion, + startupOptions: options.startupOptions + }); + const paths: IDaemonPaths = resolveDaemonPathsFromProcess(workspaceKey); + const sessions: Set = new Set(); + const lifecycle: { closing: boolean } = { closing: false }; + const startedAtMs: number = Date.now(); + const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + startedAt: new Date(startedAtMs).toISOString(), + onConnection: (connection: DaemonFrameConnection) => { + const session: DaemonControlSession = new DaemonControlSession(connection, { + daemonVersion: options.daemonVersion, + startedAtMs, + onClosed: (closedSession: DaemonControlSession, error: Error | undefined) => { + sessions.delete(closedSession); + if (error) { + options.onError?.(error); + } + }, + onError: (error: Error) => options.onError?.(error) + }); + sessions.add(session); + if (lifecycle.closing) { + void session.closeAsync(); + } + } + }); + return new RushDaemonHost(listener, paths, sessions, lifecycle); + } + + /** Closes active connections, stops listening, and removes transport artifacts. */ + public closeAsync(): Promise { + this._closePromise ??= this._closeOnceAsync(); + return this._closePromise; + } + + private async _closeOnceAsync(): Promise { + this._lifecycle.closing = true; + await Promise.all(Array.from(this._sessions, (session: DaemonControlSession) => session.closeAsync())); + await this._listener.closeAsync(); + } +} diff --git a/libraries/rush-daemon/src/index.ts b/libraries/rush-daemon/src/index.ts index 1da4d820f9..a9178e9749 100644 --- a/libraries/rush-daemon/src/index.ts +++ b/libraries/rush-daemon/src/index.ts @@ -11,3 +11,5 @@ export { RequestSchedulerError, RequestSchedulerErrorCode } from './RequestScheduler'; +export { RushDaemonHost, type IRushDaemonHostOptions } from './RushDaemonHost'; +export { serveRushDaemonAsync, type IRushDaemonServeOptions } from './serveRushDaemon'; diff --git a/libraries/rush-daemon/src/serveRushDaemon.ts b/libraries/rush-daemon/src/serveRushDaemon.ts new file mode 100644 index 0000000000..a597058686 --- /dev/null +++ b/libraries/rush-daemon/src/serveRushDaemon.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { RushDaemonHost } from './RushDaemonHost'; +import type { IRushDaemonHostOptions } from './RushDaemonHost'; + +/** + * Options for the daemon serve lifecycle. + * + * @beta + */ +export interface IRushDaemonServeOptions extends IRushDaemonHostOptions { + /** Called after the listener is bound and the lockfile is available. */ + readonly onReady?: (host: RushDaemonHost) => void | Promise; + /** Requests a clean shutdown. Process signals are used when omitted. */ + readonly shutdownSignal?: AbortSignal; +} + +/** + * Starts a daemon host, signals readiness, and serves until shutdown is requested. + * + * @beta + */ +export async function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise { + const signalRegistration: IShutdownSignalRegistration = options.shutdownSignal + ? { signal: options.shutdownSignal, dispose: () => undefined } + : createProcessShutdownSignal(); + let host: RushDaemonHost | undefined; + try { + host = await RushDaemonHost.startAsync(options); + await options.onReady?.(host); + await waitForAbortAsync(signalRegistration.signal); + } finally { + signalRegistration.dispose(); + await host?.closeAsync(); + } +} + +interface IShutdownSignalRegistration { + readonly signal: AbortSignal; + readonly dispose: () => void; +} + +function createProcessShutdownSignal(): IShutdownSignalRegistration { + const controller: AbortController = new AbortController(); + const onSignal: () => void = () => controller.abort(); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + return { + signal: controller.signal, + dispose: () => { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + } + }; +} + +function waitForAbortAsync(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve: () => void) => signal.addEventListener('abort', () => resolve(), { once: true })); +} diff --git a/libraries/rush-daemon/src/start.ts b/libraries/rush-daemon/src/start.ts new file mode 100644 index 0000000000..7f1b6378c2 --- /dev/null +++ b/libraries/rush-daemon/src/start.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { launchRushDaemonAsync } from './RushDaemonCommandLine'; + +launchRushDaemonAsync().catch((error: Error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; +}); diff --git a/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts b/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts new file mode 100644 index 0000000000..e8c8306839 --- /dev/null +++ b/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { + resolveRushDaemonWorkspace, + type IRushDaemonWorkspace +} from '../RushDaemonCommandLine'; + +describe(resolveRushDaemonWorkspace.name, () => { + let tempFolder: string; + + beforeEach(async () => { + tempFolder = await mkdtemp(path.join(tmpdir(), 'rushd-cli-')); + }); + + afterEach(async () => { + await rm(tempFolder, { force: true, recursive: true }); + }); + + it('finds and reads the nearest rush.json from a nested folder', async () => { + const nestedFolder: string = path.join(tempFolder, 'apps', 'example'); + await mkdir(nestedFolder, { recursive: true }); + await writeFile( + path.join(tempFolder, 'rush.json'), + '{\n // The selected Rush version\n "rushVersion": "5.178.0"\n}\n' + ); + + const workspace: IRushDaemonWorkspace = resolveRushDaemonWorkspace(nestedFolder); + + expect(workspace).toEqual({ + repoRoot: tempFolder, + rushVersion: '5.178.0' + }); + }); + + it('rejects a rush.json without a string rushVersion', async () => { + await writeFile(path.join(tempFolder, 'rush.json'), '{ "rushVersion": 5 }\n'); + + expect(() => resolveRushDaemonWorkspace(tempFolder)).toThrow( + /The "rushVersion" field .* must be a string/ + ); + }); + + it('reports when no rush.json exists', () => { + expect(() => resolveRushDaemonWorkspace(tempFolder)).toThrow(/Unable to find rush\.json/); + }); +}); diff --git a/libraries/rush-daemon/src/test/RushDaemonHost.test.ts b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts new file mode 100644 index 0000000000..768d95e10e --- /dev/null +++ b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts @@ -0,0 +1,149 @@ +// 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 { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + createDaemonHello, + decodeDaemonControlMessage, + encodeDaemonControlMessage +} from '@rushstack/rush-daemon-protocol'; +import type { + DaemonControlMessage, + IDaemonFrame +} from '@rushstack/rush-daemon-protocol'; +import { + connectDaemonAsync, + readDaemonLockfile +} from '@rushstack/rush-daemon-transport'; +import type { + DaemonFrameConnection, + IDaemonPaths +} from '@rushstack/rush-daemon-transport'; + +import { RushDaemonHost } from '../RushDaemonHost'; +import { serveRushDaemonAsync } from '../serveRushDaemon'; + +const RUSH_VERSION: string = '5.178.1'; +const DAEMON_VERSION: string = '0.1.0-test'; +const WINDOWS_PIPE_PREFIX: string = '\\\\.\\pipe\\rushd-'; +const REPO_PREFIX: string = 'rush-daemon-host-test-'; + +const testRepoRoots: Set = new Set(); + +afterEach(() => { + for (const repoRoot of testRepoRoots) { + fs.rmSync(repoRoot, { force: true, recursive: true }); + } + testRepoRoots.clear(); +}); + +function createTestRepoRoot(): string { + const repoRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), REPO_PREFIX)); + testRepoRoots.add(repoRoot); + return repoRoot; +} + +function createHostOptions(repoRoot: string): { + daemonVersion: string; + repoRoot: string; + rushVersion: string; +} { + return { daemonVersion: DAEMON_VERSION, repoRoot, rushVersion: RUSH_VERSION }; +} + +async function exchangeControlAsync( + connection: DaemonFrameConnection, + message: DaemonControlMessage +): Promise { + const response: Promise = new Promise( + (resolve: (message: DaemonControlMessage) => void) => { + connection.onFrame((frame: IDaemonFrame) => resolve(decodeDaemonControlMessage(frame.payload))); + } + ); + await connection.sendFrameAsync({ + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage(message) + }); + return response; +} + +describe(RushDaemonHost.name, () => { + it('binds the workspace transport and handles hello plus ping', async () => { + const host: RushDaemonHost = await RushDaemonHost.startAsync( + createHostOptions(createTestRepoRoot()) + ); + const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath); + try { + expect(readDaemonLockfile(host.paths.lockfilePath)).toMatchObject({ + pid: process.pid, + protocolVersion: DAEMON_PROTOCOL_VERSION, + socketPath: host.paths.socketPath + }); + if (process.platform === 'win32') { + expect(host.paths.socketPath.startsWith(WINDOWS_PIPE_PREFIX)).toBe(true); + } + const helloAck: DaemonControlMessage = await exchangeControlAsync( + client, + createDaemonHello(DAEMON_PROTOCOL_VERSION) + ); + expect(helloAck).toMatchObject({ + kind: 'helloAck', + payload: { protocolVersion: DAEMON_PROTOCOL_VERSION } + }); + const pong: DaemonControlMessage = await exchangeControlAsync(client, { + kind: 'ping', + payload: {} + }); + expect(pong).toMatchObject({ + kind: 'pong', + payload: { + daemonVersion: DAEMON_VERSION, + protocolVersion: DAEMON_PROTOCOL_VERSION + } + }); + } finally { + await client.closeAsync(); + await host.closeAsync(); + } + }); + + it('signals readiness only after the listener and lockfile are available', async () => { + const controller: AbortController = new AbortController(); + let readyPaths: IDaemonPaths | undefined; + const onReadyAsync: (host: RushDaemonHost) => Promise = async (host: RushDaemonHost) => { + readyPaths = host.paths; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined(); + const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath); + await client.closeAsync(); + controller.abort(); + }; + await serveRushDaemonAsync({ + ...createHostOptions(createTestRepoRoot()), + shutdownSignal: controller.signal, + onReady: onReadyAsync + }); + if (!readyPaths) { + throw new Error('The daemon did not signal readiness.'); + } + expect(readDaemonLockfile(readyPaths.lockfilePath)).toBeUndefined(); + }); + + it('closes active connections and removes transport artifacts', async () => { + const host: RushDaemonHost = await RushDaemonHost.startAsync( + createHostOptions(createTestRepoRoot()) + ); + const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath); + const closed: Promise = new Promise((resolve: () => void) => client.onClosed(() => resolve())); + await host.closeAsync(); + await closed; + expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined(); + await expect(connectDaemonAsync(host.paths.socketPath)).rejects.toMatchObject({ + code: 'connectionRefused' + }); + }); +});