Skip to content

Commit 7a0d456

Browse files
keeganirbykaranA-aws
authored andcommitted
fix(cwl): Dispose event listeners when command exits (aws#6095)
## Problem TailLogGroup command is not being fully cleaned up properly. The event listeners for: * showing the StatusBar item * closing a session when exiting its editors are not being disposed of. As users stop sessions, these listeners remain active whilst doing no meaningful work. This is effectively a memory leak. Additionally during testing, I noticed that we are adding a session to the LiveTailRegistry before the session is actually started. This could cause an issue where a session stream isn't successfully created, but the session is still registered. ## Solution * Create an array of disposables and dispose of them as we exit the TailLogGroup command * Only register a session after startLiveTail returns Because now we dispose of the "tab close" listener when tailLogGroup exits, changes are needed in the unit tests to keep the mock response stream "open" as the test executes. This keeps tailLogGroup from returning/disposing while we perform test assertions and close the editors.
1 parent e7e789b commit 7a0d456

File tree

2 files changed

+86
-88
lines changed

2 files changed

+86
-88
lines changed

packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,28 @@ export async function tailLogGroup(
5252
})
5353
return
5454
}
55-
56-
registry.set(uriToKey(session.uri), session)
57-
5855
const document = await prepareDocument(session)
5956

60-
hideShowStatusBarItemsOnActiveEditor(session, document)
61-
registerTabChangeCallback(session, registry, document, codeLensProvider)
57+
const disposables: vscode.Disposable[] = []
58+
disposables.push(hideShowStatusBarItemsOnActiveEditor(session, document))
59+
disposables.push(closeSessionWhenAllEditorsClosed(session, registry, document, codeLensProvider))
6260

63-
const stream = await session.startLiveTailSession()
64-
getLogger().info(`LiveTail session started: ${uriToKey(session.uri)}`)
65-
66-
span.record({
67-
source: source,
68-
result: 'Succeeded',
69-
sessionAlreadyStarted: false,
70-
hasLogEventFilterPattern: Boolean(wizardResponse.filterPattern),
71-
logStreamFilterType: wizardResponse.logStreamFilter.type,
72-
})
73-
await handleSessionStream(stream, document, session)
61+
try {
62+
const stream = await session.startLiveTailSession()
63+
registry.set(uriToKey(session.uri), session)
64+
codeLensProvider.refresh()
65+
getLogger().info(`LiveTail session started: ${uriToKey(session.uri)}`)
66+
span.record({
67+
source: source,
68+
result: 'Succeeded',
69+
sessionAlreadyStarted: false,
70+
hasLogEventFilterPattern: Boolean(wizardResponse.filterPattern),
71+
logStreamFilterType: wizardResponse.logStreamFilter.type,
72+
})
73+
await handleSessionStream(stream, document, session)
74+
} finally {
75+
disposables.forEach((disposable) => disposable.dispose())
76+
}
7477
})
7578
}
7679

@@ -224,8 +227,11 @@ function eventRate(event: LiveTailSessionUpdate): number {
224227
return event.sessionResults === undefined ? 0 : event.sessionResults.length
225228
}
226229

227-
function hideShowStatusBarItemsOnActiveEditor(session: LiveTailSession, document: vscode.TextDocument) {
228-
vscode.window.onDidChangeActiveTextEditor((editor) => {
230+
function hideShowStatusBarItemsOnActiveEditor(
231+
session: LiveTailSession,
232+
document: vscode.TextDocument
233+
): vscode.Disposable {
234+
return vscode.window.onDidChangeActiveTextEditor((editor) => {
229235
session.showStatusBarItem(editor?.document === document)
230236
})
231237
}
@@ -242,13 +248,13 @@ function hideShowStatusBarItemsOnActiveEditor(session: LiveTailSession, document
242248
* from view, will not be returned. Meaning a Tab that is created (shown in top bar), but not open, will not be returned. Even if
243249
* the tab isn't visible, we want to continue writing to the doc, and keep the session alive.
244250
*/
245-
function registerTabChangeCallback(
251+
function closeSessionWhenAllEditorsClosed(
246252
session: LiveTailSession,
247253
registry: LiveTailSessionRegistry,
248254
document: vscode.TextDocument,
249255
codeLensProvider: LiveTailCodeLensProvider
250-
) {
251-
vscode.window.tabGroups.onDidChangeTabs((tabEvent) => {
256+
): vscode.Disposable {
257+
return vscode.window.tabGroups.onDidChangeTabs((tabEvent) => {
252258
const isOpen = isLiveTailSessionOpenInAnyTab(session)
253259
if (!isOpen) {
254260
closeSession(session.uri, registry, 'ClosedEditors', codeLensProvider)

packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts

Lines changed: 59 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,20 @@
44
*/
55

66
import * as sinon from 'sinon'
7-
import * as FakeTimers from '@sinonjs/fake-timers'
87
import * as vscode from 'vscode'
98

109
import assert from 'assert'
1110
import { clearDocument, closeSession, tailLogGroup } from '../../../../awsService/cloudWatchLogs/commands/tailLogGroup'
12-
import { LiveTailSessionLogEvent, StartLiveTailResponseStream } from '@aws-sdk/client-cloudwatch-logs'
11+
import { StartLiveTailResponseStream } from '@aws-sdk/client-cloudwatch-logs'
1312
import { LiveTailSessionRegistry } from '../../../../awsService/cloudWatchLogs/registry/liveTailSessionRegistry'
1413
import { LiveTailSession } from '../../../../awsService/cloudWatchLogs/registry/liveTailSession'
15-
import { asyncGenerator } from '../../../../shared/utilities/collectionUtils'
1614
import {
1715
TailLogGroupWizard,
1816
TailLogGroupWizardResponse,
1917
} from '../../../../awsService/cloudWatchLogs/wizard/tailLogGroupWizard'
2018
import { getTestWindow } from '../../../shared/vscode/window'
2119
import { CloudWatchLogsSettings, uriToKey } from '../../../../awsService/cloudWatchLogs/cloudWatchLogsUtils'
22-
import { installFakeClock } from '../../../testUtil'
23-
import { DefaultAwsContext, ToolkitError } from '../../../../shared'
20+
import { DefaultAwsContext, ToolkitError, waitUntil } from '../../../../shared'
2421
import { LiveTailCodeLensProvider } from '../../../../awsService/cloudWatchLogs/document/liveTailCodeLensProvider'
2522

2623
describe('TailLogGroup', function () {
@@ -39,23 +36,12 @@ describe('TailLogGroup', function () {
3936
let cloudwatchSettingsSpy: sinon.SinonSpy
4037
let wizardSpy: sinon.SinonSpy
4138

42-
let clock: FakeTimers.InstalledClock
43-
44-
before(function () {
45-
clock = installFakeClock()
46-
})
47-
4839
beforeEach(function () {
49-
clock.reset()
5040
sandbox = sinon.createSandbox()
5141
registry = new LiveTailSessionRegistry()
5242
codeLensProvider = new LiveTailCodeLensProvider(registry)
5343
})
5444

55-
after(function () {
56-
clock.uninstall()
57-
})
58-
5945
afterEach(function () {
6046
sandbox.restore()
6147
})
@@ -64,47 +50,46 @@ describe('TailLogGroup', function () {
6450
sandbox.stub(DefaultAwsContext.prototype, 'getCredentialAccountId').returns(testAwsAccountId)
6551
sandbox.stub(DefaultAwsContext.prototype, 'getCredentials').returns(Promise.resolve(testAwsCredentials))
6652

67-
wizardSpy = sandbox.stub(TailLogGroupWizard.prototype, 'run').callsFake(async function () {
68-
return getTestWizardResponse()
69-
})
70-
const testMessage2 = `${testMessage}-2`
71-
const testMessage3 = `${testMessage}-3`
53+
const startTimestamp = 1732276800000 // 11-22-2024 12:00:00PM GMT
54+
const updateFrames: StartLiveTailResponseStream[] = [
55+
getSessionUpdateFrame(false, `${testMessage}-1`, startTimestamp + 1000),
56+
getSessionUpdateFrame(false, `${testMessage}-2`, startTimestamp + 2000),
57+
getSessionUpdateFrame(false, `${testMessage}-3`, startTimestamp + 3000),
58+
]
59+
//Returns the configured update frames and then indefinitely blocks.
60+
//This keeps the stream 'open', simulating an open network stream waiting for new events.
61+
//If the stream were to close, the event listeners in the TailLogGroup command would dispose,
62+
//breaking the 'closes tab closes session' assertions this test makes.
63+
async function* generator(): AsyncIterable<StartLiveTailResponseStream> {
64+
for (const frame of updateFrames) {
65+
yield frame
66+
}
67+
await new Promise(() => {})
68+
}
69+
7270
startLiveTailSessionSpy = sandbox
7371
.stub(LiveTailSession.prototype, 'startLiveTailSession')
74-
.callsFake(async function () {
75-
return getTestResponseStream([
76-
{
77-
message: testMessage,
78-
timestamp: 876830400000,
79-
},
80-
{
81-
message: testMessage2,
82-
timestamp: 876830402000,
83-
},
84-
{
85-
message: testMessage3,
86-
timestamp: 876830403000,
87-
},
88-
])
89-
})
72+
.returns(Promise.resolve(generator()))
9073
stopLiveTailSessionSpy = sandbox
9174
.stub(LiveTailSession.prototype, 'stopLiveTailSession')
9275
.callsFake(async function () {
9376
return
9477
})
95-
78+
wizardSpy = sandbox.stub(TailLogGroupWizard.prototype, 'run').callsFake(async function () {
79+
return getTestWizardResponse()
80+
})
9681
//Set maxLines to 1.
9782
cloudwatchSettingsSpy = sandbox.stub(CloudWatchLogsSettings.prototype, 'get').callsFake(() => {
9883
return 1
9984
})
100-
await tailLogGroup(registry, testSource, codeLensProvider, {
85+
86+
//The mock stream doesn't 'close', causing tailLogGroup to not return. If we `await`, it will never resolve.
87+
//Run it in the background and use waitUntil to poll its state.
88+
void tailLogGroup(registry, testSource, codeLensProvider, {
10189
groupName: testLogGroup,
10290
regionName: testRegion,
10391
})
104-
assert.strictEqual(wizardSpy.calledOnce, true)
105-
assert.strictEqual(cloudwatchSettingsSpy.calledOnce, true)
106-
assert.strictEqual(startLiveTailSessionSpy.calledOnce, true)
107-
assert.strictEqual(registry.size, 1)
92+
await waitUntil(async () => registry.size !== 0, { interval: 100, timeout: 1000 })
10893

10994
//registry is asserted to have only one entry, so this is assumed to be the session that was
11095
//started in this test.
@@ -113,13 +98,24 @@ describe('TailLogGroup', function () {
11398
if (sessionUri === undefined) {
11499
throw Error
115100
}
116-
const document = getTestWindow().activeTextEditor?.document
101+
102+
assert.strictEqual(wizardSpy.calledOnce, true)
103+
assert.strictEqual(cloudwatchSettingsSpy.calledOnce, true)
104+
assert.strictEqual(startLiveTailSessionSpy.calledOnce, true)
105+
assert.strictEqual(registry.size, 1)
106+
107+
//Validate writing to the document.
108+
//MaxLines is set to 1, and "testMessage3" is the last event in the stream, its contents should be the only thing in the doc.
109+
const window = getTestWindow()
110+
const document = window.activeTextEditor?.document
117111
assert.strictEqual(sessionUri.toString(), document?.uri.toString())
118-
//Test responseStream has 3 events, maxLines is set to 1. Only 3rd event should be in doc.
119-
assert.strictEqual(document?.getText().trim(), `12:00:03\t${testMessage3}`)
112+
const doesDocumentContainExpectedContent = await waitUntil(
113+
async () => document?.getText().trim() === `12:00:03\t${testMessage}-3`,
114+
{ interval: 100, timeout: 1000 }
115+
)
116+
assert.strictEqual(doesDocumentContainExpectedContent, true)
120117

121118
//Test that closing all tabs the session's document is open in will cause the session to close
122-
const window = getTestWindow()
123119
let tabs: vscode.Tab[] = []
124120
window.tabGroups.all.forEach((tabGroup) => {
125121
tabs = tabs.concat(getLiveTailSessionTabsFromTabGroup(tabGroup, sessionUri!))
@@ -159,7 +155,6 @@ describe('TailLogGroup', function () {
159155
closeSession(session.uri, registry, testSource, codeLensProvider)
160156
assert.strictEqual(0, registry.size)
161157
assert.strictEqual(true, stopLiveTailSessionSpy.calledOnce)
162-
assert.strictEqual(0, clock.countTimers())
163158
})
164159

165160
it('clearDocument clears all text from document', async function () {
@@ -200,26 +195,23 @@ describe('TailLogGroup', function () {
200195
}
201196
}
202197

203-
//Creates a test response stream. Each log event provided will be its own "frame" of the input stream.
204-
function getTestResponseStream(logEvents: LiveTailSessionLogEvent[]): AsyncIterable<StartLiveTailResponseStream> {
205-
const sessionStartFrame: StartLiveTailResponseStream = {
206-
sessionStart: {
207-
logGroupIdentifiers: [testLogGroup],
198+
function getSessionUpdateFrame(
199+
isSampled: boolean,
200+
message: string,
201+
timestamp: number
202+
): StartLiveTailResponseStream {
203+
return {
204+
sessionUpdate: {
205+
sessionMetadata: {
206+
sampled: isSampled,
207+
},
208+
sessionResults: [
209+
{
210+
message: message,
211+
timestamp: timestamp,
212+
},
213+
],
208214
},
209-
sessionUpdate: undefined,
210215
}
211-
212-
const updateFrames: StartLiveTailResponseStream[] = logEvents.map((event) => {
213-
return {
214-
sessionUpdate: {
215-
sessionMetadata: {
216-
sampled: false,
217-
},
218-
sessionResults: [event],
219-
},
220-
}
221-
})
222-
223-
return asyncGenerator([sessionStartFrame, ...updateFrames])
224216
}
225217
})

0 commit comments

Comments
 (0)