router icon indicating copy to clipboard operation
router copied to clipboard

Bug Report: Abort Signal Not Working for Streaming Server Functions

Open emorr23 opened this issue 5 months ago • 4 comments

Which project does this relate to?

Start

Describe the bug

Summary

Abort signals are not properly propagated to streaming server functions (response: 'raw') in TanStack Start, causing memory leaks and inability to cancel long-running streams. This affects the documented cancellation behavior for streaming responses.

Environment

  • TanStack Start Version: Latest (as of July 2025)
  • Node.js Version: 18+
  • Operating System: macOS
  • Browser: Chrome/Safari/Firefox (affects all browsers)

Problem Description

When using createServerFn with response: 'raw' to create streaming responses, the abort signal passed from the client is not properly propagated to the server function handler. This results in:

  1. Memory Leaks: Multiple concurrent streams continue running even after new queries are issued
  2. Resource Waste: Server continues processing after client has moved on
  3. Broken Cancellation: The documented abort signal pattern doesn't work for streaming

Root Cause Analysis

The issue is in /packages/start-server-core/src/server-functions-handler.ts at lines 232-238:

// PROBLEMATIC CODE:
request.signal.removeEventListener('abort', abort)

if (isRaw) {
  return response  // <-- Signal disconnected too early for streaming!
}

The Problem: For streaming responses, the abort listener is removed immediately after the response is created, but the ReadableStream continues to run with a signal that's no longer connected to the client's abort signal.

Expected Behavior: The abort listener should remain connected for the lifetime of the streaming response so that client cancellation can properly terminate the stream.

Reproduction Steps

1. Create a Streaming Server Function

import { createServerFn } from "@tanstack/react-start";

export const streamSearchFn = createServerFn({
  method: "GET",
  response: "raw",
})
  .validator((input: { query: string }) => input)
  .handler(async ({ data, signal }) => {
    console.log(`🚀 Server: Starting stream for query: "${data.query}"`);
    
    // This should receive abort events but doesn't
    signal.addEventListener("abort", () => {
      console.log(`🛑 Server: Abort signal received for "${data.query}"`);
    });

    const stream = new ReadableStream({
      async start(controller) {
        let count = 0;
        const intervalId = setInterval(() => {
          // This check never triggers because signal.aborted stays false
          if (signal.aborted) {
            console.log(`🛑 Server: Stream cancelled for "${data.query}"`);
            clearInterval(intervalId);
            controller.close();
            return;
          }

          const result = JSON.stringify({
            id: `result-${count++}`,
            name: `Result ${count} for "${data.query}"`,
            timestamp: new Date().toISOString(),
          });

          controller.enqueue(new TextEncoder().encode(result + "\n"));

          if (count >= 10) {
            clearInterval(intervalId);
            controller.close();
          }
        }, 1000);
      },
    });

    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
      },
    });
  });

2. Client Usage with Abort Signal

function useStreamingSearch(query: string) {
  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Cancel previous stream
    if (abortControllerRef.current) {
      console.log("🔄 Client: Cancelling previous stream");
      abortControllerRef.current.abort(); // This should cancel the server stream
    }

    const abortController = new AbortController();
    abortControllerRef.current = abortController;

    async function fetchStream() {
      const response = await streamSearchFn({
        data: { query },
        signal: abortController.signal, // Passed to server but not propagated
      });

      const reader = response.body?.getReader();
      // ... streaming logic
    }

    fetchStream();
  }, [query]);
}

3. Observed Behavior

Client logs:

🔄 Client: Cancelling previous stream for new query
🚀 Client: Starting stream for query: "new query"

Server logs:

🚀 Server: Starting stream for query: "old query"
🚀 Server: Starting stream for query: "new query"
📤 Server: Sending result 1 for "old query"    // ❌ Should be cancelled!
📤 Server: Sending result 1 for "new query"
📤 Server: Sending result 2 for "old query"    // ❌ Still running!
📤 Server: Sending result 2 for "new query"

Expected server logs:

🚀 Server: Starting stream for query: "old query"
🛑 Server: Abort signal received for "old query"  // ✅ Should happen
🚀 Server: Starting stream for query: "new query"
📤 Server: Sending result 1 for "new query"

Non-Streaming Server Functions Work Correctly

The same abort signal pattern works perfectly for non-streaming server functions:

const regularServerFn = createServerFn()
  .validator((input: { query: string }) => input)
  .handler(async ({ data, signal }) => {
    console.log(`🚀 Server: Starting regular function for query: "${data.query}"`);
    
    // This DOES work for regular functions
    signal.addEventListener("abort", () => {
      console.log(`🛑 Server: Abort signal received for "${data.query}"`);
    });

    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        resolve(`Result for ${data.query}`);
      }, 2000);

      signal.addEventListener("abort", () => {
        clearTimeout(timeout);
        reject(new Error("Aborted"));
      });
    });
  });

Impact

This bug affects:

  • Performance: Memory leaks from uncancelled streams
  • User Experience: Inability to cancel long-running searches
  • Resource Usage: Server continues processing unnecessary work
  • Documentation Accuracy: The documented cancellation pattern doesn't work for streaming

Proposed Fix

In /packages/start-server-core/src/server-functions-handler.ts, change lines 232-238 from:

// CURRENT (BROKEN):
request.signal.removeEventListener('abort', abort)

if (isRaw) {
  return response
}

To:

// PROPOSED FIX:
if (isRaw) {
  // Keep abort listener connected for streaming responses
  return response
}

// Only remove listener for non-streaming responses
request.signal.removeEventListener('abort', abort)

Test Cases

The fix should ensure these test cases pass:

Test 1: Streaming Abort Signal

test('streaming server function receives abort signal', async () => {
  const abortController = new AbortController();
  let abortReceived = false;
  
  const streamFn = createServerFn({ response: 'raw' })
    .handler(async ({ signal }) => {
      signal.addEventListener('abort', () => {
        abortReceived = true;
      });
      
      return new Response(new ReadableStream({
        start(controller) {
          const interval = setInterval(() => {
            if (signal.aborted) {
              clearInterval(interval);
              controller.close();
              return;
            }
            controller.enqueue(new TextEncoder().encode('data\n'));
          }, 100);
        }
      }));
    });

  const responsePromise = streamFn({ signal: abortController.signal });
  
  setTimeout(() => abortController.abort(), 50);
  
  await responsePromise;
  expect(abortReceived).toBe(true);
});

Test 2: Multiple Concurrent Streams

test('cancelling previous stream before starting new one', async () => {
  const streams = [];
  
  for (let i = 0; i < 3; i++) {
    const controller = new AbortController();
    if (streams.length > 0) {
      streams[streams.length - 1].controller.abort();
    }
    
    streams.push({
      controller,
      response: streamFn({ signal: controller.signal })
    });
  }
  
  // Only the last stream should still be active
  // Previous streams should be cancelled
});

References

Additional Notes

  • The existing abort signal tests only cover non-streaming server functions
  • This issue affects both React and Solid Start implementations
  • The problem appears to be framework-level, not user code
  • Current workarounds (like using Node.js request 'close' events) are not portable or reliable

Your Example Website or App

google.com

Steps to Reproduce the Bug or Issue

See above

Expected behavior

See above

Screenshots or Videos

No response

Platform

  • Router / Start Version: [e.g. 1.121.0]
  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Browser Version: [e.g. 91.1]
  • Bundler: [e.g. vite]
  • Bundler Version: [e.g. 7.0.0]

Additional context

No response

emorr23 avatar Jul 14 '25 20:07 emorr23

is this a duplicate of https://github.com/TanStack/router/issues/3490 ?

in any case, adding failing e2e test as a PR would help us!

schiller-manuel avatar Jul 14 '25 20:07 schiller-manuel

is this a duplicate of #3490 ?

No, using GET as proposed does not fix the issue either. This issue identifies the problematic code and suggests a fix. Not sure if it should be added there or used here.

emorr23 avatar Jul 14 '25 20:07 emorr23

Related: https://x.com/unnoqcom/status/1950200669828501658

juliomuhlbauer avatar Jul 29 '25 14:07 juliomuhlbauer

has anyone looked at this in a bit? Running into this error when trying to mount an Effect-ts rpc server to a route

ghardin1314 avatar Nov 20 '25 01:11 ghardin1314

import { createFileRoute } from '@tanstack/react-router'
import { convertToModelMessages, smoothStream, streamText, type UIMessage } from 'ai'

export const Route = createFileRoute('/api/chat')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const { messages } = (await request.json()) as { messages: UIMessage[] }

        console.log('messages!', messages)

        const result = streamText({
          model: google('gemini-2.5-flash-lite'),
          system: 'You are a helpful assistant.',
          messages: convertToModelMessages(messages),
          abortSignal: request.signal,
          onAbort: () => {
            console.log('abort')
          },
        })

        return result.toUIMessageStreamResponse({ sendReasoning: true, sendFinish: true })
      },
    },
  },
})

When request.signal is put in abortSignal, it is aborted right away. onAbort runs instantly

cpakken avatar Dec 15 '25 12:12 cpakken

This should solve it: https://github.com/h3js/srvx/pull/153

For now I have added the following to my package.json:

  "resolutions": {
    "srvx": "0.9.8"
  }

This seems to resolve it for me.

izakfilmalter avatar Dec 17 '25 23:12 izakfilmalter

@izakfilmalter i bumped in https://github.com/TanStack/router/releases/tag/v1.141.6

however does this affect the original issue of streaming?

schiller-manuel avatar Dec 17 '25 23:12 schiller-manuel

@schiller-manuel I was running into this exact error with Effect Http / Rpc. Pinning srvx to 0.9.8 resolved it for me. I know that Rpc does streaming under the hood.

izakfilmalter avatar Dec 18 '25 01:12 izakfilmalter

@emorr23 is this resolved in the latest version then?

schiller-manuel avatar Dec 18 '25 07:12 schiller-manuel