Skip to content

Commit f2d84b9

Browse files
fix(multipart): keep abort wired after resolve so a mid-upload disconnect tears down the stream
readMultipart resolves on the file-part header and hands the caller an un-drained stream, but settle() ran cleanup() and detached the abort listener on that path too. A client disconnect mid-upload then destroyed nothing — busboy never saw EOF, the file stream stalled, and the route's `for await` held a request slot until maxDuration (300s). Re-arm an abort handler scoped to the file stream on resolve, detached when the stream closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 457c3f8 commit f2d84b9

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

apps/sim/lib/core/utils/multipart.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,40 @@ describe('readMultipart', () => {
164164
readMultipart(request, { maxFileBytes: 1024, signal: controller.signal })
165165
).rejects.toBeTruthy()
166166
})
167+
168+
it('destroys the file stream when the signal aborts mid-upload (after resolve)', async () => {
169+
const controller = new AbortController()
170+
// A body that delivers the file-part header but never closes, so the file stream stays open
171+
// after readMultipart resolves — mimicking a client still uploading.
172+
let enqueue!: (b: Buffer) => void
173+
const body = new ReadableStream<Uint8Array>({
174+
start(c) {
175+
enqueue = (b) => c.enqueue(new Uint8Array(b))
176+
},
177+
})
178+
const head = Buffer.concat([
179+
Buffer.from(
180+
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="workspaceId"\r\n\r\nws-1\r\n`
181+
),
182+
Buffer.from(
183+
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\nContent-Type: text/csv\r\n\r\n`
184+
),
185+
Buffer.from('name,age\n'),
186+
])
187+
const request = {
188+
headers: new Headers({ 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }),
189+
body,
190+
}
191+
enqueue(head)
192+
193+
const parsed = await readMultipart(request, {
194+
maxFileBytes: 1024,
195+
requiredFieldsBeforeFile: ['workspaceId'],
196+
signal: controller.signal,
197+
})
198+
expect(parsed.file).toBeTruthy()
199+
200+
controller.abort()
201+
await expect(readStream(parsed.file!.stream)).rejects.toBeTruthy()
202+
})
167203
})

apps/sim/lib/core/utils/multipart.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,28 @@ export function readMultipart(
203203
)
204204
})
205205

206-
settle(() =>
206+
settle(() => {
207+
// settle() detached the pre-file abort handler. Re-arm one scoped to the file stream so a
208+
// client disconnect mid-upload tears it down — otherwise the caller's consume loop hangs
209+
// until maxDuration. Detach when the stream closes so it can't fire afterward.
210+
if (signal) {
211+
const onStreamAbort = () => {
212+
const reason = signal.reason instanceof Error ? signal.reason : new Error('Aborted')
213+
stream.destroy(reason)
214+
nodeStream.destroy(reason)
215+
bb.destroy()
216+
}
217+
if (signal.aborted) onStreamAbort()
218+
else {
219+
signal.addEventListener('abort', onStreamAbort, { once: true })
220+
stream.once('close', () => signal.removeEventListener('abort', onStreamAbort))
221+
}
222+
}
207223
resolve({
208224
fields,
209225
file: { fieldName: name, filename: info.filename, mimeType: info.mimeType, stream },
210226
})
211-
)
227+
})
212228
})
213229

214230
bb.on('error', (err) => {

0 commit comments

Comments
 (0)