diff --git a/apps/rush-cli-client/README.md b/apps/rush-cli-client/README.md index 1d4cf98eb5..483d671312 100644 --- a/apps/rush-cli-client/README.md +++ b/apps/rush-cli-client/README.md @@ -46,6 +46,16 @@ rounded down to milliseconds. These controls are mutually exclusive and are consumed before forwarding, never appended to a project script. Arguments after `--` remain literal script arguments. +The queue timeout is measured from when the daemon receives the request. An +explicit `--no-wait`, `--wait-timeout`, `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS`, or +`daemon.queueTimeoutSeconds` in `rush.json` bounds the entire wait: waiting for +workspace admission and waiting for a running build that the request could not +join. The built-in 30-second default bounds only workspace admission (for example, +waiting for a command that needs exclusive access). With the default, a build that +arrives while a compatible build is already running waits for it to finish and then +runs, instead of failing after 30 seconds. On a timeout, the client exits with +code 1 and says how to wait longer. + Admission controls also apply to experimental graph requests, but not `start|stop|restart|status|logs`. They affect daemon admission only; native fallback retains native command behavior. Waiting positions are shown on interactive stderr, @@ -169,7 +179,7 @@ keys and unknown `RUSH_DAEMON*` variables fail validation. | `enabled` | `RUSH_DAEMON` | false | Client routing | | `autoStart` | `RUSH_DAEMON_AUTO_START` | true | Only after opt-in | | `idleTimeoutSeconds` | `RUSH_DAEMON_IDLE_TIMEOUT_SECONDS` | 900 | Host idle shutdown after request/output/cleanup drain | -| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Sent through existing admission contract | +| `queueTimeoutSeconds` | `RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS` | 30 | Admission wait limit. The default does not bound waiting behind a running compatible build; an explicit value does | | `watch` | `RUSH_DAEMON_WATCH` | false | Persistent host observation of requested warm projects; false keeps root/config guards only. Never schedules builds | | `usePersistentIpcRunners` | `RUSH_DAEMON_USE_PERSISTENT_IPC_RUNNERS` | false | Enables explicit per-operation `daemonIpc` Node launchers for unsharded incremental daemon builds | | `warmIdleTimeoutSeconds` | `RUSH_DAEMON_WARM_IDLE_TIMEOUT_SECONDS` | 300 | Idle runner, project-watcher and retained-result eviction | diff --git a/apps/rush-cli-client/src/ClientAdmissionControls.ts b/apps/rush-cli-client/src/ClientAdmissionControls.ts index 907ac165c0..a0dc6cb017 100644 --- a/apps/rush-cli-client/src/ClientAdmissionControls.ts +++ b/apps/rush-cli-client/src/ClientAdmissionControls.ts @@ -4,6 +4,7 @@ import { MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS, validateDaemonRequestAdmissionOptions, + type DaemonRequestAdmissionErrorCode, type IDaemonRequestAdmissionOptions } from '@rushstack/rush-daemon-protocol'; @@ -54,3 +55,40 @@ export function parseClientAdmissionControls(argv: ReadonlyArray): IClie : { waitTimeoutMs }; return { argv: remaining, admission }; } + +export interface IConfiguredAdmissionOptions { + readonly queueTimeoutSeconds: number; + /** True when the timeout came from rush.json or RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS rather than the default. */ + readonly explicit: boolean; +} + +/** + * Converts the configured queue timeout into admission options for a request without `--no-wait`/`--wait-timeout`. + * A timeout that comes only from the built-in default is marked so that the daemon applies it to workspace + * admission but not to waiting behind a running compatible build. + */ +export function getConfiguredAdmission(options: IConfiguredAdmissionOptions): IDaemonRequestAdmissionOptions { + const waitTimeoutMs: number = Math.floor(options.queueTimeoutSeconds * 1000); + return options.explicit ? { waitTimeoutMs } : { waitTimeoutMs, waitTimeoutIsDefault: true }; +} + +/** Explains a daemon admission failure and how to wait longer. */ +export function formatAdmissionFailure( + code: DaemonRequestAdmissionErrorCode, + admission: IDaemonRequestAdmissionOptions | undefined +): string { + const prefix: string = `rush-client: daemon admission failed (${code})`; + if (code === 'no-wait') { + return `${prefix}: another daemon request is using this workspace and --no-wait was specified.\n`; + } + if (code === 'wait-timeout') { + const seconds: string = + admission?.waitTimeoutMs === undefined ? '' : ` after ${admission.waitTimeoutMs / 1000}s`; + return ( + `${prefix}: timed out${seconds} waiting for another daemon request in this workspace to finish ` + + '(a command that needs exclusive access, or a running build). ' + + 'To wait longer, use --wait-timeout or set RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS.\n' + ); + } + return `${prefix}.\n`; +} diff --git a/apps/rush-cli-client/src/launchClient.ts b/apps/rush-cli-client/src/launchClient.ts index b739b9fadf..74fef4a810 100644 --- a/apps/rush-cli-client/src/launchClient.ts +++ b/apps/rush-cli-client/src/launchClient.ts @@ -26,6 +26,7 @@ import { MinimalRushConfiguration } from '@microsoft/rush/lib/MinimalRushConfigu import { DaemonLauncherUnavailableError } from '@rushstack/rush-daemon/lib/VersionSelectedDaemonLauncher'; import { executeDaemonCommandAsync } from './daemonCommands'; +import { formatAdmissionFailure, getConfiguredAdmission } from './ClientAdmissionControls'; import { ClientOperationRenderer } from './ClientOperationRenderer'; import { getDaemonConnectionOptionsAsync } from './daemonConnectionOptions'; import { selectClientRoute, type IClientRoute } from './routing'; @@ -91,7 +92,14 @@ export async function launchClientAsync(rushx: boolean): Promise { columns: process.stdout.columns, acceptsStdin: true }, - admission: route.admission ?? { waitTimeoutMs: Math.floor(config.queueTimeoutSeconds * 1000) } + admission: + route.admission ?? + getConfiguredAdmission({ + queueTimeoutSeconds: config.queueTimeoutSeconds, + explicit: + workspace?.daemon?.queueTimeoutSeconds !== undefined || + environment[daemonEnvironmentVariables.queueTimeoutSeconds] !== undefined + }) }); let connection: IConnectOrStartDaemonOptions; let client: DaemonClient; @@ -190,7 +198,7 @@ export async function launchClientAsync(rushx: boolean): Promise { if (outcome.result.admissionErrorCode) { await writeStreamAsync( process.stderr, - Buffer.from(`rush-client: daemon admission failed (${outcome.result.admissionErrorCode}).\n`) + Buffer.from(formatAdmissionFailure(outcome.result.admissionErrorCode, request.admission)) ); } } else if (outcome.kind === 'rejected') { diff --git a/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts index 0c477db349..5aea17bc37 100644 --- a/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts +++ b/apps/rush-cli-client/src/test/ClientAdmissionControls.test.ts @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { parseClientAdmissionControls } from '../ClientAdmissionControls'; +import { + formatAdmissionFailure, + getConfiguredAdmission, + parseClientAdmissionControls +} from '../ClientAdmissionControls'; import { selectClientRoute } from '../routing'; describe(parseClientAdmissionControls.name, () => { @@ -55,3 +59,29 @@ describe(parseClientAdmissionControls.name, () => { }); }); }); + +describe(getConfiguredAdmission.name, () => { + it('marks the built-in default so it does not bound waiting behind a compatible build', () => { + expect(getConfiguredAdmission({ queueTimeoutSeconds: 30, explicit: false })).toEqual({ + waitTimeoutMs: 30000, + waitTimeoutIsDefault: true + }); + }); + + it('keeps an explicitly configured timeout as one absolute deadline', () => { + expect(getConfiguredAdmission({ queueTimeoutSeconds: 1.5, explicit: true })).toEqual({ waitTimeoutMs: 1500 }); + }); +}); + +describe(formatAdmissionFailure.name, () => { + it('explains a wait timeout and how to wait longer', () => { + const message: string = formatAdmissionFailure('wait-timeout', { waitTimeoutMs: 5000 }); + expect(message).toContain('daemon admission failed (wait-timeout): timed out after 5s waiting for'); + expect(message).toContain('--wait-timeout '); + expect(message).toContain('RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS'); + }); + + it('explains a no-wait failure', () => { + expect(formatAdmissionFailure('no-wait', { noWait: true })).toContain('--no-wait was specified'); + }); +}); diff --git a/common/changes/@rushstack/rush-cli-client/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json b/common/changes/@rushstack/rush-cli-client/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json new file mode 100644 index 0000000000..965c70715e --- /dev/null +++ b/common/changes/@rushstack/rush-cli-client/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-cli-client", + "comment": "Wait for a running compatible build instead of failing after the default 30-second queue timeout, and explain admission failures with how to wait longer.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-cli-client", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json b/common/changes/@rushstack/rush-daemon-protocol/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json new file mode 100644 index 0000000000..09254278fa --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "comment": "Add the optional `waitTimeoutIsDefault` admission option so a daemon can distinguish a client-default queue timeout from an explicit one.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon-protocol", + "email": "selarkin@microsoft.com" +} diff --git a/common/changes/@rushstack/rush-daemon/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json b/common/changes/@rushstack/rush-daemon/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json new file mode 100644 index 0000000000..7bedf6679a --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/fix-rushd-late-build-queue-timeout_2026-09-24-01-45.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "Do not let the default queue timeout fail a late compatible shared build that is waiting for a running build; explicit wait limits still apply, and timeout errors say what the request waited for.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon", + "email": "selarkin@microsoft.com" +} diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md index 409700cfaf..8ca51ef110 100644 --- a/common/reviews/api/rush-daemon-protocol.api.md +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -500,6 +500,7 @@ export interface IDaemonRawModeChangedMessage { // @beta export interface IDaemonRequestAdmissionOptions { readonly noWait?: boolean; + readonly waitTimeoutIsDefault?: boolean; readonly waitTimeoutMs?: number; } diff --git a/libraries/rush-daemon-protocol/src/DaemonRequestAdmission.ts b/libraries/rush-daemon-protocol/src/DaemonRequestAdmission.ts index a0d9cce5d8..6be5d0c380 100644 --- a/libraries/rush-daemon-protocol/src/DaemonRequestAdmission.ts +++ b/libraries/rush-daemon-protocol/src/DaemonRequestAdmission.ts @@ -12,6 +12,11 @@ export type DaemonRequestAdmissionErrorCode = 'aborted' | 'no-wait' | 'wait-time export interface IDaemonRequestAdmissionOptions { /** Fail immediately when the request cannot be admitted. */ readonly noWait?: boolean; + /** + * True when `waitTimeoutMs` is a client default rather than an explicit user choice. A default timeout bounds + * workspace admission only, not waiting behind running compatible shared builds. + */ + readonly waitTimeoutIsDefault?: boolean; /** Maximum queue wait in milliseconds. Omission means no timeout. */ readonly waitTimeoutMs?: number; } @@ -33,7 +38,8 @@ export function validateDaemonRequestAdmissionOptions( return; } validateAdmissionRecord(options); - validateNoWait(options.noWait); + validateBoolean(options.noWait, 'noWait'); + validateBoolean(options.waitTimeoutIsDefault, 'waitTimeoutIsDefault'); validateWaitTimeout(options.waitTimeoutMs); } @@ -43,9 +49,9 @@ function validateAdmissionRecord(options: IDaemonRequestAdmissionOptions): void } } -function validateNoWait(value: unknown): void { +function validateBoolean(value: unknown, name: string): void { if (value !== undefined && typeof value !== 'boolean') { - throw new TypeError('Daemon request admission noWait must be a boolean.'); + throw new TypeError(`Daemon request admission ${name} must be a boolean.`); } } diff --git a/libraries/rush-daemon-protocol/src/RequestEnvelopeValidation.ts b/libraries/rush-daemon-protocol/src/RequestEnvelopeValidation.ts index 92964d3a03..1bf3a7f6be 100644 --- a/libraries/rush-daemon-protocol/src/RequestEnvelopeValidation.ts +++ b/libraries/rush-daemon-protocol/src/RequestEnvelopeValidation.ts @@ -23,6 +23,7 @@ export function validateRequestAdmission(value: unknown): void { if (value === undefined) return; const admission: Record = requireRecord(value, 'requestStart payload.admission'); validateOptionalBoolean(admission.noWait, 'Request admission noWait'); + validateOptionalBoolean(admission.waitTimeoutIsDefault, 'Request admission waitTimeoutIsDefault'); validateWaitTimeout(admission.waitTimeoutMs); } diff --git a/libraries/rush-daemon-protocol/src/test/RequestAdmission.test.ts b/libraries/rush-daemon-protocol/src/test/RequestAdmission.test.ts index 1144fc128b..c36e1857af 100644 --- a/libraries/rush-daemon-protocol/src/test/RequestAdmission.test.ts +++ b/libraries/rush-daemon-protocol/src/test/RequestAdmission.test.ts @@ -14,6 +14,7 @@ describe(validateDaemonRequestAdmissionOptions.name, () => { expect(() => validateDaemonRequestAdmissionOptions({ noWait: true, + waitTimeoutIsDefault: true, waitTimeoutMs: MAX_DAEMON_REQUEST_WAIT_TIMEOUT_MS }) ).not.toThrow(); @@ -21,6 +22,7 @@ describe(validateDaemonRequestAdmissionOptions.name, () => { it.each([ [{ noWait: 'yes' }, 'noWait'], + [{ waitTimeoutIsDefault: 'yes' }, 'waitTimeoutIsDefault'], [{ waitTimeoutMs: -1 }, 'waitTimeoutMs'], [{ waitTimeoutMs: 1.5 }, 'waitTimeoutMs'], [ diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md index 713b69bec1..08ed39082b 100644 --- a/libraries/rush-daemon/README.md +++ b/libraries/rush-daemon/README.md @@ -431,9 +431,12 @@ the router validates both, reconciles retained invalidations, applies the select and runs at most one scheduled iteration. A workspace-wide `RequestScheduler` admits phased and global routes using the static built-in command policy (`SHARED-BUILD`, `SHARED-READ`, or `EXCLUSIVE`); custom-origin commands and unknown built-in names fail closed to `EXCLUSIVE`, including plugin replacements of built-in names. Queued clients receive -ordered, one-based position controls and can request fail-fast or bounded waiting. One absolute deadline and progress -channel cover both workspace admission and the temporary phased graph-execution gate. Cancellation, disconnect, or -queue-output failure removes queued work before it can execute. +ordered, one-based position controls and can request fail-fast or bounded waiting. One progress channel covers both +workspace admission and the temporary phased graph-execution gate. An explicit `noWait` or `waitTimeoutMs` is one +absolute deadline for both waits. When the client marks `waitTimeoutMs` as its default (`waitTimeoutIsDefault`), the +deadline bounds workspace admission only: a `SHARED-BUILD` request that arrives after the current batch has closed waits +on the graph-execution gate without a deadline, because it is queued only behind running compatible shared builds, and +then runs in the next batch. Cancellation, disconnect, or queue-output failure removes queued work before it can execute. A requesting client receives only its enabled dependency closure's WS1 raw chunks and structured events through backpressured, ordered callbacks, followed exactly once by a typed final command result after all preceding output drains. The result translates only that client's operation subset to Rush's success, warning, failure, or abort exit diff --git a/libraries/rush-daemon/src/PhasedRequestRouter.ts b/libraries/rush-daemon/src/PhasedRequestRouter.ts index 0903eaf136..97a8b9286d 100644 --- a/libraries/rush-daemon/src/PhasedRequestRouter.ts +++ b/libraries/rush-daemon/src/PhasedRequestRouter.ts @@ -246,7 +246,7 @@ class PhasedRequestBatchCoordinator { request.exclusivityClass === RequestExclusivityClass.SharedBuild ? RequestExclusivityClass.SharedBuild : RequestExclusivityClass.Exclusive; - const graphWaitLease: IRequestLease = await admissionController.acquireAsync( + const graphWaitLease: IRequestLease = await admissionController.acquireGraphExecutionAsync( this.#graphExecutionScheduler, graphExclusivityClass ); diff --git a/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts b/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts index 70ff8514d2..0863216263 100644 --- a/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts +++ b/libraries/rush-daemon/src/WorkspaceRequestAdmission.ts @@ -11,7 +11,7 @@ import type { import { type IRequestLease, type IRequestSchedulerAcquireOptions, - type RequestExclusivityClass, + RequestExclusivityClass, RequestScheduler, RequestSchedulerError, RequestSchedulerErrorCode @@ -123,9 +123,48 @@ export class RequestAdmissionController { } } + /** Waits for workspace admission, bounded by the request's absolute admission deadline. */ public async acquireAsync( scheduler: RequestScheduler, exclusivityClass: RequestExclusivityClass + ): Promise { + return await this.#acquireAsync( + scheduler, + exclusivityClass, + this.#getRemainingWaitTimeoutMs(), + 'workspace admission' + ); + } + + /** + * Waits for the per-graph execution gate after workspace admission. + * + * @remarks + * A shared-build request that reaches this gate is only waiting behind running compatible shared builds, which is + * progress rather than contention. A client-default timeout therefore does not apply to that wait; an explicit + * `noWait` or `waitTimeoutMs` still applies, using the same absolute deadline as workspace admission. + */ + public async acquireGraphExecutionAsync( + scheduler: RequestScheduler, + exclusivityClass: RequestExclusivityClass + ): Promise { + const waitTimeoutMs: number | undefined = + exclusivityClass === RequestExclusivityClass.SharedBuild && this.#admission?.waitTimeoutIsDefault + ? undefined + : this.#getRemainingWaitTimeoutMs(); + return await this.#acquireAsync( + scheduler, + exclusivityClass, + waitTimeoutMs, + 'the running build of the workspace operation graph' + ); + } + + async #acquireAsync( + scheduler: RequestScheduler, + exclusivityClass: RequestExclusivityClass, + waitTimeoutMs: number | undefined, + waitingFor: string ): Promise { const writer: QueuePositionWriter | undefined = this.#writer; let lease: IRequestLease | undefined; @@ -135,7 +174,7 @@ export class RequestAdmissionController { exclusivityClass, noWait: this.#admission?.noWait, onQueuePositionChanged: writer ? (position: number) => writer.enqueue(position) : undefined, - waitTimeoutMs: this.#getRemainingWaitTimeoutMs() + waitTimeoutMs }); await writer?.flushAsync(); if (this.#abortController.signal.aborted) { @@ -148,7 +187,7 @@ export class RequestAdmissionController { } catch (error) { lease?.release(); await writer?.flushAsync(); - throw this.#getReportedError(error); + throw this.#getReportedError(error, waitingFor); } } @@ -167,7 +206,7 @@ export class RequestAdmissionController { return this.#deadlineMs === undefined ? undefined : Math.max(0, this.#deadlineMs - Date.now()); } - #getReportedError(error: unknown): unknown { + #getReportedError(error: unknown, waitingFor: string): unknown { const waitTimeoutMs: number | undefined = this.#admission?.waitTimeoutMs; if ( waitTimeoutMs !== undefined && @@ -176,7 +215,8 @@ export class RequestAdmissionController { ) { return new RequestSchedulerError( RequestSchedulerErrorCode.WaitTimeout, - `The request was not admitted within ${waitTimeoutMs}ms.` + `The request was not admitted within ${waitTimeoutMs}ms while waiting for ${waitingFor}. ` + + 'Use --wait-timeout or RUSH_DAEMON_QUEUE_TIMEOUT_SECONDS to wait longer.' ); } return error; diff --git a/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts b/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts index 9ed2adef1c..d766d09c85 100644 --- a/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts +++ b/libraries/rush-daemon/src/test/PhasedRequestBatching.test.ts @@ -366,6 +366,67 @@ describe('shared phased request batching', () => { expect(fixture.runners.get(OPERATION_C)?.runCount).toBe(1); }); + it('lets a late shared build wait past a default timeout while a compatible batch executes', async () => { + const operationStarted: IDeferred = createDeferred(); + const releaseOperation: IDeferred = createDeferred(); + const fixture: ITestRoutingFixture = createFixture({ + actionAAsync: async (): Promise => { + operationStarted.resolve(); + await releaseOperation.promise; + } + }); + const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); + const first = router.executeAsync( + createRequest('first', OPERATION_A), + new TestPhasedRequestClient('one') + ); + await operationStarted.promise; + let lateSettled: boolean = false; + const late = router + .executeAsync( + { ...createRequest('late', OPERATION_C), admission: { waitTimeoutIsDefault: true, waitTimeoutMs: 20 } }, + new TestPhasedRequestClient('two') + ) + .finally(() => { + lateSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(lateSettled).toBe(false); + releaseOperation.resolve(); + + const [firstResult, lateResult] = await Promise.all([first, late]); + expect(firstResult.outcome).toBe('success'); + expect(lateResult.outcome).toBe('success'); + expect(lateResult.admissionErrorCode).toBeUndefined(); + expect(fixture.runners.get(OPERATION_C)?.runCount).toBe(1); + }); + + it('enforces an explicit timeout while a late shared build waits for a compatible batch', async () => { + const operationStarted: IDeferred = createDeferred(); + const releaseOperation: IDeferred = createDeferred(); + const fixture: ITestRoutingFixture = createFixture({ + actionAAsync: async (): Promise => { + operationStarted.resolve(); + await releaseOperation.promise; + } + }); + const router: PhasedRequestRouter = new PhasedRequestRouter(fixture.session); + const first = router.executeAsync( + createRequest('first', OPERATION_A), + new TestPhasedRequestClient('one') + ); + await operationStarted.promise; + + const lateResult: IDaemonPhasedRequestResult = await router.executeAsync( + { ...createRequest('late', OPERATION_C), admission: { waitTimeoutMs: 20 } }, + new TestPhasedRequestClient('two') + ); + expect(lateResult).toMatchObject({ admissionErrorCode: 'wait-timeout', outcome: 'failure' }); + releaseOperation.resolve(); + expect((await first).outcome).toBe('success'); + expect(fixture.runners.get(OPERATION_C)?.runCount).toBe(0); + }); + it('serializes concurrent shared-read requests instead of merging or deadlocking them', async () => { const operationStarted: IDeferred = createDeferred(); const releaseOperation: IDeferred = createDeferred();