Skip to content
Open
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",
"comment": "Add a reusable warm workspace session with stable Rush configuration metadata, retained headless invalidations, and deterministic host lifecycle integration.",
"type": "minor"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "mojazayeri@users.noreply.github.com"
}
3 changes: 3 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.

116 changes: 116 additions & 0 deletions common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@
/// <reference types="node" />

import type { IDaemonPaths } from '@rushstack/rush-daemon-transport';
import type { IInputsSnapshot } from '@microsoft/rush-lib';
import type { IOperationGraph } from '@microsoft/rush-lib';
import { RushConfiguration } from '@microsoft/rush-lib';
import type { RushSession } from '@microsoft/rush-lib';

// @beta
export type CreateWorkspaceSessionComponentsAsync = (options: ICreateWorkspaceSessionComponentsOptions) => Promise<IWorkspaceSessionComponents>;

// @beta
export interface ICreateWorkspaceSessionComponentsOptions {
// (undocumented)
readonly invalidations: WorkspaceInvalidationTracker;
// (undocumented)
readonly onError?: (error: Error) => void;
// (undocumented)
readonly rushConfiguration: RushConfiguration;
}

// @public
export interface IRequestLease {
Expand All @@ -27,6 +44,7 @@ export interface IRequestSchedulerAcquireOptions {

// @beta
export interface IRushDaemonHostOptions {
readonly createWorkspaceSessionAsync?: WorkspaceSessionFactory;
readonly daemonVersion: string;
readonly onError?: (error: Error) => void;
readonly repoRoot: string;
Expand All @@ -40,6 +58,74 @@ export interface IRushDaemonServeOptions extends IRushDaemonHostOptions {
readonly shutdownSignal?: AbortSignal;
}

// @beta
export interface IWorkspaceInvalidationSnapshot {
readonly changedPaths: ReadonlyArray<string>;
readonly hasUnknownChanges: boolean;
readonly isWatcherHealthy: boolean;
readonly sequence: number;
}

// @beta
export interface IWorkspaceInvalidationWatcher extends AsyncDisposable {
// (undocumented)
startAsync(onInvalidation: (changedPath?: string) => void): Promise<void>;
}

// @beta
export interface IWorkspaceSession extends AsyncDisposable {
// (undocumented)
readonly inputsSnapshot: IInputsSnapshot | undefined;
// (undocumented)
readonly invalidations: WorkspaceInvalidationTracker;
// (undocumented)
readonly metadata: IWorkspaceSessionMetadata;
// (undocumented)
readonly operationGraph: IOperationGraph | undefined;
// (undocumented)
readonly rushConfiguration: RushConfiguration;
// (undocumented)
readonly rushSession: RushSession | undefined;
}

// @beta
export interface IWorkspaceSessionComponents extends AsyncDisposable {
// (undocumented)
readonly inputsSnapshot?: IInputsSnapshot;
// (undocumented)
readonly operationGraph?: IOperationGraph;
// (undocumented)
readonly projectWatcher?: IWorkspaceInvalidationWatcher;
// (undocumented)
readonly rushSession?: RushSession;
}

// @beta
export interface IWorkspaceSessionMetadata {
// (undocumented)
readonly projectCount: number;
// (undocumented)
readonly projectNames: ReadonlyArray<string>;
// (undocumented)
readonly repoRoot: string;
// (undocumented)
readonly rushJsonFile: string;
// (undocumented)
readonly rushVersion: string;
}

// @beta
export interface IWorkspaceSessionOptions {
// (undocumented)
readonly createComponentsAsync?: CreateWorkspaceSessionComponentsAsync;
// (undocumented)
readonly onError?: (error: Error) => void;
// (undocumented)
readonly repoRoot: string;
// (undocumented)
readonly rushVersion: string;
}

// @public
export enum RequestExclusivityClass {
// (undocumented)
Expand Down Expand Up @@ -77,6 +163,7 @@ export enum RequestSchedulerErrorCode {
// @beta
export class RushDaemonHost {
closeAsync(): Promise<void>;
getWorkspaceSessionAsync(): Promise<IWorkspaceSession>;
// (undocumented)
readonly paths: IDaemonPaths;
static startAsync(options: IRushDaemonHostOptions): Promise<RushDaemonHost>;
Expand All @@ -85,6 +172,35 @@ export class RushDaemonHost {
// @beta
export function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise<void>;

// @beta
export class WorkspaceInvalidationTracker {
acknowledgeThrough(sequence: number): void;
getSnapshot(): IWorkspaceInvalidationSnapshot;
invalidate(changedPath?: string): void;
markWatcherUnhealthy(): void;
}

// @beta
export class WorkspaceSession implements IWorkspaceSession {
[Symbol.asyncDispose](): Promise<void>;
static createAsync(options: IWorkspaceSessionOptions): Promise<WorkspaceSession>;
// (undocumented)
readonly inputsSnapshot: IInputsSnapshot | undefined;
// (undocumented)
readonly invalidations: WorkspaceInvalidationTracker;
// (undocumented)
readonly metadata: IWorkspaceSessionMetadata;
// (undocumented)
readonly operationGraph: IOperationGraph | undefined;
// (undocumented)
readonly rushConfiguration: RushConfiguration;
// (undocumented)
readonly rushSession: RushSession | undefined;
}

// @beta
export type WorkspaceSessionFactory = (options: IWorkspaceSessionOptions) => Promise<IWorkspaceSession>;

// (No @packageDocumentation comment for this package)

```
12 changes: 11 additions & 1 deletion libraries/rush-daemon/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
# @rushstack/rush-daemon

The long-lived Rush workspace daemon host, including workspace-keyed listener bootstrap,
protocol handshake and liveness control, and explicit serve/shutdown lifecycle APIs.
protocol handshake and liveness control, a warm `WorkspaceSession`, and explicit
serve/shutdown lifecycle APIs.

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`.

The host loads `RushConfiguration` once before signaling readiness and keeps a headless file watcher
active for the daemon lifetime. Its invalidation tracker retains changes while no clients are
connected so a later request can reconcile them. The tracker starts with a conservative unknown
invalidation covering session startup, and excessive distinct paths are compacted into the same
full-workspace signal. Reusable operation graph, plugin, and input snapshot state can be supplied
through the session component factory; the default session does not construct those command-specific
resources while the reusable runner lifetime tracked by
[rushstack#5895](https://github.com/microsoft/rushstack/issues/5895) remains incomplete.
1 change: 1 addition & 0 deletions libraries/rush-daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"_phase:test": "heft run --only test -- --clean"
},
"dependencies": {
"@microsoft/rush-lib": "workspace:*",
"@rushstack/node-core-library": "workspace:*",
"@rushstack/rush-daemon-protocol": "workspace:*",
"@rushstack/rush-daemon-transport": "workspace:*"
Expand Down
109 changes: 86 additions & 23 deletions libraries/rush-daemon/src/RushDaemonHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ import type {
} from '@rushstack/rush-daemon-transport';

import { DaemonControlSession } from './DaemonControlSession';
import { WorkspaceSession } from './WorkspaceSession';
import type { IWorkspaceSession, WorkspaceSessionFactory } from './WorkspaceSession';
import { WorkspaceSessionProvider } from './WorkspaceSessionProvider';

/**
* Options for starting one workspace daemon host.
*
* @beta
*/
export interface IRushDaemonHostOptions {
/** Overrides workspace session construction for engine integration or testing. */
readonly createWorkspaceSessionAsync?: WorkspaceSessionFactory;
/** The daemon implementation version reported by `pong`. */
readonly daemonVersion: string;
/** Reports connection-level failures. */
Expand All @@ -42,6 +47,7 @@ export interface IRushDaemonHostOptions {
export class RushDaemonHost {
private readonly _listener: DaemonFrameListener;
private readonly _sessions: Set<DaemonControlSession>;
private readonly _workspaceSessionProvider: WorkspaceSessionProvider;
private readonly _lifecycle: { closing: boolean };
public readonly paths: IDaemonPaths;
private _closePromise: Promise<void> | undefined;
Expand All @@ -50,12 +56,14 @@ export class RushDaemonHost {
listener: DaemonFrameListener,
paths: IDaemonPaths,
sessions: Set<DaemonControlSession>,
lifecycle: { closing: boolean }
lifecycle: { closing: boolean },
workspaceSessionProvider: WorkspaceSessionProvider
) {
this._listener = listener;
this.paths = paths;
this._sessions = sessions;
this._lifecycle = lifecycle;
this._workspaceSessionProvider = workspaceSessionProvider;
}

/** Resolves only after the transport is bound and its lockfile has been written. */
Expand All @@ -69,29 +77,62 @@ export class RushDaemonHost {
const paths: IDaemonPaths = resolveDaemonPathsFromProcess(workspaceKey);
const sessions: Set<DaemonControlSession> = new Set();
const lifecycle: { closing: boolean } = { closing: false };
const workspaceSessionProvider: WorkspaceSessionProvider = new WorkspaceSessionProvider(
options.createWorkspaceSessionAsync ?? WorkspaceSession.createAsync,
{
onError: options.onError,
repoRoot: canonicalRepoRoot,
rushVersion: options.rushVersion
}
);
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();
await workspaceSessionProvider.getSessionAsync();
let listener: DaemonFrameListener;
try {
listener = 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();
}
}
});
} catch (error) {
try {
await workspaceSessionProvider[Symbol.asyncDispose]();
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
'Failed to bind the daemon listener and dispose its workspace session.'
);
}
});
return new RushDaemonHost(listener, paths, sessions, lifecycle);
throw error;
}
return new RushDaemonHost(
listener,
paths,
sessions,
lifecycle,
workspaceSessionProvider
);
}

/** Returns the single warm workspace session owned by this host. */
public getWorkspaceSessionAsync(): Promise<IWorkspaceSession> {
return this._workspaceSessionProvider.getSessionAsync();
}

/** Closes active connections, stops listening, and removes transport artifacts. */
Expand All @@ -102,7 +143,29 @@ export class RushDaemonHost {

private async _closeOnceAsync(): Promise<void> {
this._lifecycle.closing = true;
await Promise.all(Array.from(this._sessions, (session: DaemonControlSession) => session.closeAsync()));
await this._listener.closeAsync();
const errors: unknown[] = [];
try {
await Promise.all(
Array.from(this._sessions, (session: DaemonControlSession) => session.closeAsync())
);
} catch (error) {
errors.push(error);
}
try {
await this._listener.closeAsync();
} catch (error) {
errors.push(error);
}
try {
await this._workspaceSessionProvider[Symbol.asyncDispose]();
} catch (error) {
errors.push(error);
}

if (errors.length === 1) {
throw errors[0];
} else if (errors.length > 1) {
throw new AggregateError(errors, 'Failed to close Rush daemon host resources.');
}
}
}
Loading
Loading