Skip to content

c.req.raw.signal is not triggered with node:http2 #398

Description

@dvoloshyn

Hono 4.13.4
@hono/node-server 2.1.1
Nodejs v26.7.0

Steps to reproduce:

  1. Run the http2 code below:
import {serve} from "@hono/node-server";
import {createServer, type Http2Server} from "node:http2";
import {Hono} from "hono";

const hono = new Hono();
hono.get('/timeout/:millis', (c) => {
    c.req.raw.signal.addEventListener('abort', () =>
        console.log("Request aborted", c.req.raw.signal.reason)
    );
    return new Promise<Response>((resolve) =>
        setTimeout(() => resolve(c.text("Completed")), Number(c.req.param('millis')))
    );
});

const server = serve({
    fetch: hono.fetch,
    hostname: "0.0.0.0",
    port: 8080,
    createServer,
}) as Http2Server;

server.on('session', (session) => {
    console.log("Session opened");

    session.once('close', () => console.log("Session closed"));
});
  1. Run the following command and abort it with Ctrl+C curl -v --http2-prior-knowledge http://localhost:8080/timeout/10000
  2. Notice in the console session events firing but never "Request aborted"
Session opened
Session closed
Session opened
Session closed
  1. Now run the http1 code below:
import {serve} from "@hono/node-server";
import {Hono} from "hono";

const hono = new Hono();
hono.get('/timeout/:millis', (c) => {
    c.req.raw.signal.addEventListener('abort', () =>
        console.log("Request aborted", c.req.raw.signal.reason)
    );
    return new Promise<Response>((resolve) =>
        setTimeout(() => resolve(c.text("Completed")), Number(c.req.param('millis')))
    );
});

const server = serve({
    fetch: hono.fetch,
    hostname: "0.0.0.0",
    port: 8080,
})
  1. Run the command and abort it with Ctrl+C curl -v --http1.1 http://localhost:8080/timeout/10000
  2. Notice in the console "Request aborted" firing
Request aborted Error: aborted
Request aborted Error: aborted

Expected behavior: c.req.raw.signal should be triggered for http2, not only for http1.

I traced it to the makeCloseHandler.
incoming.errored is true for http1 and null for http2
But
incoming.aborted is true for both http2 and http1
Is there any specific reason why we have these peculiar conditions for triggering AbortSignal?

const makeCloseHandler = (req, incoming, outgoing, needsBodyCleanup) => () => {
	if (incoming.errored) {
		recordBodyBufferedBeforeDisconnect(incoming);
		req[abortRequest](incoming.errored.toString());
	} else if (!outgoing.writableFinished) {
		recordBodyBufferedBeforeDisconnect(incoming);
		req[abortRequest]("Client connection prematurely closed.");
	}
	if (needsBodyCleanup && !incoming.readableEnded) setTimeout(() => {
		if (!incoming.readableEnded) setTimeout(() => {
			drainIncoming(incoming);
		});
	});
};

instead of relying, say, on incoming.aborted?

IncomingMessage.aborted is deprecated, but still works. IncomingMessage.destroyed is recommended by the docs https://nodejs.org/api/http.html#requestaborted as replacement, but doesn't work for http2

Http2ServerRequest.aborted is not deprecated, but destroyed is false in makeCloseHandler

Can we improve the current implementation? Want to hear more opinions

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