Skip to content

Commit 68c9e6d

Browse files
committed
fix: resolve debounce promise hanging in GhostInlineCompletionProvider
Fixed timeout issues in request deduplication tests by ensuring all pending debounce promises are properly resolved. The previous implementation would create new promises on each call but never resolve old ones when the timer was cleared and reset. Changes: - Added pendingDebounceResolvers array to track all promise resolvers - Resolve all accumulated promises when debounce timer fires - Distinguish between 'typing ahead' (reuse request) and 'diverging' (flush and start new) - Clear pending resolvers in dispose() method - Updated test helpers to properly handle async timer advancement All 5458 tests now pass, including the 5 request deduplication tests that were previously timing out after 20 seconds.
1 parent 80cd15d commit 68c9e6d

File tree

2 files changed

+97
-22
lines changed

2 files changed

+97
-22
lines changed

src/services/ghost/classic-auto-complete/GhostInlineCompletionProvider.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
204204
clearTimeout(this.debounceTimer)
205205
this.debounceTimer = null
206206
}
207+
// Clear pending debounce resolvers
208+
this.pendingDebounceResolvers = []
207209
// Cancel all pending requests
208210
for (const pending of this.pendingRequests.values()) {
209211
pending.abortController.abort()
@@ -292,16 +294,58 @@ export class GhostInlineCompletionProvider implements vscode.InlineCompletionIte
292294
}
293295
}
294296

297+
private pendingDebounceResolvers: Array<() => void> = []
298+
private lastDebouncedPrompt: { prompt: GhostPrompt; prefix: string; suffix: string } | null = null
299+
295300
private debouncedFetchAndCacheSuggestion(prompt: GhostPrompt, prefix: string, suffix: string): Promise<void> {
296-
if (this.debounceTimer !== null) {
297-
clearTimeout(this.debounceTimer)
301+
// Check if we have a pending request with a different prefix/suffix
302+
if (this.debounceTimer !== null && this.lastDebouncedPrompt) {
303+
const lastPrompt = this.lastDebouncedPrompt
304+
305+
// Check if this is a "typing ahead" scenario (new prefix starts with old prefix and same suffix)
306+
const isTypingAhead = prefix.startsWith(lastPrompt.prefix) && suffix === lastPrompt.suffix
307+
308+
// Check if prefix has truly diverged (not just typing ahead)
309+
const hasDiverged = !isTypingAhead && (lastPrompt.prefix !== prefix || lastPrompt.suffix !== suffix)
310+
311+
if (hasDiverged) {
312+
// Prefix/suffix has diverged - flush the pending request immediately
313+
clearTimeout(this.debounceTimer)
314+
this.debounceTimer = null
315+
316+
// Trigger the previous request
317+
const previousResolvers = this.pendingDebounceResolvers.splice(0)
318+
this.fetchAndCacheSuggestion(lastPrompt.prompt, lastPrompt.prefix, lastPrompt.suffix).then(() => {
319+
previousResolvers.forEach((r) => r())
320+
})
321+
} else {
322+
// Same prefix/suffix or typing ahead - just clear the timer to restart debounce
323+
clearTimeout(this.debounceTimer)
324+
}
298325
}
299326

327+
// Store the current prompt
328+
this.lastDebouncedPrompt = { prompt, prefix, suffix }
329+
300330
return new Promise<void>((resolve) => {
331+
// Add this resolver to the list
332+
this.pendingDebounceResolvers.push(resolve)
333+
301334
this.debounceTimer = setTimeout(async () => {
302335
this.debounceTimer = null
303-
await this.fetchAndCacheSuggestion(prompt, prefix, suffix)
304-
resolve()
336+
// Use the last prompt that was set
337+
if (this.lastDebouncedPrompt) {
338+
await this.fetchAndCacheSuggestion(
339+
this.lastDebouncedPrompt.prompt,
340+
this.lastDebouncedPrompt.prefix,
341+
this.lastDebouncedPrompt.suffix,
342+
)
343+
this.lastDebouncedPrompt = null
344+
}
345+
346+
// Resolve all pending promises
347+
const resolvers = this.pendingDebounceResolvers.splice(0)
348+
resolvers.forEach((r) => r())
305349
}, DEBOUNCE_DELAY_MS)
306350
})
307351
}

src/services/ghost/classic-auto-complete/__tests__/GhostInlineCompletionProvider.request-deduplication.test.ts

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ describe("GhostInlineCompletionProvider - Request Deduplication", () => {
3333
// Helper to call provideInlineCompletionItems and advance timers
3434
async function provideWithDebounce(doc: vscode.TextDocument, pos: vscode.Position) {
3535
const promise = provider.provideInlineCompletionItems_Internal(doc, pos, {} as any, {} as any)
36-
await vi.advanceTimersByTimeAsync(300) // Advance past debounce delay
37-
return promise
36+
// Wait a tick to let the promise chain set up
37+
await Promise.resolve()
38+
// Advance timers past debounce delay
39+
await vi.advanceTimersByTimeAsync(300)
40+
// Wait for the completion to finish
41+
return await promise
3842
}
3943

4044
beforeEach(() => {
@@ -49,9 +53,16 @@ describe("GhostInlineCompletionProvider - Request Deduplication", () => {
4953

5054
mockContextProvider = {
5155
getIde: vi.fn().mockReturnValue(mockIde),
52-
getFormattedContext: vi.fn().mockResolvedValue(""),
53-
getFimFormattedContext: vi.fn().mockResolvedValue({ prefix: "" }),
54-
getFimCompiledPrefix: vi.fn().mockResolvedValue(""),
56+
getProcessedSnippets: vi.fn().mockResolvedValue({
57+
filepathUri: "file:///test.ts",
58+
helper: {
59+
lang: { name: "typescript", singleLineComment: "//" },
60+
prunedPrefix: "",
61+
prunedSuffix: "",
62+
},
63+
snippetsWithUris: [],
64+
workspaceDirs: [],
65+
}),
5566
}
5667

5768
mockModel = {
@@ -93,17 +104,25 @@ describe("GhostInlineCompletionProvider - Request Deduplication", () => {
93104
let callCount = 0
94105
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
95106
callCount++
96-
onChunk({ type: "text", text: "test suggestion" })
107+
onChunk({ type: "text", text: "<COMPLETION>test suggestion</COMPLETION>" })
97108
return mockResponse
98109
})
99110

100111
const document = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = \nconst y = 2")
101112
const position = new vscode.Position(0, 10)
102113

103-
// Make two identical requests quickly
104-
const promise1 = provideWithDebounce(document, position)
105-
const promise2 = provideWithDebounce(document, position)
114+
// Make two identical requests - the second should reuse the first's pending request
115+
const promise1 = provider.provideInlineCompletionItems_Internal(document, position, {} as any, {} as any)
116+
117+
// Wait a tick to let the first request's debounce timer start
118+
await Promise.resolve()
119+
120+
const promise2 = provider.provideInlineCompletionItems_Internal(document, position, {} as any, {} as any)
106121

122+
// Advance timers to trigger the debounce
123+
await vi.advanceTimersByTimeAsync(300)
124+
125+
// Wait for both promises to complete
107126
await Promise.all([promise1, promise2])
108127

109128
// Should only call the API once due to deduplication
@@ -122,20 +141,26 @@ describe("GhostInlineCompletionProvider - Request Deduplication", () => {
122141
let callCount = 0
123142
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
124143
callCount++
125-
onChunk({ type: "text", text: "function test() {}" })
144+
onChunk({ type: "text", text: "<COMPLETION>function test() {}</COMPLETION>" })
126145
return mockResponse
127146
})
128147

129148
const document = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = f\nconst y = 2")
130149
const position1 = new vscode.Position(0, 11)
131-
const position2 = new vscode.Position(0, 12) // User typed one more character
132150

133151
// Start first request
134-
const promise1 = provideWithDebounce(document, position1)
152+
const promise1 = provider.provideInlineCompletionItems_Internal(document, position1, {} as any, {} as any)
153+
154+
// Wait a tick
155+
await Promise.resolve()
135156

157+
// User types ahead - new document with one more character
136158
const document2 = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = fu\nconst y = 2")
159+
const position2 = new vscode.Position(0, 12)
160+
const promise2 = provider.provideInlineCompletionItems_Internal(document2, position2, {} as any, {} as any)
137161

138-
const promise2 = provideWithDebounce(document2, position2)
162+
// Advance timers
163+
await vi.advanceTimersByTimeAsync(300)
139164

140165
await Promise.all([promise1, promise2])
141166

@@ -156,19 +181,25 @@ describe("GhostInlineCompletionProvider - Request Deduplication", () => {
156181

157182
vi.mocked(mockModel.generateResponse).mockImplementation(async (_sys, _user, onChunk) => {
158183
callCount++
159-
onChunk({ type: "text", text: "test suggestion" })
184+
onChunk({ type: "text", text: "<COMPLETION>test suggestion</COMPLETION>" })
160185
return mockResponse
161186
})
162187

163188
const document1 = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = f\nconst y = 2")
164-
const document2 = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = g\nconst y = 2")
165-
166189
const position = new vscode.Position(0, 11)
167190

168191
// Start first request
169-
const promise1 = provideWithDebounce(document1, position)
192+
const promise1 = provider.provideInlineCompletionItems_Internal(document1, position, {} as any, {} as any)
193+
194+
// Wait a tick
195+
await Promise.resolve()
196+
197+
// User changes to different prefix - this should cancel the first request
198+
const document2 = new MockTextDocument(vscode.Uri.file("/test/file.ts"), "const x = g\nconst y = 2")
199+
const promise2 = provider.provideInlineCompletionItems_Internal(document2, position, {} as any, {} as any)
170200

171-
const promise2 = provideWithDebounce(document2, position)
201+
// Advance timers
202+
await vi.advanceTimersByTimeAsync(300)
172203

173204
await Promise.all([promise1, promise2])
174205

0 commit comments

Comments
 (0)