Skip to content

Commit 198af88

Browse files
committed
feat(context): leave local bindings and parent unchanged during close
See #2541
1 parent fe22f82 commit 198af88

File tree

5 files changed

+110
-14
lines changed

5 files changed

+110
-14
lines changed

packages/context/src/__tests__/acceptance/interceptor.acceptance.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,46 @@ describe('Interceptor', () => {
171171
expect(events).to.eql(['logError: before-greet', 'logError: error-greet']);
172172
});
173173

174+
it('closes invocation context after invocation', async () => {
175+
const testInterceptor: Interceptor = async (invocationCtx, next) => {
176+
// Add observers to the invocation context, which in turn adds listeners
177+
// to its parent - `ctx`
178+
invocationCtx.subscribe(() => {});
179+
return await next();
180+
};
181+
182+
class MyController {
183+
@intercept(testInterceptor)
184+
async greet(name: string) {
185+
return `Hello, ${name}`;
186+
}
187+
}
188+
189+
// No listeners yet
190+
expect(ctx.listenerCount('bind')).to.eql(0);
191+
const controller = new MyController();
192+
193+
// Run the invocation 5 times
194+
for (let i = 0; i < 5; i++) {
195+
const count = ctx.listenerCount('bind');
196+
const invocationPromise = invokeMethodWithInterceptors(
197+
ctx,
198+
controller,
199+
'greet',
200+
['John'],
201+
);
202+
// New listeners are added to `ctx`
203+
expect(ctx.listenerCount('bind')).to.be.greaterThan(count);
204+
205+
// Wait until the invocation finishes
206+
await invocationPromise;
207+
}
208+
209+
// Listeners added by invocation context are gone now
210+
// There is one left for ctx.observers
211+
expect(ctx.listenerCount('bind')).to.eql(1);
212+
});
213+
174214
it('invokes static interceptors', async () => {
175215
class MyController {
176216
// Apply `log` to a static method

packages/context/src/__tests__/unit/context.unit.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
BindingScope,
1212
BindingType,
1313
Context,
14+
ContextEventObserver,
1415
isPromiseLike,
1516
} from '../..';
1617

@@ -19,13 +20,17 @@ import {
1920
* for assertions
2021
*/
2122
class TestContext extends Context {
23+
observers: Set<ContextEventObserver> | undefined;
2224
get parent() {
2325
return this._parent;
2426
}
2527
get bindingMap() {
2628
const map = new Map(this.registry);
2729
return map;
2830
}
31+
get parentEventListeners() {
32+
return this._parentEventListeners;
33+
}
2934
}
3035

3136
describe('Context constructor', () => {
@@ -725,18 +730,48 @@ describe('Context', () => {
725730
});
726731

727732
describe('close()', () => {
728-
it('clears all bindings', () => {
729-
ctx.bind('foo').to('foo-value');
730-
expect(ctx.bindingMap.size).to.eql(1);
731-
ctx.close();
732-
expect(ctx.bindingMap.size).to.eql(0);
733+
it('clears all observers', () => {
734+
const childCtx = new TestContext(ctx);
735+
childCtx.subscribe(() => {});
736+
expect(childCtx.observers!.size).to.eql(1);
737+
childCtx.close();
738+
expect(childCtx.observers).to.be.undefined();
733739
});
734740

735-
it('dereferences parent', () => {
741+
it('removes listeners from parent context', () => {
736742
const childCtx = new TestContext(ctx);
737-
expect(childCtx.parent).to.equal(ctx);
743+
childCtx.subscribe(() => {});
744+
// Now we have one observer
745+
expect(childCtx.observers!.size).to.eql(1);
746+
// Two listeners are also added to the parent context
747+
const parentEventListeners = childCtx.parentEventListeners!;
748+
expect(parentEventListeners.size).to.eql(2);
749+
750+
// The map contains listeners added to the parent context
751+
// Take a snapshot into `copy`
752+
const copy = new Map(parentEventListeners);
753+
for (const [key, val] of copy.entries()) {
754+
expect(val).to.be.a.Function();
755+
expect(ctx.listeners(key)).to.containEql(val);
756+
}
757+
758+
// Now clear subscriptions
759+
childCtx.close();
760+
761+
// observers are gone
762+
expect(childCtx.observers).to.be.undefined();
763+
// listeners are removed from parent context
764+
for (const [key, val] of copy.entries()) {
765+
expect(ctx.listeners(key)).to.not.containEql(val);
766+
}
767+
});
768+
769+
it('keeps parent and bindings', () => {
770+
const childCtx = new TestContext(ctx);
771+
childCtx.bind('foo').to('foo-value');
738772
childCtx.close();
739-
expect(childCtx.parent).to.be.undefined();
773+
expect(childCtx.parent).to.equal(ctx);
774+
expect(childCtx.contains('foo'));
740775
});
741776
});
742777

packages/context/src/context.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,10 @@ export class Context extends EventEmitter {
403403
}
404404

405405
/**
406-
* Close the context and release references to other objects in the context
407-
* chain.
406+
* Close the context: clear observers, stop notifications, and remove event
407+
* listeners from its parent context.
408408
*
409+
* @remarks
409410
* This method MUST be called to avoid memory leaks once a context object is
410411
* no longer needed and should be recycled. An example is the `RequestContext`,
411412
* which is created per request.
@@ -426,8 +427,6 @@ export class Context extends EventEmitter {
426427
}
427428
this._parentEventListeners = undefined;
428429
}
429-
this.registry.clear();
430-
this._parent = undefined;
431430
}
432431

433432
/**

packages/rest/src/__tests__/integration/request-context.integration.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import {
99
createRestAppClient,
1010
expect,
1111
givenHttpServerConfig,
12-
supertest,
1312
httpsGetAsync,
13+
supertest,
1414
} from '@loopback/testlab';
1515
import * as express from 'express';
1616
import {RequestContext} from '../../request-context';
1717
import {RestApplication} from '../../rest.application';
18-
import {RestServerConfig} from '../../rest.server';
18+
import {RestServer, RestServerConfig} from '../../rest.server';
1919
import {DefaultSequence} from '../../sequence';
2020

2121
let app: RestApplication;
@@ -116,6 +116,22 @@ describe('RequestContext', () => {
116116
});
117117
});
118118

119+
describe('close', () => {
120+
it('removes listeners from parent context', async () => {
121+
await givenRunningAppWithClient();
122+
const server = await app.getServer(RestServer);
123+
// Running the request 5 times
124+
for (let i = 0; i < 5; i++) {
125+
await client
126+
.get('/products')
127+
.set('x-forwarded-proto', 'https')
128+
.expect(200);
129+
}
130+
expect(observedCtx.contains('req.originalUrl'));
131+
expect(server.listenerCount('bind')).to.eql(1);
132+
});
133+
});
134+
119135
function setup() {
120136
(app as unknown) = undefined;
121137
(client as unknown) = undefined;
@@ -141,5 +157,9 @@ function contextObservingHandler(
141157
_sequence: DefaultSequence,
142158
) {
143159
observedCtx = ctx;
160+
// Add a subscriber to verify `close()`
161+
ctx.subscribe(() => {});
162+
// Add a binding to the request context
163+
ctx.bind('req.originalUrl').to(ctx.request.originalUrl);
144164
ctx.response.end('ok');
145165
}

packages/rest/src/request-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export class RequestContext extends Context implements HandlerContext {
9595
super(parent, name);
9696
this._setupBindings(request, response);
9797
onFinished(this.response, () => {
98+
// Close the request context when the http response is finished so that
99+
// it can be recycled by GC
98100
this.close();
99101
});
100102
}

0 commit comments

Comments
 (0)