You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ai-harness: view.dispose() doesn't cancel snapshot, transcript or describe requests #1960
With @tanstack/ai-harness 0.1.1, view.dispose() aborts only the events stream. Requests the view starts on its own (the snapshot it reloads after a turn ends, and transcript() and describe()) keep running after dispose(), and the view can't cancel them.
We see this in tests that open a view against a real Fastify server, ask one question, dispose the view, then close the server. In about half the runs, the snapshot refresh that follows the turn's end is still in flight when app.close() starts. Its keep-alive connection survives the close, and app.close() waits until the keep-alive timeout runs out, so the test times out. The last request the server got before the hang is always GET .../snapshot.
Expected
dispose() cancels every request the view started. A disposed view leaves no request open.
Why
SessionViewSource passes a signal only to events. snapshot, transcript and describe take no options (src/view/index.ts, around line 195).
refreshSnapshot, reload and ready call source.snapshot() / source.transcript() without a signal (around lines 914, 987 and 1184), and refreshSnapshot runs after events that change the snapshot (line 1101).
dispose() sets disposed and calls reader.abort() (line 1346). The disposed checks drop the results of the other requests, but the requests themselves keep going.
In the client, read() (src/client.ts, line 691) has no signal to pass on either.
Here's a minimal, standalone repro against the published packages (@tanstack/ai-harness 0.1.1, @tanstack/ai 1.0.1, @tanstack/ai-persistence 1.0.1). It runs a real host behind createHarnessHandler on node:http, a real createHarnessClient, and createSessionView. The model is fakeText from @tanstack/ai/testing, so it needs no API key.
The server holds GET snapshot, GET transcript and GET describe for 1 s, so each one is still in flight when the view is disposed. Then it prints whether the client aborted each request.
Save the two files below, then run npm install && npm start. On Node 26.9 it prints this every time (3 of 3 runs):
Case 1: dispose() while view.ready is loading
16 ms -> GET describe
16 ms view.dispose() <- first snapshot/transcript/describe request is in flight
18 ms -> GET snapshot (sent after dispose())
1033 ms -> GET transcript (sent after dispose())
Case 1 result:
GET describe completed after dispose()
GET snapshot completed after dispose()
GET transcript completed after dispose()
BUG: 3 request(s) not cancelled by dispose()
Case 2: dispose() during the snapshot refresh after a turn ends
...
5527 ms -> GET events
5529 ms -> POST control
5551 ms -> GET snapshot
5551 ms view.dispose() <- GET snapshot (refresh after the turn) is in flight
6555 ms -> GET snapshot (sent after dispose())
Case 2 result:
GET events aborted by the client
POST control completed
GET snapshot completed after dispose()
GET snapshot completed after dispose()
BUG: 2 request(s) not cancelled by dispose()
GET events is aborted as expected. The other requests aren't, and the view also sends new requests after dispose():
In case 1, ready goes on to source.snapshot() and then source.transcript() after dispose(). It checks disposed only after all three reads (src/view/index.ts, around line 1197).
In case 2, the turn's events request another refresh while the first is running, so the coalesce loop (line 481) runs source.snapshot() again after dispose(). The loop never checks disposed.
// Repro for TanStack/ai#1960: view.dispose() doesn't cancel the snapshot,// transcript or describe requests a session view starts.//// A real harness host behind createHarnessHandler on node:http, a real// createHarnessClient, and a real createSessionView. The model is the fake// adapter from @tanstack/ai/testing, so no API key is needed.//// The server holds GET snapshot / transcript / describe for 1 s, so a request// is still in flight when the view is disposed. For each request it prints// whether the client aborted it (what dispose() should do) or it ran to the// end after dispose().importhttpfrom'node:http'import{Readable}from'node:stream'import{createHarnessHost,defineHarness}from'@tanstack/ai-harness'import{createHarnessHandler}from'@tanstack/ai-harness/server'import{createHarnessClient}from'@tanstack/ai-harness/client'import{createSessionView}from'@tanstack/ai-harness/view'import{memoryPersistence}from'@tanstack/ai-persistence'import{fakeText}from'@tanstack/ai/testing'constHOLD_MS=1000constHELD=newSet(['snapshot','transcript','describe'])constadapter=fakeText({model: 'fake'})constassistant=defineHarness({name: 'repro/assistant', adapter })consthost=createHarnessHost({persistence: memoryPersistence()})consthandler=createHarnessHandler({ host,harness: assistant,openAccess: true,media: {secret: 'repro-only-media-secret-0123456789abcdef'},})constt0=Date.now()constms=()=>`${String(Date.now()-t0).padStart(5)} ms`letdisposedAt=nullletonHeldRequest=null// called when a held request arrivesconstlog=[]constserver=http.createServer(async(req,res)=>{consturl=newURL(req.url,'http://localhost')constpath=url.pathname.split('/').pop()constentry={request: `${req.method}${path}`,sentAfterDispose: disposedAt!==null,outcome: 'open',}log.push(entry)res.on('close',()=>{entry.outcome=res.writableFinished
? `completed${disposedAt!==null ? ' after dispose()' : ''}`
: 'aborted by the client'})console.log(`${ms()} -> ${entry.request}${entry.sentAfterDispose ? ' (sent after dispose())' : ''}`)if(req.method==='GET'&&HELD.has(path)){onHeldRequest?.(entry)awaitnewPromise((resolve)=>setTimeout(resolve,HOLD_MS))if(res.destroyed)return}constbody=req.method==='GET'||req.method==='HEAD' ? undefined : Readable.toWeb(req)constabort=newAbortController()res.on('close',()=>abort.abort())constresponse=awaithandler(newRequest(url,{method: req.method,headers: req.headers, body,duplex: 'half',signal: abort.signal}),)res.writeHead(response.status,Object.fromEntries(response.headers))if(response.body)Readable.fromWeb(response.body).on('error',()=>{}).pipe(res)elseres.end()})awaitnewPromise((resolve)=>server.listen(0,'127.0.0.1',resolve))constbaseUrl=`http://127.0.0.1:${server.address().port}/api/harness`functionopenView(threadId){returncreateSessionView(createHarnessClient({url: baseUrl, threadId }))}functiondispose(view,why){disposedAt=Date.now()console.log(`${ms()} view.dispose() <- ${why}`)view.dispose()}asyncfunctionreport(title){// Wait until the view stops sending and every request has finished or been aborted.letquietSince=Date.now()letseen=log.lengthwhile(Date.now()-quietSince<HOLD_MS+500){awaitnewPromise((resolve)=>setTimeout(resolve,50))if(log.length!==seen||log.some((e)=>e.outcome==='open')){seen=log.lengthquietSince=Date.now()}}console.log(`\n${title}`)for(constentryoflog)console.log(` ${entry.request.padEnd(16)}${entry.outcome}`)constleaked=log.filter((e)=>HELD.has(e.request.split(' ')[1])&&e.outcome!=='aborted by the client')console.log(leaked.length>0 ? ` BUG: ${leaked.length} request(s) not cancelled by dispose()\n` : ' OK: dispose() cancelled everything\n')log.length=0disposedAt=nullonHeldRequest=nullreturnleaked.length}letleaks=0// Case 1: dispose while the view is still loading (view.ready pending).{console.log('Case 1: dispose() while view.ready is loading')constview=openView('thread-1')view.ready.catch(()=>{})onHeldRequest=()=>{onHeldRequest=nulldispose(view,'first snapshot/transcript/describe request is in flight')}leaks+=awaitreport('Case 1 result:')}// Case 2: dispose while the snapshot refresh after a turn is in flight.{console.log('Case 2: dispose() during the snapshot refresh after a turn ends')adapter.setResponses([{text: 'Hello!'}])constview=openView('thread-2')awaitview.readylog.length=0// keep only what happens from the turn onconsole.log(`${ms()} view.ready resolved; sending a message`)onHeldRequest=(entry)=>{if(entry.request!=='GET snapshot')returnonHeldRequest=nulldispose(view,'GET snapshot (refresh after the turn) is in flight')}constturnEnded=newPromise((resolve)=>view.on('turnEnd',resolve))awaitview.send('Hi')awaitturnEndedleaks+=awaitreport('Case 2 result:')}server.closeAllConnections()server.close()awaithost.close?.()console.log(leaks>0 ? 'Reproduced: dispose() leaves requests running.' : 'Not reproduced.')process.exit(leaks>0 ? 1 : 0)
Activity
github-actions commentedon Nov 1, 2026
Thanks for the report, @381181295! To help us reproduce this, could you add a minimal reproduction?
A runnable example makes fixes dramatically faster — you can fork one of the official examples, or use StackBlitz / CodeSandbox. For TypeScript-only issues, a TS Playground link works too.
Automated triage — a maintainer will follow up.
381181295 commentedon Nov 1, 2026
Here's a minimal, standalone repro against the published packages (
@tanstack/ai-harness0.1.1,@tanstack/ai1.0.1,@tanstack/ai-persistence1.0.1). It runs a real host behindcreateHarnessHandleronnode:http, a realcreateHarnessClient, andcreateSessionView. The model isfakeTextfrom@tanstack/ai/testing, so it needs no API key.The server holds
GET snapshot,GET transcriptandGET describefor 1 s, so each one is still in flight when the view is disposed. Then it prints whether the client aborted each request.Save the two files below, then run
npm install && npm start. On Node 26.9 it prints this every time (3 of 3 runs):GET eventsis aborted as expected. The other requests aren't, and the view also sends new requests afterdispose():readygoes on tosource.snapshot()and thensource.transcript()afterdispose(). It checksdisposedonly after all three reads (src/view/index.ts, around line 1197).coalesceloop (line 481) runssource.snapshot()again afterdispose(). The loop never checksdisposed.package.json{ "name": "tanstack-ai-1960-repro", "private": true, "type": "module", "description": "Repro for TanStack/ai#1960: view.dispose() doesn't cancel snapshot, transcript or describe requests", "scripts": { "start": "node repro.mjs" }, "dependencies": { "@tanstack/ai": "1.0.1", "@tanstack/ai-harness": "0.1.1", "@tanstack/ai-persistence": "1.0.1" } }repro.mjs