Skip to content

Commit 44b824a

Browse files
authored
fix(mothership): bound every handed tool call by the deadline its frame names (#9090)
* fix(mothership): bound every handed tool call by the deadline its frame names * fix(mothership): start nothing for a tool call once its deadline has passed * test(mothership): type the client deadline waiter mock * fix(mothership): report a past-deadline client call as not run only when nothing claimed it * fix(media): submit no fal request for a caller that already gave up * fix(media): flag a refused fal cancellation and stop an aborted inline download
1 parent e79bd40 commit 44b824a

32 files changed

Lines changed: 32541 additions & 66 deletions

‎apps/sim/lib/media/falai-audio.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface GenerateFalAudioParams {
2626
instrumental?: boolean
2727
/** When set, clones the voice from this reference sample (data URI) via a zero-shot clone model. */
2828
voiceSampleDataUri?: string
29+
/** Ends the generation: the queued request is cancelled and every wait stops. */
30+
signal?: AbortSignal
2931
}
3032

3133
export interface GeneratedAudio {
@@ -94,11 +96,16 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise<
9496
ref_audio_url: params.voiceSampleDataUri,
9597
model_type: 'F5-TTS',
9698
}
97-
const { requestId, data } = await runFalQueue(model, input, apiKey)
99+
const { requestId, data } = await runFalQueue(model, input, apiKey, params.signal)
98100
const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output'])
99101
if (!url) throw new Error('No audio URL in Fal.ai clone response')
100-
const { buffer, contentType } = await downloadFalMedia(url)
101-
const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId })
102+
const { buffer, contentType } = await downloadFalMedia(url, params.signal)
103+
const cost = await getFalAICostMetadata({
104+
apiKey,
105+
endpointId: model,
106+
requestId,
107+
signal: params.signal,
108+
})
102109
return {
103110
buffer,
104111
contentType: contentType.startsWith('audio/') ? contentType : 'audio/mpeg',
@@ -113,12 +120,17 @@ export async function generateFalAudio(params: GenerateFalAudioParams): Promise<
113120
const input = buildInput(type, params, model)
114121

115122
// For fal audio models the model ID is the queue endpoint.
116-
const { requestId, data } = await runFalQueue(model, input, apiKey)
123+
const { requestId, data } = await runFalQueue(model, input, apiKey, params.signal)
117124
const url = extractFalMediaUrl(data, ['audio', 'audio_url', 'audio_file', 'output'])
118125
if (!url) throw new Error('No audio URL in Fal.ai response')
119126

120-
const { buffer, contentType } = await downloadFalMedia(url)
121-
const cost = await getFalAICostMetadata({ apiKey, endpointId: model, requestId })
127+
const { buffer, contentType } = await downloadFalMedia(url, params.signal)
128+
const cost = await getFalAICostMetadata({
129+
apiKey,
130+
endpointId: model,
131+
requestId,
132+
signal: params.signal,
133+
})
122134

123135
return {
124136
buffer,

‎apps/sim/lib/media/falai-video.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export interface GenerateFalVideoParams {
110110
promptOptimizer?: boolean
111111
/** Optional start-frame image as a data URI; when set, routes to the model's image-to-video endpoint. */
112112
imageDataUri?: string
113+
/** Ends the generation: the queued request is cancelled and every wait stops. */
114+
signal?: AbortSignal
113115
}
114116

115117
export interface GeneratedVideo {
@@ -171,13 +173,18 @@ export async function generateFalVideo(params: GenerateFalVideoParams): Promise<
171173
input.prompt_optimizer = params.promptOptimizer
172174
}
173175

174-
const { requestId, data } = await runFalQueue(endpoint, input, apiKey)
176+
const { requestId, data } = await runFalQueue(endpoint, input, apiKey, params.signal)
175177
const url = extractFalMediaUrl(data, ['video', 'output'])
176178
if (!url) throw new Error('No video URL in Fal.ai response')
177179

178180
const videoNode = isRecordLike(data.video) ? data.video : undefined
179-
const { buffer, contentType } = await downloadFalMedia(url)
180-
const cost = await getFalAICostMetadata({ apiKey, endpointId: endpoint, requestId })
181+
const { buffer, contentType } = await downloadFalMedia(url, params.signal)
182+
const cost = await getFalAICostMetadata({
183+
apiKey,
184+
endpointId: endpoint,
185+
requestId,
186+
signal: params.signal,
187+
})
181188

182189
return {
183190
buffer,

‎apps/sim/lib/media/falai.test.ts‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { jsonResponse } from '@sim/testing/helpers/http'
2+
import {
3+
inputValidationMock,
4+
inputValidationMockFns,
5+
} from '@sim/testing/mocks/input-validation.mock'
6+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
10+
11+
import { downloadFalMedia, runFalQueue } from '@/lib/media/falai'
12+
13+
const CANCEL_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/cancel'
14+
const STATUS_URL = 'https://queue.fal.run/fal-ai/model/requests/req-1/status'
15+
16+
interface FalCall {
17+
url: string
18+
method: string
19+
headers: Record<string, string>
20+
}
21+
22+
/**
23+
* A fal queue whose request is accepted and then never finishes: every status poll reports
24+
* IN_PROGRESS, or (`statusHangs`) never answers at all until its signal aborts.
25+
*/
26+
function stubStuckFalQueue(options: { statusHangs?: boolean; cancelStatus?: number } = {}) {
27+
const calls: FalCall[] = []
28+
vi.stubGlobal(
29+
'fetch',
30+
vi.fn(async (url: string, init: RequestInit = {}) => {
31+
calls.push({
32+
url,
33+
method: init.method ?? 'GET',
34+
headers: (init.headers ?? {}) as Record<string, string>,
35+
})
36+
if (init.method === 'POST')
37+
return jsonResponse({
38+
request_id: 'req-1',
39+
status_url: STATUS_URL,
40+
cancel_url: CANCEL_URL,
41+
})
42+
if (init.method === 'PUT')
43+
return jsonResponse({ status: 'CANCELLATION_REQUESTED' }, options.cancelStatus ?? 202)
44+
if (options.statusHangs)
45+
return new Promise<Response>((_resolve, reject) => {
46+
init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
47+
})
48+
return jsonResponse({ status: 'IN_PROGRESS' })
49+
})
50+
)
51+
const statusPolls = () => calls.filter((call) => call.url === STATUS_URL).length
52+
const cancels = () => calls.filter((call) => call.method === 'PUT')
53+
return { calls, statusPolls, cancels }
54+
}
55+
56+
describe('runFalQueue on a request that never finishes', () => {
57+
beforeEach(() => {
58+
vi.useFakeTimers()
59+
})
60+
afterEach(() => {
61+
vi.useRealTimers()
62+
})
63+
64+
it('stops polling and cancels the request once its caller gives up', async () => {
65+
const fal = stubStuckFalQueue()
66+
const caller = new AbortController()
67+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal)
68+
const outcome = generation.then(
69+
() => 'resolved',
70+
() => 'rejected'
71+
)
72+
73+
await vi.advanceTimersByTimeAsync(9_500)
74+
expect(fal.statusPolls()).toBe(3)
75+
caller.abort(new Error('watchdog'))
76+
await vi.advanceTimersByTimeAsync(0)
77+
78+
expect(await outcome).toBe('rejected')
79+
expect(fal.cancels()).toEqual([expect.objectContaining({ url: CANCEL_URL })])
80+
await vi.advanceTimersByTimeAsync(60_000)
81+
expect(fal.statusPolls()).toBe(3)
82+
})
83+
84+
it('ends a poll that never answers when its caller gives up', async () => {
85+
const fal = stubStuckFalQueue({ statusHangs: true })
86+
const caller = new AbortController()
87+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal)
88+
const outcome = generation.then(
89+
() => 'resolved',
90+
() => 'rejected'
91+
)
92+
93+
await vi.advanceTimersByTimeAsync(3_000)
94+
expect(fal.statusPolls()).toBe(1)
95+
caller.abort(new Error('watchdog'))
96+
await vi.advanceTimersByTimeAsync(0)
97+
98+
expect(await outcome).toBe('rejected')
99+
expect(fal.cancels()).toHaveLength(1)
100+
})
101+
102+
it('fails and cancels after its own ceiling even when nothing aborts it', async () => {
103+
const fal = stubStuckFalQueue()
104+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key')
105+
const outcome = generation.then(
106+
() => 'resolved',
107+
(error: unknown) => String(error)
108+
)
109+
110+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
111+
112+
expect(await outcome).toContain('Fal.ai generation timed out')
113+
expect(fal.cancels()).toHaveLength(1)
114+
})
115+
116+
it('warns when fal refuses to cancel a request', async () => {
117+
const fal = stubStuckFalQueue({ cancelStatus: 500 })
118+
const caller = new AbortController()
119+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch(
120+
() => undefined
121+
)
122+
await vi.advanceTimersByTimeAsync(3_500)
123+
caller.abort()
124+
await generation
125+
126+
expect(fal.cancels()).toHaveLength(1)
127+
expect(getMockLogger('FalMediaClient').warn).toHaveBeenCalledWith(
128+
'Fal.ai request cancellation failed',
129+
expect.objectContaining({ requestId: 'req-1' })
130+
)
131+
})
132+
133+
it('submits nothing for a caller that already gave up', async () => {
134+
const fal = stubStuckFalQueue()
135+
136+
await expect(
137+
runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', AbortSignal.abort())
138+
).rejects.toThrow()
139+
expect(fal.calls).toEqual([])
140+
})
141+
142+
it('cancels a request its caller gave up on while submitting it', async () => {
143+
const calls: string[] = []
144+
let accept: (response: Response) => void = () => {}
145+
vi.stubGlobal(
146+
'fetch',
147+
vi.fn((url: string, init: RequestInit = {}) => {
148+
calls.push(`${init.method ?? 'GET'} ${url}`)
149+
if (init.method === 'POST')
150+
return new Promise<Response>((resolve, reject) => {
151+
accept = resolve
152+
init.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
153+
once: true,
154+
})
155+
})
156+
return Promise.resolve(jsonResponse({ status: 'CANCELLATION_REQUESTED' }, 202))
157+
})
158+
)
159+
const caller = new AbortController()
160+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch(
161+
() => 'rejected'
162+
)
163+
164+
await vi.advanceTimersByTimeAsync(0)
165+
caller.abort(new Error('watchdog'))
166+
accept(jsonResponse({ request_id: 'req-1', cancel_url: CANCEL_URL }))
167+
168+
expect(await generation).toBe('rejected')
169+
expect(calls).toContain(`PUT ${CANCEL_URL}`)
170+
})
171+
172+
it('asks fal never to start the request past that ceiling', async () => {
173+
const fal = stubStuckFalQueue()
174+
const caller = new AbortController()
175+
const generation = runFalQueue('fal-ai/model', { prompt: 'p' }, 'key', caller.signal).catch(
176+
() => undefined
177+
)
178+
await vi.advanceTimersByTimeAsync(0)
179+
caller.abort()
180+
await generation
181+
182+
expect(fal.calls[0]).toMatchObject({
183+
method: 'POST',
184+
headers: expect.objectContaining({ 'X-Fal-Request-Timeout': '1800' }),
185+
})
186+
})
187+
})
188+
189+
describe('downloadFalMedia', () => {
190+
it('returns nothing for a caller that already gave up, even inline media', async () => {
191+
await expect(
192+
downloadFalMedia('data:audio/mpeg;base64,AAAA', AbortSignal.abort())
193+
).rejects.toThrow()
194+
})
195+
196+
it('ends a download still resolving its host once its caller gives up', async () => {
197+
inputValidationMockFns.mockValidateUrlWithDNS.mockImplementationOnce(
198+
(_url: string, _name: string, _profile: string, options?: { signal?: AbortSignal }) =>
199+
new Promise((_resolve, reject) => {
200+
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
201+
once: true,
202+
})
203+
})
204+
)
205+
const caller = new AbortController()
206+
const download = downloadFalMedia('https://media.example/out.mp3', caller.signal)
207+
208+
caller.abort(new Error('watchdog'))
209+
210+
await expect(download).rejects.toThrow('watchdog')
211+
})
212+
})

0 commit comments

Comments
 (0)