Skip to content

πŸ› Bug Report β€” Runtime APIs: client disconnect never calls a response stream's cancel() with typescript_implemented_streamsΒ #8038

Closed

Description

@james-elicx

Description

With typescript_implemented_streams, when the client disconnects while a Worker is streaming a response body from a JavaScript ReadableStream, the source's cancel() is never called. Pulling stops, but the cancel hook never runs, even several seconds later. With the C++ streams implementation, cancel() runs as soon as the client goes away.

Streaming handlers rely on cancel() for cleanup when the client leaves, for example to clear an interval feeding server-sent events, abort an upstream fetch(), or close a database cursor. Under the flag that cleanup silently never runs. We found it while testing vinext (Next.js on Workers). It also reproduced in a deployed Worker with the flag, where requests may land on different isolates, so that check is less conclusive.

Reproduction

worker.js:

const cancelled = new Set();

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const id = url.searchParams.get("id");
    if (url.pathname === "/stream") {
      return new Response(
        new ReadableStream({
          async pull(controller) {
            await new Promise((resolve) => setTimeout(resolve, 20));
            controller.enqueue(new TextEncoder().encode("tick\n"));
          },
          cancel() {
            cancelled.add(id);
          },
        }),
      );
    }
    return Response.json({ cancelled: cancelled.has(id) });
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [(name = "main", worker = .worker)],
  sockets = [(name = "http", address = "127.0.0.1:4396", http = (), service = "main")],
  # typescript_implemented_streams only starts with this autogate.
  autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
);

const worker :Workerd.Worker = (
  modules = [(name = "worker", esModule = embed "worker.js")],
  compatibilityDate = "2026-02-12",
  compatibilityFlags = ["typescript_implemented_streams"],
);
workerd serve --experimental config.capnp &
# Read the stream for a second, then disconnect.
curl -s -m 1 "http://127.0.0.1:4396/stream?id=a" > /dev/null
sleep 1
curl -s "http://127.0.0.1:4396/status?id=a"

With the flag (workerd 2026-10-27, @cloudflare/workerd-darwin-arm64@1.20261027.1), 3 out of 3 runs:

{"cancelled":false}

With compatibilityFlags = [] (C++ streams), 3 out of 3 runs:

{"cancelled":true}

The result is the same with Node's fetch() plus an AbortController as the client, and with a longer wait before checking the status (5 s).

Expected behavior

When the client disconnects, the response body is cancelled and the underlying source's cancel() runs, as it does with the C++ implementation.

Activity

  1. james-elicx commented on Oct 27, 2026

    @james-elicx
    MemberAuthor

    The same bug reproduces without an HTTP client, through a service binding, so it fits in a workerd test. The caller cancels the response body, and the upstream worker's source never sees cancel(). It depends only on the responding worker: with the flag on the upstream alone it fails, and with it on the caller alone it passes.

    worker.js:

    export default {
      async test(ctrl, env) {
        const response = await env.UPSTREAM.fetch("http://upstream/stream");
        await response.body.cancel("done reading");
        await new Promise((resolve) => setTimeout(resolve, 100));
        const status = await (await env.UPSTREAM.fetch("http://upstream/status")).text();
        console.log(`upstream source cancelled: ${status}`);
        if (status !== "true") throw new Error("cancel() never reached the upstream source");
      },
    };

    upstream.js:

    let cancelled = false;
    
    export default {
      async fetch(request) {
        if (new URL(request.url).pathname === "/stream") {
          return new Response(
            new ReadableStream({
              async pull(controller) {
                await new Promise((resolve) => setTimeout(resolve, 5));
                controller.enqueue(new Uint8Array(1024));
              },
              cancel() {
                cancelled = true;
              },
            }),
          );
        }
        return new Response(String(cancelled));
      },
    };

    config.capnp:

    using Workerd = import "/workerd/workerd.capnp";
    
    const config :Workerd.Config = (
      services = [(name = "main", worker = .main), (name = "upstream", worker = .upstream)],
      # typescript_implemented_streams only starts with this autogate.
      autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
    );
    
    const main :Workerd.Worker = (
      modules = [(name = "worker", esModule = embed "worker.js")],
      compatibilityDate = "2026-02-12",
      compatibilityFlags = ["typescript_implemented_streams"],
      bindings = [(name = "UPSTREAM", service = "upstream")],
    );
    
    const upstream :Workerd.Worker = (
      modules = [(name = "upstream", esModule = embed "upstream.js")],
      compatibilityDate = "2026-02-12",
      compatibilityFlags = ["typescript_implemented_streams"],
    );

    With the flag: upstream source cancelled: false / [ FAIL ]. With compatibilityFlags = []: upstream source cancelled: true / [ PASS ] (workerd 1.20261027.1). The same happens when the caller breaks out of for await (const chunk of response.body) or aborts a pipeTo().

  2. vicb commented on Oct 28, 2026

    @vicb
    Contributor

    @james-elicx was faster than me :)

    I have a repro at https://github.com/vicb/workerd-TSStream-disconnect if it helps (see the README for info and steps to reproduce)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions