Skip to content

[engine.io-client] polling transport: the long-polling request stays open after the socket is closed #5628

Open

Description

@mark1russell7

Describe the bug

When the polling transport is closed, doClose() only sends a close packet (one more POST request). It does not abort the requests that are still pending, like the long-polling GET request (BaseXHR keeps it in this.pollXhr, but does not use it).

With an Engine.IO server, this is not visible: when the server receives the close packet, it answers the pending GET request. But when the GET request does not get an answer (a proxy that holds it, or a request on a connection that is no longer usable, for example after a network change), the request stays open after the close event. It holds a connection (in a browser, one of the few connections per origin), and in Node.js the process does not exit.

To Reproduce

Engine.IO client version: 6.6.8 (also present on main)

The server is a small HTTP server that answers the handshake and the POST requests, but never answers the long-polling GET request. The client closes the socket 100 ms after it is open, while the long-polling request is pending. The repro uses the Fetch transport (see the note below about xmlhttprequest-ssl).

package.json:

{
  "private": true,
  "type": "module",
  "dependencies": {
    "engine.io-client": "6.6.8"
  },
  "devDependencies": {
    "@types/node": "24.19.2",
    "typescript": "7.0.2"
  }
}

repro.ts:

// npm install, then: node repro.ts (Node.js 22.18 or later runs TypeScript directly)
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { Fetch, Socket } from "engine.io-client";

let pending = false;

// A server (or a proxy) that answers the POST requests, but never answers the long-polling GET request.
const httpServer = createServer((req, res) => {
  if (req.method === "POST") {
    req.resume();
    req.on("end", () => res.end("ok"));
    return;
  }
  if (!new URL(req.url ?? "/", "http://x").searchParams.has("sid")) {
    res.end('0{"sid":"abc","upgrades":[],"pingInterval":10000,"pingTimeout":5000,"maxPayload":1000000}');
    return;
  }
  pending = true;
  console.log("server: long-polling request received (never answered)");
  res.on("close", () => {
    pending = false;
    console.log("server: long-polling request closed");
  });
});

httpServer.listen(0, "127.0.0.1", () => {
  const { port } = httpServer.address() as AddressInfo;
  const socket = new Socket(`http://127.0.0.1:${port}`, { transports: [Fetch] });
  // Close the socket while the long-polling request is pending.
  socket.on("open", () => setTimeout(() => socket.close(), 100));
  socket.on("close", (reason) => console.log(`client: close "${reason}"`));

  setTimeout(() => {
    console.log(`after 2 s, the long-polling request is still open: ${pending}`);
    process.exit(pending ? 1 : 0);
  }, 2000);
});

npm install, then node repro.ts (Node.js 22.18 or later runs TypeScript directly). Output:

server: long-polling request received (never answered)
client: close "forced close"
after 2 s, the long-polling request is still open: true

The server never sees the long-polling request closed: it stays open after the close event, until process.exit().

Expected behavior

Once the close packet is sent, the pending requests are aborted, and the server prints server: long-polling request closed.

Platform:

  • Device: a GitHub Actions runner
  • OS: Linux, Node.js 24.22.2

Additional context

https://github.com/socketio/socket.io/blob/engine.io-client%406.6.8/packages/engine.io-client/lib/transports/polling.ts#L122-L137
https://github.com/socketio/socket.io/blob/engine.io-client%406.6.8/packages/engine.io-client/lib/transports/polling-xhr.ts#L82

The requests must not be aborted before the server receives the close packet: if the server sees the GET request closed first, it closes the session with the reason transport error instead of transport close.

Note: with the default polling transport of Node.js (xmlhttprequest-ssl), a pending request cannot be aborted today: from version 2.1.2 (the version that ~2.1.1 installs), abort() on a pending request throws an uncaught TypeError: Cannot read properties of null (reading 'reusedSocket') (mjwwit/node-XMLHttpRequest#37).

A pull request with a fix and a test follows: the pending requests are aborted once the close packet is sent (or cannot be sent), with the XMLHttpRequest object of a browser and with the Fetch transport.

I found this with AI assistance (Claude) during a review of WebSocket client libraries. The repro above was run with engine.io-client 6.6.8 on Node.js 24.22.2 on Linux (a GitHub Actions runner), and the output above is from that run.

Activity

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