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
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 1 addition & 1 deletion common/config/rush/browser-approved-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
},
{
"name": "@rushstack/rush-daemon-transport",
"allowedCategories": [ "tests" ]
"allowedCategories": [ "libraries", "tests" ]
},
{
"name": "@rushstack/rush-serve-dashboard",
Expand Down
10 changes: 10 additions & 0 deletions common/config/subspaces/default/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions common/reviews/api/rush-daemon-protocol.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,8 @@ export interface IDaemonPongMessage {
readonly kind: 'pong';
// (undocumented)
readonly payload: {
readonly daemonVersion?: string;
readonly protocolVersion?: IDaemonProtocolVersion;
readonly uptimeMs: number;
};
}
Expand Down
28 changes: 28 additions & 0 deletions common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

/// <reference types="node" />

import type { IDaemonPaths } from '@rushstack/rush-daemon-transport';

// @public
export interface IRequestLease {
// (undocumented)
Expand All @@ -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<Record<string, unknown>>;
}

// @beta
export interface IRushDaemonServeOptions extends IRushDaemonHostOptions {
readonly onReady?: (host: RushDaemonHost) => void | Promise<void>;
readonly shutdownSignal?: AbortSignal;
}

// @public
export enum RequestExclusivityClass {
// (undocumented)
Expand Down Expand Up @@ -57,6 +74,17 @@ export enum RequestSchedulerErrorCode {
WaitTimeout = "WAIT_TIMEOUT"
}

// @beta
export class RushDaemonHost {
closeAsync(): Promise<void>;
// (undocumented)
readonly paths: IDaemonPaths;
static startAsync(options: IRushDaemonHostOptions): Promise<RushDaemonHost>;
}

// @beta
export function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise<void>;

// (No @packageDocumentation comment for this package)

```
14 changes: 9 additions & 5 deletions libraries/rush-daemon-protocol/src/ControlMessageValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return typeof value === 'object' && value !== null;
}
Expand Down Expand Up @@ -45,6 +45,12 @@ function validateHelloAck(payload: Record<string, unknown>): void {
requireStringField(payload, 'sessionId');
}

function validatePong(payload: Record<string, unknown>): void {
if (payload.daemonVersion !== undefined) requireStringField(payload, 'daemonVersion');
if (payload.protocolVersion !== undefined) requireVersion(payload);
requireNumberField(payload, 'uptimeMs');
}

function validateSubscribe(payload: Record<string, unknown>): void {
if (typeof payload.isTTY !== 'boolean') {
fail('Subscribe message payload.isTTY must be a boolean.');
Expand Down Expand Up @@ -73,13 +79,11 @@ const VALIDATORS_BY_KIND: Record<string, ControlValidator> = {
subscribe: validateSubscribe,
unsubscribe: noopValidator,
ping: noopValidator,
pong: (payload: Record<string, unknown>) => 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
Expand Down
9 changes: 2 additions & 7 deletions libraries/rush-daemon-protocol/src/DaemonControlMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> = new Set<string>(DAEMON_CONTROL_MESSAGE_KINDS);
Expand Down
16 changes: 16 additions & 0 deletions libraries/rush-daemon-protocol/src/DaemonPongMessage.ts
Original file line number Diff line number Diff line change
@@ -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;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface IDaemonProtocolVersion {
*/
export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = {
major: 0,
minor: 1
minor: 2
};

/**
Expand Down
3 changes: 2 additions & 1 deletion libraries/rush-daemon-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand All @@ -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' } }
];

Expand Down
7 changes: 5 additions & 2 deletions libraries/rush-daemon/README.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions libraries/rush-daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -41,6 +44,11 @@
"_phase:build": "heft run --only build -- --clean",
"_phase:test": "heft run --only test -- --clean"
},
"dependencies": {
Comment thread
mojaza marked this conversation as resolved.
"@rushstack/node-core-library": "workspace:*",
"@rushstack/rush-daemon-protocol": "workspace:*",
"@rushstack/rush-daemon-transport": "workspace:*"
},
"devDependencies": {
"@rushstack/heft": "workspace:*",
"eslint": "~9.37.0",
Expand Down
117 changes: 117 additions & 0 deletions libraries/rush-daemon/src/DaemonControlSession.ts
Original file line number Diff line number Diff line change
@@ -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<void> = 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<void> {
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<typeof negotiateDaemonHello> = 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<void> {
const normalizedError: Error = error instanceof Error ? error : new Error(String(error));
this._options.onError(normalizedError);
await this._connection.closeAsync();
}
}
Loading