From afc2bbc745f96ea10bac20be4e582d4ef4e1f457 Mon Sep 17 00:00:00 2001 From: Mojazayeri Date: Thu, 13 Aug 2026 15:33:26 -0700 Subject: [PATCH 1/2] feat(rushd): add request scheduler foundation Introduce the rush-daemon package with fair shared and exclusive request admission, cancellation, timeouts, and queue position reporting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ush-daemon-scheduler_2026-08-13-22-30.json | 11 + .../config/subspaces/default/pnpm-lock.yaml | 12 + common/reviews/api/rush-daemon.api.md | 62 ++++ libraries/rush-daemon/.npmignore | 36 +++ libraries/rush-daemon/CHANGELOG.json | 4 + libraries/rush-daemon/CHANGELOG.md | 3 + libraries/rush-daemon/LICENSE | 24 ++ libraries/rush-daemon/README.md | 5 + .../rush-daemon/config/api-extractor.json | 4 + libraries/rush-daemon/config/jest.config.json | 3 + libraries/rush-daemon/config/rig.json | 7 + libraries/rush-daemon/eslint.config.js | 20 ++ libraries/rush-daemon/package.json | 55 ++++ libraries/rush-daemon/src/RequestScheduler.ts | 269 ++++++++++++++++++ libraries/rush-daemon/src/index.ts | 13 + .../src/test/RequestScheduler.test.ts | 167 +++++++++++ libraries/rush-daemon/tsconfig.json | 8 + rush.json | 6 + 18 files changed, 709 insertions(+) create mode 100644 common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-scheduler_2026-08-13-22-30.json create mode 100644 common/reviews/api/rush-daemon.api.md create mode 100644 libraries/rush-daemon/.npmignore create mode 100644 libraries/rush-daemon/CHANGELOG.json create mode 100644 libraries/rush-daemon/CHANGELOG.md create mode 100644 libraries/rush-daemon/LICENSE create mode 100644 libraries/rush-daemon/README.md create mode 100644 libraries/rush-daemon/config/api-extractor.json create mode 100644 libraries/rush-daemon/config/jest.config.json create mode 100644 libraries/rush-daemon/config/rig.json create mode 100644 libraries/rush-daemon/eslint.config.js create mode 100644 libraries/rush-daemon/package.json create mode 100644 libraries/rush-daemon/src/RequestScheduler.ts create mode 100644 libraries/rush-daemon/src/index.ts create mode 100644 libraries/rush-daemon/src/test/RequestScheduler.test.ts create mode 100644 libraries/rush-daemon/tsconfig.json 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", From 6e9f257320e4fa4dc7e5ed1ca78b7197d3dcb98f Mon Sep 17 00:00:00 2001 From: Mojaza Date: Thu, 13 Aug 2026 17:55:41 -0700 Subject: [PATCH 2/2] chore: update generated repository README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) 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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor.svg)](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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon.svg)](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/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush-lib.svg)](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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10.svg)](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/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v8.svg)](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) |