Skip to content

Commit f704dcb

Browse files
Merge pull request #15405 from hajekjiri/bug/fix-race-condition-in-dependency-resolution
fix(core): fix race condition in class dependency resolution
2 parents 61889b1 + a453b63 commit f704dcb

File tree

7 files changed

+426
-105
lines changed

7 files changed

+426
-105
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Test } from '@nestjs/testing';
2+
import { expect } from 'chai';
3+
import * as sinon from 'sinon';
4+
import { Global, Inject, Injectable, Module, Scope } from '@nestjs/common';
5+
6+
@Global()
7+
@Module({})
8+
export class GlobalModule1 {}
9+
10+
@Global()
11+
@Module({})
12+
export class GlobalModule2 {}
13+
14+
@Global()
15+
@Module({})
16+
export class GlobalModule3 {}
17+
18+
@Global()
19+
@Module({})
20+
export class GlobalModule4 {}
21+
22+
@Global()
23+
@Module({})
24+
export class GlobalModule5 {}
25+
26+
@Global()
27+
@Module({})
28+
export class GlobalModule6 {}
29+
30+
@Global()
31+
@Module({})
32+
export class GlobalModule7 {}
33+
34+
@Global()
35+
@Module({})
36+
export class GlobalModule8 {}
37+
38+
@Global()
39+
@Module({})
40+
export class GlobalModule9 {}
41+
42+
@Global()
43+
@Module({})
44+
export class GlobalModule10 {}
45+
46+
@Injectable()
47+
class TransientProvider {}
48+
49+
@Injectable()
50+
class RequestProvider {}
51+
52+
@Injectable()
53+
export class Dependant {
54+
constructor(
55+
private readonly transientProvider: TransientProvider,
56+
57+
@Inject(RequestProvider)
58+
private readonly requestProvider: RequestProvider,
59+
) {}
60+
61+
public checkDependencies() {
62+
expect(this.transientProvider).to.be.instanceOf(TransientProvider);
63+
expect(this.requestProvider).to.be.instanceOf(RequestProvider);
64+
}
65+
}
66+
67+
@Global()
68+
@Module({
69+
providers: [
70+
{
71+
provide: TransientProvider,
72+
scope: Scope.TRANSIENT,
73+
useClass: TransientProvider,
74+
},
75+
{
76+
provide: Dependant,
77+
scope: Scope.DEFAULT,
78+
useClass: Dependant,
79+
},
80+
],
81+
})
82+
export class GlobalModuleWithTransientProviderAndDependant {}
83+
84+
@Global()
85+
@Module({
86+
providers: [
87+
{
88+
provide: RequestProvider,
89+
scope: Scope.REQUEST,
90+
useFactory: () => {
91+
return new RequestProvider();
92+
},
93+
},
94+
],
95+
exports: [RequestProvider],
96+
})
97+
export class GlobalModuleWithRequestProvider {}
98+
99+
@Module({
100+
imports: [
101+
GlobalModule1,
102+
GlobalModule2,
103+
GlobalModule3,
104+
GlobalModule4,
105+
GlobalModule5,
106+
GlobalModule6,
107+
GlobalModule7,
108+
GlobalModule8,
109+
GlobalModule9,
110+
GlobalModule10,
111+
GlobalModuleWithTransientProviderAndDependant,
112+
GlobalModuleWithRequestProvider,
113+
],
114+
})
115+
export class AppModule {}
116+
117+
describe('Many global modules', () => {
118+
it('should inject request-scoped useFactory provider and transient-scoped useClass provider from different modules', async () => {
119+
const moduleBuilder = Test.createTestingModule({
120+
imports: [AppModule],
121+
});
122+
const moduleRef = await moduleBuilder.compile();
123+
124+
const dependant = await moduleRef.resolve(Dependant);
125+
const checkDependenciesSpy = sinon.spy(dependant, 'checkDependencies');
126+
dependant.checkDependencies();
127+
128+
expect(checkDependenciesSpy.called).to.be.true;
129+
});
130+
});

integration/inspector/e2e/fixtures/post-init-graph.json

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2197,22 +2197,6 @@
21972197
},
21982198
"id": "1976848738"
21992199
},
2200-
"-2105726668": {
2201-
"source": "-1803759743",
2202-
"target": "1010833816",
2203-
"metadata": {
2204-
"type": "class-to-class",
2205-
"sourceModuleName": "PropertiesModule",
2206-
"sourceClassName": "PropertiesService",
2207-
"targetClassName": "token",
2208-
"sourceClassToken": "PropertiesService",
2209-
"targetClassToken": "token",
2210-
"targetModuleName": "PropertiesModule",
2211-
"keyOrIndex": "token",
2212-
"injectionType": "property"
2213-
},
2214-
"id": "-2105726668"
2215-
},
22162200
"-21463590": {
22172201
"source": "-1378706112",
22182202
"target": "1004276345",
@@ -2229,6 +2213,22 @@
22292213
},
22302214
"id": "-21463590"
22312215
},
2216+
"-2105726668": {
2217+
"source": "-1803759743",
2218+
"target": "1010833816",
2219+
"metadata": {
2220+
"type": "class-to-class",
2221+
"sourceModuleName": "PropertiesModule",
2222+
"sourceClassName": "PropertiesService",
2223+
"targetClassName": "token",
2224+
"sourceClassToken": "PropertiesService",
2225+
"targetClassToken": "token",
2226+
"targetModuleName": "PropertiesModule",
2227+
"keyOrIndex": "token",
2228+
"injectionType": "property"
2229+
},
2230+
"id": "-2105726668"
2231+
},
22322232
"-1657371464": {
22332233
"source": "-1673986099",
22342234
"target": "1919157847",

packages/core/helpers/barrier.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* A simple barrier to synchronize flow of multiple async operations.
3+
*/
4+
export class Barrier {
5+
private currentCount: number;
6+
private targetCount: number;
7+
private promise: Promise<void>;
8+
private resolve: () => void;
9+
10+
constructor(targetCount: number) {
11+
this.currentCount = 0;
12+
this.targetCount = targetCount;
13+
14+
this.promise = new Promise<void>(resolve => {
15+
this.resolve = resolve;
16+
});
17+
}
18+
19+
/**
20+
* Signal that a participant has reached the barrier.
21+
*
22+
* The barrier will be resolved once `targetCount` participants have reached it.
23+
*/
24+
public signal(): void {
25+
this.currentCount += 1;
26+
if (this.currentCount === this.targetCount) {
27+
this.resolve();
28+
}
29+
}
30+
31+
/**
32+
* Wait for the barrier to be resolved.
33+
*
34+
* @returns A promise that resolves when the barrier is resolved.
35+
*/
36+
public async wait(): Promise<void> {
37+
return this.promise;
38+
}
39+
40+
/**
41+
* Signal that a participant has reached the barrier and wait for the barrier to be resolved.
42+
*
43+
* The barrier will be resolved once `targetCount` participants have reached it.
44+
*
45+
* @returns A promise that resolves when the barrier is resolved.
46+
*/
47+
public async signalAndWait(): Promise<void> {
48+
this.signal();
49+
return this.wait();
50+
}
51+
}

packages/core/injector/injector.ts

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from './instance-wrapper';
4343
import { Module } from './module';
4444
import { SettlementSignal } from './settlement-signal';
45+
import { Barrier } from '../helpers/barrier';
4546

4647
/**
4748
* The type of an injectable dependency
@@ -313,10 +314,16 @@ export class Injector {
313314
? this.getFactoryProviderDependencies(wrapper)
314315
: this.getClassDependencies(wrapper);
315316

317+
const paramBarrier = new Barrier(dependencies.length);
316318
let isResolved = true;
317319
const resolveParam = async (param: unknown, index: number) => {
318320
try {
319321
if (this.isInquirer(param, parentInquirer)) {
322+
/*
323+
* Signal the barrier to make sure other dependencies do not get stuck waiting forever.
324+
*/
325+
paramBarrier.signal();
326+
320327
return parentInquirer && parentInquirer.instance;
321328
}
322329
if (inquirer?.isTransient && parentInquirer) {
@@ -332,15 +339,36 @@ export class Injector {
332339
inquirer,
333340
index,
334341
);
335-
const instanceHost = paramWrapper.getInstanceByContextId(
336-
this.getContextId(contextId, paramWrapper),
342+
343+
/*
344+
* Ensure that all instance wrappers are resolved at this point before we continue.
345+
* Otherwise the staticity of `wrapper`'s dependency tree may be evaluated incorrectly
346+
* and result in undefined / null injection.
347+
*/
348+
await paramBarrier.signalAndWait();
349+
350+
const paramWrapperWithInstance = await this.resolveComponentHost(
351+
moduleRef,
352+
paramWrapper,
353+
contextId,
354+
inquirer,
355+
);
356+
const instanceHost = paramWrapperWithInstance.getInstanceByContextId(
357+
this.getContextId(contextId, paramWrapperWithInstance),
337358
inquirerId,
338359
);
339-
if (!instanceHost.isResolved && !paramWrapper.forwardRef) {
360+
if (!instanceHost.isResolved && !paramWrapperWithInstance.forwardRef) {
340361
isResolved = false;
341362
}
342363
return instanceHost?.instance;
343364
} catch (err) {
365+
/*
366+
* Signal the barrier to make sure other dependencies do not get stuck waiting forever. We
367+
* do not care if this occurs after `Barrier.signalAndWait()` is called in the `try` block
368+
* because the barrier will always have been resolved by then.
369+
*/
370+
paramBarrier.signal();
371+
344372
const isOptional = optionalDependenciesIds.includes(index);
345373
if (!isOptional) {
346374
throw err;
@@ -440,7 +468,7 @@ export class Injector {
440468
);
441469
}
442470
const token = this.resolveParamToken(wrapper, param);
443-
return this.resolveComponentInstance<T>(
471+
return this.resolveComponentWrapper(
444472
moduleRef,
445473
token,
446474
dependencyContext,
@@ -462,7 +490,7 @@ export class Injector {
462490
return param;
463491
}
464492

465-
public async resolveComponentInstance<T>(
493+
public async resolveComponentWrapper<T>(
466494
moduleRef: Module,
467495
token: InjectionToken,
468496
dependencyContext: InjectorDependencyContext,
@@ -474,7 +502,7 @@ export class Injector {
474502
this.printResolvingDependenciesLog(token, inquirer);
475503
this.printLookingForProviderLog(token, moduleRef);
476504
const providers = moduleRef.providers;
477-
const instanceWrapper = await this.lookupComponent(
505+
return this.lookupComponent(
478506
providers,
479507
moduleRef,
480508
{ ...dependencyContext, name: token },
@@ -483,13 +511,6 @@ export class Injector {
483511
inquirer,
484512
keyOrIndex,
485513
);
486-
487-
return this.resolveComponentHost(
488-
moduleRef,
489-
instanceWrapper,
490-
contextId,
491-
inquirer,
492-
);
493514
}
494515

495516
public async resolveComponentHost<T>(
@@ -689,6 +710,7 @@ export class Injector {
689710
return this.loadPropertiesMetadata(metadata, contextId, inquirer);
690711
}
691712
const properties = this.reflectProperties(wrapper.metatype as Type<any>);
713+
const propertyBarrier = new Barrier(properties.length);
692714
const instances = await Promise.all(
693715
properties.map(async (item: PropertyDependency) => {
694716
try {
@@ -697,6 +719,11 @@ export class Injector {
697719
name: item.name as Function | string | symbol,
698720
};
699721
if (this.isInquirer(item.name, parentInquirer)) {
722+
/*
723+
* Signal the barrier to make sure other dependencies do not get stuck waiting forever.
724+
*/
725+
propertyBarrier.signal();
726+
700727
return parentInquirer && parentInquirer.instance;
701728
}
702729
const paramWrapper = await this.resolveSingleParam<T>(
@@ -708,16 +735,37 @@ export class Injector {
708735
inquirer,
709736
item.key,
710737
);
711-
if (!paramWrapper) {
738+
739+
/*
740+
* Ensure that all instance wrappers are resolved at this point before we continue.
741+
* Otherwise the staticity of `wrapper`'s dependency tree may be evaluated incorrectly
742+
* and result in undefined / null injection.
743+
*/
744+
await propertyBarrier.signalAndWait();
745+
746+
const paramWrapperWithInstance = await this.resolveComponentHost(
747+
moduleRef,
748+
paramWrapper,
749+
contextId,
750+
inquirer,
751+
);
752+
if (!paramWrapperWithInstance) {
712753
return undefined;
713754
}
714755
const inquirerId = this.getInquirerId(inquirer);
715-
const instanceHost = paramWrapper.getInstanceByContextId(
716-
this.getContextId(contextId, paramWrapper),
756+
const instanceHost = paramWrapperWithInstance.getInstanceByContextId(
757+
this.getContextId(contextId, paramWrapperWithInstance),
717758
inquirerId,
718759
);
719760
return instanceHost.instance;
720761
} catch (err) {
762+
/*
763+
* Signal the barrier to make sure other dependencies do not get stuck waiting forever. We
764+
* do not care if this occurs after `Barrier.signalAndWait()` is called in the `try` block
765+
* because the barrier will always have been resolved by then.
766+
*/
767+
propertyBarrier.signal();
768+
721769
if (!item.isOptional) {
722770
throw err;
723771
}

0 commit comments

Comments
 (0)