Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/js/node/http2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ const kReceivedGoaway = Symbol("receivedGoaway");
// The error code carried by a received GOAWAY; like Node's state.goawayCode it
// takes precedence over the destroy code when streams are torn down.
const kGoawayCode = Symbol("goawayCode");
// The Last-Stream-ID carried by a received GOAWAY (Node's state.goawayLastStreamID).
const kGoawayLastStreamID = Symbol("goawayLastStreamID");
const kReleaseUnannouncedStream = Symbol("releaseUnannouncedStream");
const kGoawaySent = Symbol("goawaySent");

Expand Down Expand Up @@ -2082,6 +2084,13 @@ class Http2Session extends EventEmitter {
// run inside it so 'close' doesn't inherit the last stream's frame.
[bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0);
[kDeferWriteCallback] = setImmediate;
// The GOAWAY this side received (not one it sent), like node's Http2Session getters.
get goawayCode() {
return this[kGoawayCode] || NGHTTP2_NO_ERROR;
}
get goawayLastStreamID() {
return this[kGoawayLastStreamID] || 0;
}
[EventEmitter.captureRejectionSymbol](err, event, ...args) {
switch (event) {
case "stream": {
Expand Down Expand Up @@ -4454,6 +4463,7 @@ class ServerHttp2Session extends Http2Session {
if (!self) return;
if (self.destroyed) return;
self[kGoawayCode] = errorCode;
self[kGoawayLastStreamID] = lastStreamId;
self.emit("goaway", errorCode, lastStreamId, opaqueData || Buffer.allocUnsafe(0));
if (errorCode === constants.NGHTTP2_NO_ERROR) {
// Graceful shutdown: no new streams, existing ones may finish.
Expand Down Expand Up @@ -5449,6 +5459,7 @@ class ClientHttp2Session extends Http2Session {
if (!self) return;
if (self.destroyed) return;
self[kGoawayCode] = errorCode;
self[kGoawayLastStreamID] = lastStreamId;
// node: once a GOAWAY is received, new streams cannot be created on this session -
// request() throws ERR_HTTP2_GOAWAY_SESSION (clients like grpc rely on the throw to
// fail over to a fresh connection).
Expand Down
130 changes: 130 additions & 0 deletions test/js/node/http2/node-http2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2156,6 +2156,136 @@ it("http2 client receives 'goaway' when the server rejects a stream", async () =
}
});

// node's Http2Session#goawayCode / #goawayLastStreamID report the GOAWAY frame this side
// received (0 / 0 until one arrives). Expected values below were checked against node v26.3.0.
describe.concurrent("http2 session goawayCode / goawayLastStreamID", () => {
const goawayState = session => ({ goawayCode: session.goawayCode, goawayLastStreamID: session.goawayLastStreamID });
const noGoaway = { goawayCode: 0, goawayLastStreamID: 0 };

// One connected session, seen from both ends. The server answers every request immediately.
async function connectedPair() {
const server = http2.createServer();
server.on("stream", stream => stream.respond({ ":status": 200 }, { endStream: true }));
const { promise: serverSessionPromise, resolve: onServerSession } = Promise.withResolvers();
server.once("session", onServerSession);
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const { promise: connected, resolve: onConnect } = Promise.withResolvers();
const client = http2.connect(`http://127.0.0.1:${server.address().port}`, onConnect);
client.on("error", () => {});
const serverSession = await serverSessionPromise;
serverSession.on("error", () => {});
await connected;
return { server, client, serverSession };
}

async function requestOnce(client) {
const req = client.request({ ":path": "/" });
req.on("error", () => {});
req.resume();
await new Promise(resolve => req.once("close", resolve));
}

// Resolves with the 'goaway' event arguments and what the getters reported while it was emitted.
function goawayReceived(session) {
return new Promise(resolve =>
session.once("goaway", (code, lastStreamID) =>
resolve({ args: [code, lastStreamID], getters: goawayState(session) }),
),
);
}

function closed(session) {
return new Promise(resolve => session.once("close", resolve));
}

it("are getters that report 0 / 0 on client and server sessions before any GOAWAY is received", async () => {
const { server, client, serverSession } = await connectedPair();
try {
expect(goawayState(client)).toEqual(noGoaway);
expect(goawayState(serverSession)).toEqual(noGoaway);
for (const session of [client, serverSession]) {
for (const name of ["goawayCode", "goawayLastStreamID"]) {
let proto = Object.getPrototypeOf(session);
while (proto !== null && !Object.hasOwn(proto, name)) proto = Object.getPrototypeOf(proto);
const descriptor = proto === null ? undefined : Object.getOwnPropertyDescriptor(proto, name);
expect(typeof descriptor?.get).toBe("function");
expect(descriptor.set).toBeUndefined();
}
}
} finally {
client.destroy();
serverSession.destroy();
server.close();
}
});

it("client session reports the code and last stream id of the GOAWAY the server sent", async () => {
const { server, client, serverSession } = await connectedPair();
try {
await requestOnce(client); // stream 1, so a Last-Stream-ID of 1 names a real stream
const received = goawayReceived(client);
const clientClosed = closed(client);
serverSession.goaway(http2.constants.NGHTTP2_ENHANCE_YOUR_CALM, 1);
// Sending a GOAWAY does not count as receiving one.
expect(goawayState(serverSession)).toEqual(noGoaway);

const expected = { goawayCode: http2.constants.NGHTTP2_ENHANCE_YOUR_CALM, goawayLastStreamID: 1 };
expect(await received).toEqual({ args: [expected.goawayCode, expected.goawayLastStreamID], getters: expected });
// A GOAWAY with an error code destroys the receiving session; the values survive that.
await clientClosed;
expect(client.destroyed).toBe(true);
expect(goawayState(client)).toEqual(expected);
} finally {
client.destroy();
serverSession.destroy();
server.close();
}
});

it("server session reports the code and last stream id of the GOAWAY the client sent", async () => {
const { server, client, serverSession } = await connectedPair();
try {
const received = goawayReceived(serverSession);
const serverSessionClosed = closed(serverSession);
// From a client, Last-Stream-ID names a server-initiated (even) stream.
client.goaway(http2.constants.NGHTTP2_CANCEL, 2);
expect(goawayState(client)).toEqual(noGoaway);

const expected = { goawayCode: http2.constants.NGHTTP2_CANCEL, goawayLastStreamID: 2 };
expect(await received).toEqual({ args: [expected.goawayCode, expected.goawayLastStreamID], getters: expected });
await serverSessionClosed;
expect(serverSession.destroyed).toBe(true);
expect(goawayState(serverSession)).toEqual(expected);
} finally {
client.destroy();
serverSession.destroy();
server.close();
}
});

it("a graceful GOAWAY leaves goawayCode at 0 but still records the last stream id", async () => {
const { server, client, serverSession } = await connectedPair();
try {
await requestOnce(client);
const received = goawayReceived(client);
const clientClosed = closed(client);
// No explicit Last-Stream-ID: like node, the last stream the server processed (1) goes out.
serverSession.goaway();
expect(goawayState(serverSession)).toEqual(noGoaway);

const expected = { goawayCode: http2.constants.NGHTTP2_NO_ERROR, goawayLastStreamID: 1 };
expect(await received).toEqual({ args: [0, 1], getters: expected });
// NO_ERROR starts a graceful close; with no streams left the session closes on its own.
await clientClosed;
expect(goawayState(client)).toEqual(expected);
} finally {
client.destroy();
serverSession.destroy();
server.close();
}
});
});

it(
"http2 server with minimal maxSessionMemory handles multiple requests",
async () => {
Expand Down