Skip to content

ai-harness: view.dispose() doesn't cancel snapshot, transcript or describe requests #1960

Open

Description

@381181295

What happens

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.

Checked on main at 3ce3085.

Workaround

In our test server's close, we call server.closeAllConnections() right after starting app.close().

Activity

  1. github-actions commented on Nov 1, 2026

    @github-actions
    Contributor

    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.

  2. 381181295 commented on Nov 1, 2026

    @381181295
    Author

    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.
    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
    // 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().
    import http from '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'
    
    const HOLD_MS = 1000
    const HELD = new Set(['snapshot', 'transcript', 'describe'])
    
    const adapter = fakeText({ model: 'fake' })
    const assistant = defineHarness({ name: 'repro/assistant', adapter })
    const host = createHarnessHost({ persistence: memoryPersistence() })
    const handler = createHarnessHandler({ host, harness: assistant, openAccess: true,
      media: { secret: 'repro-only-media-secret-0123456789abcdef' },
    })
    
    const t0 = Date.now()
    const ms = () => `${String(Date.now() - t0).padStart(5)} ms`
    let disposedAt = null
    let onHeldRequest = null // called when a held request arrives
    const log = []
    
    const server = http.createServer(async (req, res) => {
      const url = new URL(req.url, 'http://localhost')
      const path = url.pathname.split('/').pop()
      const entry = {
        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)
        await new Promise((resolve) => setTimeout(resolve, HOLD_MS))
        if (res.destroyed) return
      }
    
      const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : Readable.toWeb(req)
      const abort = new AbortController()
      res.on('close', () => abort.abort())
      const response = await handler(
        new Request(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)
      else res.end()
    })
    await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
    const baseUrl = `http://127.0.0.1:${server.address().port}/api/harness`
    
    function openView(threadId) {
      return createSessionView(createHarnessClient({ url: baseUrl, threadId }))
    }
    
    function dispose(view, why) {
      disposedAt = Date.now()
      console.log(`${ms()}  view.dispose()  <- ${why}`)
      view.dispose()
    }
    
    async function report(title) {
      // Wait until the view stops sending and every request has finished or been aborted.
      let quietSince = Date.now()
      let seen = log.length
      while (Date.now() - quietSince < HOLD_MS + 500) {
        await new Promise((resolve) => setTimeout(resolve, 50))
        if (log.length !== seen || log.some((e) => e.outcome === 'open')) {
          seen = log.length
          quietSince = Date.now()
        }
      }
      console.log(`\n${title}`)
      for (const entry of log) console.log(`  ${entry.request.padEnd(16)} ${entry.outcome}`)
      const leaked = 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 = 0
      disposedAt = null
      onHeldRequest = null
      return leaked.length
    }
    
    let leaks = 0
    
    // Case 1: dispose while the view is still loading (view.ready pending).
    {
      console.log('Case 1: dispose() while view.ready is loading')
      const view = openView('thread-1')
      view.ready.catch(() => {})
      onHeldRequest = () => {
        onHeldRequest = null
        dispose(view, 'first snapshot/transcript/describe request is in flight')
      }
      leaks += await report('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!' }])
      const view = openView('thread-2')
      await view.ready
      log.length = 0 // keep only what happens from the turn on
      console.log(`${ms()}  view.ready resolved; sending a message`)
      onHeldRequest = (entry) => {
        if (entry.request !== 'GET snapshot') return
        onHeldRequest = null
        dispose(view, 'GET snapshot (refresh after the turn) is in flight')
      }
      const turnEnded = new Promise((resolve) => view.on('turnEnd', resolve))
      await view.send('Hi')
      await turnEnded
      leaks += await report('Case 2 result:')
    }
    
    server.closeAllConnections()
    server.close()
    await host.close?.()
    console.log(leaks > 0 ? 'Reproduced: dispose() leaves requests running.' : 'Not reproduced.')
    process.exit(leaks > 0 ? 1 : 0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

needs-reproNeeds a minimal reproduction before it can be worked onwaiting-on: maintainerThe ball is in the maintainers’ court

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions