Skip to content

Commit 6ded045

Browse files
committed
fixes for WaitTimeout and added comments.
1 parent b8fa1f5 commit 6ded045

File tree

5 files changed

+316
-11
lines changed

5 files changed

+316
-11
lines changed

packages/core/src/amazonq/webview/ui/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,7 @@ export const createMynahUI = (
706706
return
707707
}
708708

709+
// For new user prompt stopping chat with UI changes
709710
mynahUI.updateStore(tabID, {
710711
loadingChat: false,
711712
promptInputDisabledState: false,

packages/core/src/codewhispererChat/clients/chat/v0/chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class ChatSession {
7474
try {
7575
this.tokenSource.dispose()
7676
} catch (error) {
77-
// Ignore errors during disposal
77+
getLogger().debug(`Error disposing token source: ${error}`)
7878
}
7979
}
8080
}

packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,7 @@ export class Messenger {
449449
},
450450
timeout,
451451
{
452-
onCancel: () => {
453-
if (this.isTriggerCancelled(triggerID)) {
454-
return true
455-
}
456-
return false
457-
},
452+
cancellationCondition: () => this.isTriggerCancelled(triggerID),
458453
}
459454
)
460455
.catch((error: any) => {

packages/core/src/shared/utilities/timeoutUtils.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,11 @@ export async function waitUntil<T>(fn: () => Promise<T>, options: WaitUntilOptio
346346
* @param opt.onExpire Callback for when the promise expired. The callback can return a value
347347
* @param opt.onCancel Callback for when the promise was cancelled. The callback can return a value
348348
* @param opt.completeTimeout Automatically completes the Timeout upon resolution (default: true)
349+
* @param opt.cancellationCondition Function that returns boolean, checked periodically for cancellation
350+
* @param opt.cancellationCheckInterval Interval in ms to check cancellation condition (default: 100)
351+
* @param opt.onCancellationConditionMet Callback for when the cancellation condition is met. The callback can return a value
349352
*
350-
* @returns A Promise that returns if successful, or rejects when the Timeout was cancelled or expired.
353+
* @returns A Promise that returns if successful, or returns the result of the appropriate callback when cancelled, expired, or cancellation condition is met.
351354
*/
352355
export async function waitTimeout<T, R = void, B extends boolean = true>(
353356
promise: Promise<T> | (() => Promise<T>),
@@ -357,13 +360,64 @@ export async function waitTimeout<T, R = void, B extends boolean = true>(
357360
onExpire?: () => R
358361
onCancel?: () => R
359362
completeTimeout?: boolean
363+
cancellationCondition?: () => boolean
364+
cancellationCheckInterval?: number
365+
onCancellationConditionMet?: () => R
360366
} = {}
361367
): Promise<T | R | (true extends typeof opt.allowUndefined ? undefined : never)> {
362-
if (typeof promise === 'function') {
363-
promise = promise()
368+
// Ensure we have a promise, not a function
369+
const actualPromise: Promise<T> = typeof promise === 'function' ? promise() : promise
370+
371+
// Create a promise that will check for cancellation periodically
372+
const cancellationMonitor = async (): Promise<T | R | undefined> => {
373+
// If there's a cancellation condition, set up periodic checking
374+
if (opt.cancellationCondition) {
375+
const checkInterval = opt.cancellationCheckInterval ?? 100 // Default to checking every 100ms
376+
377+
// Create a promise that resolves when cancellation condition is met
378+
const cancellationPromise = new Promise<void>((resolve) => {
379+
const intervalId = globals.clock.setInterval(() => {
380+
try {
381+
// The cancellation condition is a function reference that needs to be called
382+
if (opt.cancellationCondition && opt.cancellationCondition()) {
383+
globals.clock.clearInterval(intervalId)
384+
resolve()
385+
}
386+
} catch (err) {
387+
// If there's an error checking the condition, just continue
388+
console.error('Error checking cancellation condition:', err)
389+
}
390+
}, checkInterval)
391+
392+
// We need to clean up the interval when the main promise completes
393+
actualPromise
394+
.finally(() => {
395+
globals.clock.clearInterval(intervalId)
396+
})
397+
.catch(() => {
398+
/* Ignore any errors */
399+
})
400+
})
401+
402+
// Race the cancellation check against the main promise
403+
const winner = await Promise.race([actualPromise, cancellationPromise.then(() => 'CANCELLED' as const)])
404+
405+
// If cancellation won the race
406+
if (winner === 'CANCELLED') {
407+
if (opt.onCancellationConditionMet) {
408+
return opt.onCancellationConditionMet()
409+
}
410+
return undefined
411+
}
412+
413+
return winner
414+
}
415+
416+
// If no cancellation condition, just return the original promise
417+
return actualPromise
364418
}
365419

366-
const result = await Promise.race([promise, timeout.promisify()])
420+
const result = await Promise.race([cancellationMonitor(), timeout.promisify()])
367421
.catch((e) => (e instanceof Error ? e : new Error(`unknown error: ${e}`)))
368422
.finally(() => {
369423
if ((opt.completeTimeout ?? true) === true) {
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import * as vscode from 'vscode'
7+
import * as sinon from 'sinon'
8+
import * as assert from 'assert'
9+
import { ConversationTracker } from '../../../codewhispererChat/storages/conversationTracker'
10+
11+
describe('ConversationTracker', () => {
12+
let tracker: ConversationTracker
13+
let sandbox: sinon.SinonSandbox
14+
15+
beforeEach(() => {
16+
// Reset the singleton instance before each test
17+
// @ts-ignore: Accessing private static property for testing
18+
ConversationTracker.instance = undefined
19+
tracker = ConversationTracker.getInstance()
20+
sandbox = sinon.createSandbox()
21+
})
22+
23+
afterEach(() => {
24+
sandbox.restore()
25+
})
26+
27+
describe('getInstance', () => {
28+
it('should return the same instance when called multiple times', () => {
29+
const instance1 = ConversationTracker.getInstance()
30+
const instance2 = ConversationTracker.getInstance()
31+
assert.strictEqual(instance1, instance2)
32+
})
33+
})
34+
35+
describe('registerTrigger', () => {
36+
it('should register a trigger with a token', () => {
37+
const tokenSource = new vscode.CancellationTokenSource()
38+
tracker.registerTrigger('trigger1', tokenSource)
39+
40+
// @ts-ignore: Accessing private property for testing
41+
assert.strictEqual(tracker.triggerToToken.get('trigger1'), tokenSource)
42+
})
43+
44+
it('should associate a trigger with a tab', () => {
45+
const tokenSource = new vscode.CancellationTokenSource()
46+
tracker.registerTrigger('trigger1', tokenSource, 'tab1')
47+
48+
// @ts-ignore: Accessing private property for testing
49+
const tabTriggers = tracker.tabToTriggers.get('tab1')
50+
assert.deepStrictEqual(tabTriggers, ['trigger1'])
51+
})
52+
53+
it('should add new triggers to the beginning of the tab triggers array', () => {
54+
const tokenSource1 = new vscode.CancellationTokenSource()
55+
const tokenSource2 = new vscode.CancellationTokenSource()
56+
57+
tracker.registerTrigger('trigger1', tokenSource1, 'tab1')
58+
tracker.registerTrigger('trigger2', tokenSource2, 'tab1')
59+
60+
// @ts-ignore: Accessing private property for testing
61+
const tabTriggers = tracker.tabToTriggers.get('tab1')
62+
assert.deepStrictEqual(tabTriggers, ['trigger2', 'trigger1'])
63+
})
64+
65+
it('should not register if triggerID or tokenSource is missing', () => {
66+
const tokenSource = new vscode.CancellationTokenSource()
67+
68+
// @ts-ignore: Testing with invalid parameters
69+
tracker.registerTrigger(null, tokenSource)
70+
// @ts-ignore: Testing with invalid parameters
71+
tracker.registerTrigger('trigger1', null)
72+
73+
// @ts-ignore: Accessing private property for testing
74+
assert.strictEqual(tracker.triggerToToken.size, 0)
75+
})
76+
77+
it('should clean up old triggers when exceeding maxTriggersPerTab', () => {
78+
// @ts-ignore: Set a smaller maxTriggersPerTab for testing
79+
tracker.maxTriggersPerTab = 3
80+
81+
for (let i = 1; i <= 5; i++) {
82+
tracker.registerTrigger(`trigger${i}`, new vscode.CancellationTokenSource(), 'tab1')
83+
}
84+
85+
// @ts-ignore: Accessing private property for testing
86+
const tabTriggers = tracker.tabToTriggers.get('tab1')
87+
assert.strictEqual(tabTriggers?.length, 3)
88+
assert.deepStrictEqual(tabTriggers, ['trigger5', 'trigger4', 'trigger3'])
89+
})
90+
})
91+
92+
describe('markTriggerCompleted', () => {
93+
it('should dispose and remove the token for a completed trigger', () => {
94+
const tokenSource = new vscode.CancellationTokenSource()
95+
const disposeSpy = sandbox.spy(tokenSource, 'dispose')
96+
97+
tracker.registerTrigger('trigger1', tokenSource)
98+
tracker.markTriggerCompleted('trigger1')
99+
100+
assert.strictEqual(disposeSpy.calledOnce, true)
101+
// @ts-ignore: Accessing private property for testing
102+
assert.strictEqual(tracker.triggerToToken.has('trigger1'), false)
103+
})
104+
105+
it('should do nothing if triggerID is missing', () => {
106+
const tokenSource = new vscode.CancellationTokenSource()
107+
const disposeSpy = sandbox.spy(tokenSource, 'dispose')
108+
109+
tracker.registerTrigger('trigger1', tokenSource)
110+
// @ts-ignore: Testing with invalid parameter
111+
tracker.markTriggerCompleted(null)
112+
113+
assert.strictEqual(disposeSpy.called, false)
114+
// @ts-ignore: Accessing private property for testing
115+
assert.strictEqual(tracker.triggerToToken.has('trigger1'), true)
116+
})
117+
})
118+
119+
describe('cancelTrigger', () => {
120+
it('should cancel a trigger and return true', () => {
121+
const tokenSource = new vscode.CancellationTokenSource()
122+
const cancelSpy = sandbox.spy(tokenSource, 'cancel')
123+
124+
tracker.registerTrigger('trigger1', tokenSource)
125+
const result = tracker.cancelTrigger('trigger1')
126+
127+
assert.strictEqual(cancelSpy.calledOnce, true)
128+
assert.strictEqual(result, true)
129+
})
130+
131+
it('should return false if trigger does not exist', () => {
132+
const result = tracker.cancelTrigger('nonexistent')
133+
assert.strictEqual(result, false)
134+
})
135+
136+
it('should return false if triggerID is missing', () => {
137+
// @ts-ignore: Testing with invalid parameter
138+
const result = tracker.cancelTrigger(null)
139+
assert.strictEqual(result, false)
140+
})
141+
})
142+
143+
describe('cancelTabTriggers', () => {
144+
it('should cancel all triggers for a tab and return the count', () => {
145+
const tokenSource1 = new vscode.CancellationTokenSource()
146+
const tokenSource2 = new vscode.CancellationTokenSource()
147+
const cancelSpy1 = sandbox.spy(tokenSource1, 'cancel')
148+
const cancelSpy2 = sandbox.spy(tokenSource2, 'cancel')
149+
150+
tracker.registerTrigger('trigger1', tokenSource1, 'tab1')
151+
tracker.registerTrigger('trigger2', tokenSource2, 'tab1')
152+
153+
const result = tracker.cancelTabTriggers('tab1')
154+
155+
assert.strictEqual(cancelSpy1.calledOnce, true)
156+
assert.strictEqual(cancelSpy2.calledOnce, true)
157+
assert.strictEqual(result, 2)
158+
})
159+
160+
it('should return 0 if tab has no triggers', () => {
161+
const result = tracker.cancelTabTriggers('nonexistent')
162+
assert.strictEqual(result, 0)
163+
})
164+
165+
it('should return 0 if tabID is missing', () => {
166+
// @ts-ignore: Testing with invalid parameter
167+
const result = tracker.cancelTabTriggers(null)
168+
assert.strictEqual(result, 0)
169+
})
170+
})
171+
172+
describe('isTriggerCancelled', () => {
173+
it('should return true if trigger is cancelled', () => {
174+
const tokenSource = new vscode.CancellationTokenSource()
175+
tokenSource.cancel()
176+
177+
tracker.registerTrigger('trigger1', tokenSource)
178+
const result = tracker.isTriggerCancelled('trigger1')
179+
180+
assert.strictEqual(result, true)
181+
})
182+
183+
it('should return false if trigger is not cancelled', () => {
184+
const tokenSource = new vscode.CancellationTokenSource()
185+
186+
tracker.registerTrigger('trigger1', tokenSource)
187+
const result = tracker.isTriggerCancelled('trigger1')
188+
189+
assert.strictEqual(result, false)
190+
})
191+
192+
it('should return false if trigger does not exist', () => {
193+
const result = tracker.isTriggerCancelled('nonexistent')
194+
assert.strictEqual(result, false)
195+
})
196+
197+
it('should return true if triggerID is missing', () => {
198+
// @ts-ignore: Testing with invalid parameter
199+
const result = tracker.isTriggerCancelled(null)
200+
assert.strictEqual(result, true)
201+
})
202+
})
203+
204+
describe('getTokenForTrigger', () => {
205+
it('should return the token for a trigger', () => {
206+
const tokenSource = new vscode.CancellationTokenSource()
207+
208+
tracker.registerTrigger('trigger1', tokenSource)
209+
const result = tracker.getTokenForTrigger('trigger1')
210+
211+
assert.strictEqual(result, tokenSource.token)
212+
})
213+
214+
it('should return undefined if trigger does not exist', () => {
215+
const result = tracker.getTokenForTrigger('nonexistent')
216+
assert.strictEqual(result, undefined)
217+
})
218+
})
219+
220+
describe('clearTabTriggers', () => {
221+
it('should clear all triggers for a tab without cancelling them', () => {
222+
const tokenSource1 = new vscode.CancellationTokenSource()
223+
const tokenSource2 = new vscode.CancellationTokenSource()
224+
const cancelSpy1 = sandbox.spy(tokenSource1, 'cancel')
225+
const cancelSpy2 = sandbox.spy(tokenSource2, 'cancel')
226+
227+
tracker.registerTrigger('trigger1', tokenSource1, 'tab1')
228+
tracker.registerTrigger('trigger2', tokenSource2, 'tab1')
229+
230+
const result = tracker.clearTabTriggers('tab1')
231+
232+
assert.strictEqual(cancelSpy1.called, false)
233+
assert.strictEqual(cancelSpy2.called, false)
234+
assert.strictEqual(result, 2)
235+
236+
// @ts-ignore: Accessing private property for testing
237+
assert.strictEqual(tracker.triggerToToken.has('trigger1'), false)
238+
// @ts-ignore: Accessing private property for testing
239+
assert.strictEqual(tracker.triggerToToken.has('trigger2'), false)
240+
// @ts-ignore: Accessing private property for testing
241+
assert.strictEqual(tracker.tabToTriggers.has('tab1'), false)
242+
})
243+
244+
it('should return 0 if tab has no triggers', () => {
245+
const result = tracker.clearTabTriggers('nonexistent')
246+
assert.strictEqual(result, 0)
247+
})
248+
249+
it('should return 0 if tabID is missing', () => {
250+
// @ts-ignore: Testing with invalid parameter
251+
const result = tracker.clearTabTriggers(null)
252+
assert.strictEqual(result, 0)
253+
})
254+
})
255+
})

0 commit comments

Comments
 (0)