Skip to content

Commit 178fdec

Browse files
committed
test(bedrock): strengthen abort signal coverage and add CodeRabbit rules
1 parent 71448e7 commit 178fdec

3 files changed

Lines changed: 156 additions & 42 deletions

File tree

.coderabbit.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ reviews:
5656
Check cleanup and deterministic async behavior and prefer shared typed test helpers.
5757
Visible webview changes require a durable Playwright component snapshot; behavior-only
5858
changes do not.
59+
Reject weak assertions on values that could take multiple forms: .toBeDefined() or
60+
.toHaveBeenCalled() alone are not sufficient when the actual type, value, or object
61+
identity is verifiable. For listener registration and removal, assert the same function
62+
reference was added and removed (not expect.any(Function)).
63+
Flag tests that assert in-flight behavior only after the call completes — these cannot
64+
prove the behavior fires during execution. Check that describe block names match the
65+
actual subjects of the tests they contain.
5966
6067
- path: "apps/vscode-e2e/**"
6168
instructions: >-

src/api/providers/__tests__/bedrock.spec.ts

Lines changed: 140 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,9 @@ describe("AwsBedrockHandler", () => {
18201820
expect(isAdaptiveThinkingModel("anthropic.claude-3-5-sonnet-20241022-v2:0")).toBe(false)
18211821
expect(isAdaptiveThinkingModel("amazon.nova-lite-v1:0")).toBe(false)
18221822
})
1823+
})
1824+
1825+
describe("completePrompt and createMessage: abort signal and listener lifecycle", () => {
18231826
it("should pass abort signal through to client.send", async () => {
18241827
const mockSend = vi.fn()
18251828

@@ -1893,6 +1896,9 @@ describe("AwsBedrockHandler", () => {
18931896
const sendOptions = mockSend.mock.calls[0][1]
18941897
expect(sendOptions).toBeDefined()
18951898
expect(sendOptions?.abortSignal).toBeDefined()
1899+
// The signal must not be aborted yet (i.e. a real timeout signal was created,
1900+
// not a no-op placeholder)
1901+
expect(sendOptions?.abortSignal.aborted).toBe(false)
18961902
})
18971903

18981904
it("completePrompt should merge abortSignal and timeoutMs", async () => {
@@ -1918,42 +1924,62 @@ describe("AwsBedrockHandler", () => {
19181924
expect(mockSend).toHaveBeenCalled()
19191925
const sendOptions = mockSend.mock.calls[0][1]
19201926
expect(sendOptions?.abortSignal).toBeDefined()
1927+
// AbortSignal.any() returns a new composite object; if the merge were skipped and
1928+
// the external signal returned directly, this assertion would fail
1929+
expect(sendOptions?.abortSignal).not.toBe(controller.signal)
1930+
// The merged signal propagates the external abort
1931+
controller.abort()
1932+
expect(sendOptions?.abortSignal.aborted).toBe(true)
19211933
})
19221934

1923-
it("should abort internal controller when external abortSignal is triggered", async () => {
1924-
const mockResult = {
1925-
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1926-
}
1927-
const mockSend = vi.fn().mockResolvedValue(mockResult)
1928-
1935+
it("should abort the merged signal mid-flight when the external abortSignal fires", async () => {
1936+
// This test keeps client.send pending so it can verify that aborting the external
1937+
// signal while the request is in flight propagates through the composite signal
1938+
// and cancels the SDK call — a post-completion check cannot prove this.
19291939
const handler = new AwsBedrockHandler({
19301940
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
19311941
awsAccessKey: "test-access-key",
19321942
awsSecretKey: "test-secret-key",
19331943
awsRegion: "us-east-1",
19341944
})
19351945

1936-
const clientInstance = handler["client"]
1937-
clientInstance.send = mockSend
1938-
19391946
const controller = new AbortController()
19401947
let internalSignalCaptured: AbortSignal | undefined
19411948

1942-
// Spy on the send call to capture the abortSignal
1943-
mockSend.mockImplementation(async (_command: unknown, options?: { abortSignal?: AbortSignal }) => {
1944-
internalSignalCaptured = options?.abortSignal
1945-
return mockResult
1949+
const mockSend = vi
1950+
.fn()
1951+
.mockImplementation((_command: unknown, options?: { abortSignal?: AbortSignal }) => {
1952+
internalSignalCaptured = options?.abortSignal
1953+
return new Promise<unknown>((_resolve, reject) => {
1954+
internalSignalCaptured?.addEventListener(
1955+
"abort",
1956+
() => reject(new DOMException("The operation was aborted.", "AbortError")),
1957+
{ once: true },
1958+
)
1959+
})
1960+
})
1961+
handler["client"].send = mockSend
1962+
1963+
// Pass timeoutMs so mergeAbortSignalAndTimeout creates a composite via AbortSignal.any()
1964+
const sendPromise = handler.completePrompt("test prompt", {
1965+
abortSignal: controller.signal,
1966+
timeoutMs: 5000,
19461967
})
19471968

1948-
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
1969+
// Wait until client.send is in flight and the composite signal is captured
1970+
await vi.waitFor(() => {
1971+
expect(internalSignalCaptured).toBeDefined()
1972+
})
19491973

1950-
expect(internalSignalCaptured).toBeDefined()
1951-
expect(internalSignalCaptured).toBeInstanceOf(AbortSignal)
1974+
// The composite is a distinct object — not the same reference as the external signal
1975+
expect(internalSignalCaptured).not.toBe(controller.signal)
1976+
expect(internalSignalCaptured?.aborted).toBe(false)
19521977

1953-
// Abort the external signal and verify it propagates to the captured signal
1978+
// Abort mid-flight; the composite must propagate it immediately (synchronous)
19541979
controller.abort()
1955-
await new Promise((resolve) => setTimeout(resolve, 10))
19561980
expect(internalSignalCaptured?.aborted).toBe(true)
1981+
1982+
await expect(sendPromise).rejects.toMatchObject({ name: "AbortError" })
19571983
})
19581984

19591985
it("should abort immediately when signal is already aborted and timeoutMs > 0", async () => {
@@ -1982,6 +2008,9 @@ describe("AwsBedrockHandler", () => {
19822008
const sendOptions = mockSend.mock.calls[0][1]
19832009
expect(sendOptions?.abortSignal).toBeDefined()
19842010
expect(sendOptions?.abortSignal.aborted).toBe(true)
2011+
// AbortSignal.any() always returns a new composite; this distinguishes the merged
2012+
// path from a mutation that returns the pre-aborted external signal directly
2013+
expect(sendOptions?.abortSignal).not.toBe(controller.signal)
19852014
})
19862015

19872016
it("should return undefined sendOptions when timeoutMs is 0 and no signal", async () => {
@@ -2087,6 +2116,54 @@ describe("AwsBedrockHandler", () => {
20872116
expect(result).toBe("")
20882117
})
20892118

2119+
it("completePrompt should reject with AbortError when the signal is aborted while client.send is in flight", async () => {
2120+
const handler = new AwsBedrockHandler({
2121+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
2122+
awsAccessKey: "test-access-key",
2123+
awsSecretKey: "test-secret-key",
2124+
awsRegion: "us-east-1",
2125+
})
2126+
2127+
const controller = new AbortController()
2128+
let rejectSend: ((err: unknown) => void) | undefined
2129+
2130+
// mockSend hangs until the abort signal fires
2131+
const mockSend = vi
2132+
.fn()
2133+
.mockImplementation((_command: unknown, options?: { abortSignal?: AbortSignal }) => {
2134+
return new Promise<unknown>((_resolve, reject) => {
2135+
rejectSend = reject
2136+
options?.abortSignal?.addEventListener(
2137+
"abort",
2138+
() => {
2139+
const abortError = new DOMException("The operation was aborted.", "AbortError")
2140+
reject(abortError)
2141+
},
2142+
{ once: true },
2143+
)
2144+
})
2145+
})
2146+
handler["client"].send = mockSend
2147+
2148+
const sendPromise = handler.completePrompt("test prompt", { abortSignal: controller.signal })
2149+
2150+
// Wait until send is in flight
2151+
await vi.waitFor(() => {
2152+
expect(rejectSend).toBeDefined()
2153+
})
2154+
2155+
// Abort mid-flight; completePrompt must reject with AbortError.
2156+
// Also assert the ABORT classification is applied: the error message must come
2157+
// from the ABORT template ("Request was aborted"), not the GENERIC fallback.
2158+
// This proves "ABORT" sits in errorTypeOrder before competing patterns.
2159+
controller.abort()
2160+
2161+
await expect(sendPromise).rejects.toMatchObject({
2162+
name: "AbortError",
2163+
message: expect.stringContaining("Request was aborted"),
2164+
})
2165+
})
2166+
20902167
it("createMessage should reject with an AbortError when the external signal is already aborted", async () => {
20912168
const handler = new AwsBedrockHandler({
20922169
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
@@ -2163,28 +2240,42 @@ describe("AwsBedrockHandler", () => {
21632240

21642241
const controller = new AbortController()
21652242

2166-
const generator = handler.createMessage(
2167-
"You are a helpful assistant",
2168-
[{ role: "user", content: "Hello" }],
2169-
makeCreateMessageMetadata({ abortSignal: controller.signal }),
2170-
)
2243+
// Spy before createMessage so we capture the exact listener the production code registers
2244+
const addSpy = vi.spyOn(controller.signal, "addEventListener")
2245+
const removeSpy = vi.spyOn(controller.signal, "removeEventListener")
21712246

2172-
const consumed = (async () => {
2173-
for await (const _chunk of generator) {
2174-
// ignore chunks
2175-
}
2176-
})()
2247+
try {
2248+
const generator = handler.createMessage(
2249+
"You are a helpful assistant",
2250+
[{ role: "user", content: "Hello" }],
2251+
makeCreateMessageMetadata({ abortSignal: controller.signal }),
2252+
)
2253+
2254+
const consumed = (async () => {
2255+
for await (const _chunk of generator) {
2256+
// ignore chunks
2257+
}
2258+
})()
21772259

2178-
// Wait until the request is in flight and the internal signal is captured
2179-
await vi.waitFor(() => {
2180-
expect(internalSignal).toBeDefined()
2181-
})
2182-
expect(internalSignal?.aborted).toBe(false)
2260+
// Wait until the request is in flight and the internal signal is captured
2261+
await vi.waitFor(() => {
2262+
expect(internalSignal).toBeDefined()
2263+
})
2264+
expect(internalSignal?.aborted).toBe(false)
21832265

2184-
// Abort the external signal mid-flight; the stream must reject with an AbortError
2185-
controller.abort()
2266+
// Abort the external signal mid-flight; the stream must reject with an AbortError
2267+
controller.abort()
21862268

2187-
await expect(consumed).rejects.toMatchObject({ name: "AbortError" })
2269+
await expect(consumed).rejects.toMatchObject({ name: "AbortError" })
2270+
2271+
// Verify the finally block removed the exact listener it registered (error path cleanup)
2272+
const registeredListener = addSpy.mock.calls.find(([type]) => type === "abort")?.[1]
2273+
expect(registeredListener).toBeDefined()
2274+
expect(removeSpy).toHaveBeenCalledWith("abort", registeredListener)
2275+
} finally {
2276+
addSpy.mockRestore()
2277+
removeSpy.mockRestore()
2278+
}
21882279
})
21892280

21902281
it("createMessage should detach the external abort listener when the request completes", async () => {
@@ -2210,8 +2301,12 @@ describe("AwsBedrockHandler", () => {
22102301
})
22112302
handler["client"].send = mockSend
22122303

2213-
// First request completes normally with its own external signal
2304+
// First request completes normally with its own external signal.
2305+
// Spy on both add and remove so we can assert the exact same function
2306+
// reference was registered and then detached — expect.any(Function) would
2307+
// pass even if a different listener were removed, leaving the real one attached.
22142308
const firstController = new AbortController()
2309+
const firstAddSpy = vi.spyOn(firstController.signal, "addEventListener")
22152310
const firstRemoveSpy = vi.spyOn(firstController.signal, "removeEventListener")
22162311
const firstGenerator = handler.createMessage(
22172312
"You are a helpful assistant",
@@ -2229,8 +2324,13 @@ describe("AwsBedrockHandler", () => {
22292324
})()
22302325
expect(firstText).toBe("hello")
22312326

2232-
// The bridge listener must be detached as soon as the request completes
2233-
expect(firstRemoveSpy).toHaveBeenCalledWith("abort", expect.any(Function))
2327+
// The bridge listener must be detached as soon as the request completes.
2328+
// Extract the exact function reference that was registered so we can assert
2329+
// the same reference (not just any function) was passed to removeEventListener.
2330+
const abortAddCall = firstAddSpy.mock.calls.find(([type]) => type === "abort")
2331+
const registeredAbortListener = abortAddCall?.[1]
2332+
expect(registeredAbortListener).toBeDefined()
2333+
expect(firstRemoveSpy).toHaveBeenCalledWith("abort", registeredAbortListener)
22342334

22352335
// Second request starts with a DIFFERENT external signal
22362336
const secondController = new AbortController()
@@ -2255,6 +2355,7 @@ describe("AwsBedrockHandler", () => {
22552355
expect(secondSendSignal).toBeDefined()
22562356
expect(secondSendSignal?.aborted).toBe(false)
22572357

2358+
firstAddSpy.mockRestore()
22582359
firstRemoveSpy.mockRestore()
22592360
})
22602361

src/api/providers/bedrock.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,12 +561,17 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
561561
// Create a request-local AbortController with 10 minute timeout. Keeping it
562562
// request-local (and detaching the bridge listener in the finally block) means
563563
// a completed request can never leave a stale listener on the caller's signal.
564+
// A manual setTimeout (rather than AbortSignal.timeout()) is required here
565+
// because clearTimeout in the finally block needs a cancelable handle —
566+
// AbortSignal.timeout() self-manages its timer and cannot be cleared.
564567
const requestController = new AbortController()
565568
let timeoutId: NodeJS.Timeout | undefined
566569

567-
// Bridge external abort signal to the request controller using the Bedrock pattern:
568-
// - pre-aborted guard: check if already aborted before adding listener
569-
// - { once: true }: remove listener after first abort to avoid leaks
570+
// Bridge external abort signal to the request controller using the standard
571+
// abort bridge pattern:
572+
// - pre-aborted guard: a listener on an already-aborted signal may never fire,
573+
// so abort the local controller directly in that case
574+
// - { once: true }: the listener auto-removes on first abort event
570575
let abortListener: (() => void) | undefined
571576
const externalAbortSignal = metadata?.abortSignal
572577
if (externalAbortSignal) {
@@ -1612,6 +1617,7 @@ Please check:
16121617

16131618
// Check each error type's patterns in order of specificity (most specific first)
16141619
const errorTypeOrder = [
1620+
"ABORT", // Classify cancellations (user abort or request timeout) before any other pattern
16151621
"SERVICE_QUOTA_EXCEEDED", // Most specific - check before THROTTLING
16161622
"MODEL_NOT_READY",
16171623
"TOO_MANY_TOKENS",

0 commit comments

Comments
 (0)