diff --git a/README.md b/README.md
index e40ff5073d8..580b61f0bed 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ These GitHub repositories provide supplementary resources for Rush Stack:
| [/libraries/package-extractor](./libraries/package-extractor/) | [](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor) | [changelog](./libraries/package-extractor/CHANGELOG.md) | [@rushstack/package-extractor](https://www.npmjs.com/package/@rushstack/package-extractor) |
| [/libraries/problem-matcher](./libraries/problem-matcher/) | [](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) |
| [/libraries/rig-package](./libraries/rig-package/) | [](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) |
+| [/libraries/rush-daemon](./libraries/rush-daemon/) | [](https://badge.fury.io/js/%40rushstack%2Frush-daemon) | [changelog](./libraries/rush-daemon/CHANGELOG.md) | [@rushstack/rush-daemon](https://www.npmjs.com/package/@rushstack/rush-daemon) |
| [/libraries/rush-lib](./libraries/rush-lib/) | [](https://badge.fury.io/js/%40microsoft%2Frush-lib) | | [@microsoft/rush-lib](https://www.npmjs.com/package/@microsoft/rush-lib) |
| [/libraries/rush-pnpm-kit-v10](./libraries/rush-pnpm-kit-v10/) | [](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10) | [changelog](./libraries/rush-pnpm-kit-v10/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v10](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v10) |
| [/libraries/rush-pnpm-kit-v8](./libraries/rush-pnpm-kit-v8/) | [](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v8) | [changelog](./libraries/rush-pnpm-kit-v8/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v8](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v8) |
diff --git a/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-scheduler_2026-08-13-22-30.json b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-scheduler_2026-08-13-22-30.json
new file mode 100644
index 00000000000..ad4340eb91b
--- /dev/null
+++ b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-scheduler_2026-08-13-22-30.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "packageName": "@rushstack/rush-daemon",
+ "comment": "Add the initial daemon request scheduler.",
+ "type": "minor"
+ }
+ ],
+ "packageName": "@rushstack/rush-daemon",
+ "email": "mojazayeri@users.noreply.github.com"
+}
diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml
index 4ec1b48ee88..c6a6ca6d3c4 100644
--- a/common/config/subspaces/default/pnpm-lock.yaml
+++ b/common/config/subspaces/default/pnpm-lock.yaml
@@ -4048,6 +4048,18 @@ importers:
specifier: ~9.37.0
version: 9.37.0
+ ../../../libraries/rush-daemon:
+ devDependencies:
+ '@rushstack/heft':
+ specifier: workspace:*
+ version: link:../../apps/heft
+ eslint:
+ specifier: ~9.37.0
+ version: 9.37.0
+ local-node-rig:
+ specifier: workspace:*
+ version: link:../../rigs/local-node-rig
+
../../../libraries/rush-lib:
dependencies:
'@inquirer/checkbox':
diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md
new file mode 100644
index 00000000000..e32d01c6bc8
--- /dev/null
+++ b/common/reviews/api/rush-daemon.api.md
@@ -0,0 +1,62 @@
+## API Report File for "@rushstack/rush-daemon"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+///
+
+// @public
+export interface IRequestLease {
+ // (undocumented)
+ readonly exclusivityClass: RequestExclusivityClass;
+ // (undocumented)
+ release(): void;
+}
+
+// @public
+export interface IRequestSchedulerAcquireOptions {
+ abortSignal?: AbortSignal;
+ exclusivityClass: RequestExclusivityClass;
+ noWait?: boolean;
+ onQueuePositionChanged?: (position: number) => void;
+ waitTimeoutMs?: number;
+}
+
+// @public
+export enum RequestExclusivityClass {
+ // (undocumented)
+ Exclusive = "EXCLUSIVE",
+ // (undocumented)
+ SharedBuild = "SHARED-BUILD",
+ // (undocumented)
+ SharedRead = "SHARED-READ"
+}
+
+// @public
+export class RequestScheduler {
+ acquireAsync(options: IRequestSchedulerAcquireOptions): Promise;
+ get activeRequestCount(): number;
+ get queuedRequestCount(): number;
+}
+
+// @public
+export class RequestSchedulerError extends Error {
+ constructor(code: RequestSchedulerErrorCode, message: string);
+ // (undocumented)
+ readonly code: RequestSchedulerErrorCode;
+}
+
+// @public
+export enum RequestSchedulerErrorCode {
+ // (undocumented)
+ Aborted = "ABORTED",
+ // (undocumented)
+ NoWait = "NO_WAIT",
+ // (undocumented)
+ WaitTimeout = "WAIT_TIMEOUT"
+}
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/libraries/rush-daemon/.npmignore b/libraries/rush-daemon/.npmignore
new file mode 100644
index 00000000000..f7a40e10213
--- /dev/null
+++ b/libraries/rush-daemon/.npmignore
@@ -0,0 +1,36 @@
+# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO.
+
+# Ignore all files by default, to avoid accidentally publishing unintended files.
+*
+
+# Use negative patterns to bring back the specific things we want to publish.
+!/bin/**
+!/lib/**
+!/lib-*/**
+!/dist/**
+!/includes/**
+
+!CHANGELOG.md
+!CHANGELOG.json
+!heft-plugin.json
+!rush-plugin-manifest.json
+!ThirdPartyNotice.txt
+
+# Ignore certain patterns that should not get published.
+/dist/*.stats.*
+/lib/**/test/
+/lib-*/**/test/
+*.test.js
+*.test.[cm]js
+*.test.d.ts
+*.test.d.[cm]ts
+
+# NOTE: These don't need to be specified, because NPM includes them automatically.
+#
+# package.json
+# README.md
+# LICENSE
+
+# ---------------------------------------------------------------------------
+# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below.
+# ---------------------------------------------------------------------------
diff --git a/libraries/rush-daemon/CHANGELOG.json b/libraries/rush-daemon/CHANGELOG.json
new file mode 100644
index 00000000000..74ce0d171db
--- /dev/null
+++ b/libraries/rush-daemon/CHANGELOG.json
@@ -0,0 +1,4 @@
+{
+ "name": "@rushstack/rush-daemon",
+ "entries": []
+}
diff --git a/libraries/rush-daemon/CHANGELOG.md b/libraries/rush-daemon/CHANGELOG.md
new file mode 100644
index 00000000000..1dd2ac2ea1d
--- /dev/null
+++ b/libraries/rush-daemon/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Change Log - @rushstack/rush-daemon
+
+This log was last generated on Tue, 11 Aug 2026 00:00:00 GMT and should not be manually modified.
diff --git a/libraries/rush-daemon/LICENSE b/libraries/rush-daemon/LICENSE
new file mode 100644
index 00000000000..c4b670983ab
--- /dev/null
+++ b/libraries/rush-daemon/LICENSE
@@ -0,0 +1,24 @@
+@rushstack/rush-daemon
+
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md
new file mode 100644
index 00000000000..5dba14abaf5
--- /dev/null
+++ b/libraries/rush-daemon/README.md
@@ -0,0 +1,5 @@
+# @rushstack/rush-daemon
+
+The long-lived Rush workspace daemon host.
+
+This package is under active development and is not yet integrated with the Rush command line.
diff --git a/libraries/rush-daemon/config/api-extractor.json b/libraries/rush-daemon/config/api-extractor.json
new file mode 100644
index 00000000000..3dbb76c0e6f
--- /dev/null
+++ b/libraries/rush-daemon/config/api-extractor.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "extends": "local-node-rig/profiles/default/config/api-extractor-base.json"
+}
diff --git a/libraries/rush-daemon/config/jest.config.json b/libraries/rush-daemon/config/jest.config.json
new file mode 100644
index 00000000000..d1749681d90
--- /dev/null
+++ b/libraries/rush-daemon/config/jest.config.json
@@ -0,0 +1,3 @@
+{
+ "extends": "local-node-rig/profiles/default/config/jest.config.json"
+}
diff --git a/libraries/rush-daemon/config/rig.json b/libraries/rush-daemon/config/rig.json
new file mode 100644
index 00000000000..165ffb001f5
--- /dev/null
+++ b/libraries/rush-daemon/config/rig.json
@@ -0,0 +1,7 @@
+{
+ // The "rig.json" file directs tools to look for their config files in an external package.
+ // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
+
+ "rigPackageName": "local-node-rig"
+}
diff --git a/libraries/rush-daemon/eslint.config.js b/libraries/rush-daemon/eslint.config.js
new file mode 100644
index 00000000000..283ad708e70
--- /dev/null
+++ b/libraries/rush-daemon/eslint.config.js
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+const nodeTrustedToolProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node-trusted-tool');
+const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals');
+const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc');
+
+module.exports = [
+ ...nodeTrustedToolProfile,
+ ...friendlyLocalsMixin,
+ ...tsdocMixin,
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parserOptions: {
+ tsconfigRootDir: __dirname
+ }
+ }
+ }
+];
diff --git a/libraries/rush-daemon/package.json b/libraries/rush-daemon/package.json
new file mode 100644
index 00000000000..151bb4b891f
--- /dev/null
+++ b/libraries/rush-daemon/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@rushstack/rush-daemon",
+ "version": "0.1.0",
+ "description": "The long-lived Rush workspace daemon host",
+ "main": "./lib-commonjs/index.js",
+ "module": "./lib-esm/index.js",
+ "types": "./dist/rush-daemon.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/rush-daemon.d.ts",
+ "node": "./lib-commonjs/index.js",
+ "import": "./lib-esm/index.js",
+ "require": "./lib-commonjs/index.js"
+ },
+ "./lib/*": {
+ "types": "./lib-dts/*.d.ts",
+ "node": "./lib-commonjs/*.js",
+ "import": "./lib-esm/*.js",
+ "require": "./lib-commonjs/*.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "typesVersions": {
+ "*": {
+ "lib/*": [
+ "lib-dts/*"
+ ]
+ }
+ },
+ "license": "MIT",
+ "repository": {
+ "url": "https://github.com/microsoft/rushstack.git",
+ "type": "git",
+ "directory": "libraries/rush-daemon"
+ },
+ "scripts": {
+ "build": "heft build --clean",
+ "_phase:build": "heft run --only build -- --clean",
+ "_phase:test": "heft run --only test -- --clean"
+ },
+ "devDependencies": {
+ "@rushstack/heft": "workspace:*",
+ "eslint": "~9.37.0",
+ "local-node-rig": "workspace:*"
+ },
+ "peerDependencies": {
+ "@types/node": "*"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ },
+ "sideEffects": false
+}
diff --git a/libraries/rush-daemon/src/RequestScheduler.ts b/libraries/rush-daemon/src/RequestScheduler.ts
new file mode 100644
index 00000000000..1effea187e0
--- /dev/null
+++ b/libraries/rush-daemon/src/RequestScheduler.ts
@@ -0,0 +1,269 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+/**
+ * Describes which daemon requests may execute concurrently.
+ *
+ * @public
+ */
+export enum RequestExclusivityClass {
+ SharedBuild = 'SHARED-BUILD',
+ SharedRead = 'SHARED-READ',
+ Exclusive = 'EXCLUSIVE'
+}
+
+/**
+ * Identifies why admission to the scheduler failed.
+ *
+ * @public
+ */
+export enum RequestSchedulerErrorCode {
+ Aborted = 'ABORTED',
+ NoWait = 'NO_WAIT',
+ WaitTimeout = 'WAIT_TIMEOUT'
+}
+
+/**
+ * An error raised when a request cannot be admitted.
+ *
+ * @public
+ */
+export class RequestSchedulerError extends Error {
+ public readonly code: RequestSchedulerErrorCode;
+
+ public constructor(code: RequestSchedulerErrorCode, message: string) {
+ super(message);
+ this.name = RequestSchedulerError.name;
+ this.code = code;
+ }
+}
+
+/**
+ * Options that control admission to a {@link RequestScheduler}.
+ *
+ * @public
+ */
+export interface IRequestSchedulerAcquireOptions {
+ /**
+ * The compatibility class for the request.
+ */
+ exclusivityClass: RequestExclusivityClass;
+
+ /**
+ * Fail immediately instead of entering the queue.
+ */
+ noWait?: boolean;
+
+ /**
+ * The maximum time to wait in the queue. There is no timeout when omitted.
+ */
+ waitTimeoutMs?: number;
+
+ /**
+ * Cancels this request while it is waiting. Releasing an admitted request remains the caller's responsibility.
+ */
+ abortSignal?: AbortSignal;
+
+ /**
+ * Called with the request's one-based position whenever the queue changes.
+ */
+ onQueuePositionChanged?: (position: number) => void;
+}
+
+/**
+ * A scheduler admission. The caller must release the lease when its request finishes.
+ *
+ * @public
+ */
+export interface IRequestLease {
+ readonly exclusivityClass: RequestExclusivityClass;
+ release(): void;
+}
+
+interface IQueuedRequest {
+ readonly options: IRequestSchedulerAcquireOptions;
+ readonly resolve: (lease: IRequestLease) => void;
+ readonly reject: (error: Error) => void;
+ timeout: NodeJS.Timeout | undefined;
+ abortListener: (() => void) | undefined;
+}
+
+/**
+ * Provides fair, queue-and-wait admission for daemon requests.
+ *
+ * Requests of the same shared class may execute concurrently. Different shared classes are serialized because
+ * they access different consistency views of the workspace. Exclusive requests execute alone. Once an exclusive
+ * request reaches the queue, it gates all requests behind it until it has executed.
+ *
+ * @public
+ */
+export class RequestScheduler {
+ private readonly _queue: IQueuedRequest[] = [];
+ private _activeClass: RequestExclusivityClass | undefined;
+ private _activeRequestCount: number = 0;
+
+ /**
+ * The number of requests currently waiting for admission.
+ */
+ public get queuedRequestCount(): number {
+ return this._queue.length;
+ }
+
+ /**
+ * The number of requests that currently hold a lease.
+ */
+ public get activeRequestCount(): number {
+ return this._activeRequestCount;
+ }
+
+ /**
+ * Waits until the request is compatible with all active requests and earlier queued requests.
+ */
+ public acquireAsync(options: IRequestSchedulerAcquireOptions): Promise {
+ this._validateOptions(options);
+
+ if (options.abortSignal?.aborted) {
+ return Promise.reject(
+ new RequestSchedulerError(RequestSchedulerErrorCode.Aborted, 'The request was aborted before admission.')
+ );
+ }
+
+ if (this._queue.length === 0 && this._canAdmit(options.exclusivityClass)) {
+ return Promise.resolve(this._createLease(options.exclusivityClass));
+ }
+
+ if (options.noWait) {
+ return Promise.reject(
+ new RequestSchedulerError(
+ RequestSchedulerErrorCode.NoWait,
+ 'The request cannot be admitted immediately and --no-wait was specified.'
+ )
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ const request: IQueuedRequest = {
+ options,
+ resolve,
+ reject,
+ timeout: undefined,
+ abortListener: undefined
+ };
+
+ if (options.waitTimeoutMs !== undefined) {
+ request.timeout = setTimeout(() => {
+ this._rejectQueuedRequest(
+ request,
+ new RequestSchedulerError(
+ RequestSchedulerErrorCode.WaitTimeout,
+ `The request was not admitted within ${options.waitTimeoutMs}ms.`
+ )
+ );
+ }, options.waitTimeoutMs);
+ }
+
+ if (options.abortSignal) {
+ request.abortListener = () => {
+ this._rejectQueuedRequest(
+ request,
+ new RequestSchedulerError(RequestSchedulerErrorCode.Aborted, 'The request was aborted while waiting.')
+ );
+ };
+ options.abortSignal.addEventListener('abort', request.abortListener, { once: true });
+ }
+ this._queue.push(request);
+ this._notifyQueuePositions();
+ this._drainQueue();
+ });
+ }
+
+ private _validateOptions(options: IRequestSchedulerAcquireOptions): void {
+ if (
+ options.waitTimeoutMs !== undefined &&
+ (!Number.isFinite(options.waitTimeoutMs) || options.waitTimeoutMs < 0)
+ ) {
+ throw new RangeError('waitTimeoutMs must be a nonnegative finite number.');
+ }
+ }
+
+ private _canAdmit(exclusivityClass: RequestExclusivityClass): boolean {
+ if (this._activeRequestCount === 0) {
+ return true;
+ }
+
+ return (
+ exclusivityClass !== RequestExclusivityClass.Exclusive && exclusivityClass === this._activeClass
+ );
+ }
+
+ private _createLease(exclusivityClass: RequestExclusivityClass): IRequestLease {
+ this._activeClass = exclusivityClass;
+ this._activeRequestCount++;
+
+ let released: boolean = false;
+ return {
+ exclusivityClass,
+ release: (): void => {
+ if (released) {
+ return;
+ }
+
+ released = true;
+ this._activeRequestCount--;
+ if (this._activeRequestCount === 0) {
+ this._activeClass = undefined;
+ }
+ this._drainQueue();
+ }
+ };
+ }
+
+ private _drainQueue(): void {
+ let admittedRequest: boolean = false;
+ while (this._queue.length > 0) {
+ const request: IQueuedRequest = this._queue[0];
+ if (!this._canAdmit(request.options.exclusivityClass)) {
+ break;
+ }
+
+ this._queue.shift();
+ this._cleanupQueuedRequest(request);
+ request.resolve(this._createLease(request.options.exclusivityClass));
+ admittedRequest = true;
+ }
+
+ if (admittedRequest) {
+ this._notifyQueuePositions();
+ }
+ }
+
+ private _rejectQueuedRequest(request: IQueuedRequest, error: Error): void {
+ const index: number = this._queue.indexOf(request);
+ if (index < 0) {
+ return;
+ }
+
+ this._queue.splice(index, 1);
+ this._cleanupQueuedRequest(request);
+ request.reject(error);
+ this._notifyQueuePositions();
+ this._drainQueue();
+ }
+
+ private _cleanupQueuedRequest(request: IQueuedRequest): void {
+ if (request.timeout) {
+ clearTimeout(request.timeout);
+ request.timeout = undefined;
+ }
+ if (request.options.abortSignal && request.abortListener) {
+ request.options.abortSignal.removeEventListener('abort', request.abortListener);
+ request.abortListener = undefined;
+ }
+ }
+
+ private _notifyQueuePositions(): void {
+ for (let index: number = 0; index < this._queue.length; index++) {
+ this._queue[index].options.onQueuePositionChanged?.(index + 1);
+ }
+ }
+}
diff --git a/libraries/rush-daemon/src/index.ts b/libraries/rush-daemon/src/index.ts
new file mode 100644
index 00000000000..1da4d820f98
--- /dev/null
+++ b/libraries/rush-daemon/src/index.ts
@@ -0,0 +1,13 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+///
+
+export {
+ type IRequestLease,
+ type IRequestSchedulerAcquireOptions,
+ RequestExclusivityClass,
+ RequestScheduler,
+ RequestSchedulerError,
+ RequestSchedulerErrorCode
+} from './RequestScheduler';
diff --git a/libraries/rush-daemon/src/test/RequestScheduler.test.ts b/libraries/rush-daemon/src/test/RequestScheduler.test.ts
new file mode 100644
index 00000000000..e3f910865c3
--- /dev/null
+++ b/libraries/rush-daemon/src/test/RequestScheduler.test.ts
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import {
+ type IRequestLease,
+ RequestExclusivityClass,
+ RequestScheduler,
+ RequestSchedulerErrorCode
+} from '../RequestScheduler';
+
+describe(RequestScheduler.name, () => {
+ it('admits requests from the same shared class concurrently', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+
+ const first: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedBuild
+ });
+ const second: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedBuild
+ });
+
+ expect(scheduler.activeRequestCount).toBe(2);
+ expect(scheduler.queuedRequestCount).toBe(0);
+
+ first.release();
+ second.release();
+ });
+
+ it('serializes different shared classes', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const build: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedBuild
+ });
+ let readWasAdmitted: boolean = false;
+ const readPromise: Promise = scheduler
+ .acquireAsync({ exclusivityClass: RequestExclusivityClass.SharedRead })
+ .then((lease) => {
+ readWasAdmitted = true;
+ return lease;
+ });
+
+ await Promise.resolve();
+ expect(readWasAdmitted).toBe(false);
+
+ build.release();
+ const read: IRequestLease = await readPromise;
+ read.release();
+ });
+
+ it('uses an exclusive request as a FIFO gate', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const executionOrder: string[] = [];
+ const activeBuild: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedBuild
+ });
+ const exclusivePromise: Promise = scheduler
+ .acquireAsync({ exclusivityClass: RequestExclusivityClass.Exclusive })
+ .then((lease) => {
+ executionOrder.push('exclusive');
+ return lease;
+ });
+ const laterBuildPromise: Promise = scheduler
+ .acquireAsync({ exclusivityClass: RequestExclusivityClass.SharedBuild })
+ .then((lease) => {
+ executionOrder.push('later build');
+ return lease;
+ });
+
+ activeBuild.release();
+ const exclusive: IRequestLease = await exclusivePromise;
+ expect(executionOrder).toEqual(['exclusive']);
+
+ exclusive.release();
+ const laterBuild: IRequestLease = await laterBuildPromise;
+ expect(executionOrder).toEqual(['exclusive', 'later build']);
+ laterBuild.release();
+ });
+
+ it('fails immediately when noWait is specified', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const active: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.Exclusive
+ });
+
+ await expect(
+ scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead,
+ noWait: true
+ })
+ ).rejects.toMatchObject({
+ code: RequestSchedulerErrorCode.NoWait
+ });
+
+ active.release();
+ });
+
+ it('times out a queued request', async () => {
+ jest.useFakeTimers();
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const active: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.Exclusive
+ });
+ const waiting: Promise = scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead,
+ waitTimeoutMs: 100
+ });
+
+ jest.advanceTimersByTime(100);
+ await expect(waiting).rejects.toMatchObject({
+ code: RequestSchedulerErrorCode.WaitTimeout
+ });
+ expect(scheduler.queuedRequestCount).toBe(0);
+
+ active.release();
+ jest.useRealTimers();
+ });
+
+ it('cancels a queued request without affecting later requests', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const active: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.Exclusive
+ });
+ const abortController: AbortController = new AbortController();
+ const cancelled: Promise = scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead,
+ abortSignal: abortController.signal
+ });
+ const laterPromise: Promise = scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead
+ });
+
+ abortController.abort();
+ await expect(cancelled).rejects.toMatchObject({
+ code: RequestSchedulerErrorCode.Aborted
+ });
+
+ active.release();
+ const later: IRequestLease = await laterPromise;
+ later.release();
+ });
+
+ it('reports queue positions when the queue changes', async () => {
+ const scheduler: RequestScheduler = new RequestScheduler();
+ const active: IRequestLease = await scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.Exclusive
+ });
+ const firstPositions: number[] = [];
+ const secondPositions: number[] = [];
+ const firstPromise: Promise = scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead,
+ onQueuePositionChanged: (position) => firstPositions.push(position)
+ });
+ const secondPromise: Promise = scheduler.acquireAsync({
+ exclusivityClass: RequestExclusivityClass.SharedRead,
+ onQueuePositionChanged: (position) => secondPositions.push(position)
+ });
+
+ expect(firstPositions).toEqual([1, 1]);
+ expect(secondPositions).toEqual([2]);
+
+ active.release();
+ const first: IRequestLease = await firstPromise;
+ const second: IRequestLease = await secondPromise;
+ first.release();
+ second.release();
+ });
+});
diff --git a/libraries/rush-daemon/tsconfig.json b/libraries/rush-daemon/tsconfig.json
new file mode 100644
index 00000000000..f51cb9e037f
--- /dev/null
+++ b/libraries/rush-daemon/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json",
+
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020"]
+ }
+}
diff --git a/rush.json b/rush.json
index 723bcdc8ba3..e3e8b2ac412 100644
--- a/rush.json
+++ b/rush.json
@@ -1283,6 +1283,12 @@
"shouldPublish": true,
"decoupledLocalDependencies": ["@rushstack/heft"]
},
+ {
+ "packageName": "@rushstack/rush-daemon",
+ "projectFolder": "libraries/rush-daemon",
+ "reviewCategory": "libraries",
+ "shouldPublish": true
+ },
{
"packageName": "@rushstack/package-deps-hash",
"projectFolder": "libraries/package-deps-hash",