Skip to content
Open
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
19 changes: 19 additions & 0 deletions doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -1948,6 +1948,24 @@ an `ERR_QUIC_APPLICATION_ERROR` or `ERR_QUIC_TRANSPORT_ERROR` when the
stream is closed due to a QUIC error (e.g., stream reset by the peer,
CONNECTION\_CLOSE with a non-zero error code).

### `stream.cancel([reason])`

<!-- YAML
added: REPLACEME
-->

* `reason` {string} Optional human-readable reason.

Cancels the stream: abruptly terminates both directions, signaling to the
peer that the request or response was deliberately abandoned. `STOP_SENDING`
is sent for a still-open readable side and `RESET_STREAM` for a still-open
writable side. When the negotiated application protocol defines a
cancellation code ([RFC 9114 section 4.1.1][] defines `H3_REQUEST_CANCELLED`
for HTTP/3) the frames carry it; other application protocols use their
"no error" code.

The call does nothing if the stream is already destroyed.

### `stream.destroy([error[, options]])`

<!-- YAML
Expand Down Expand Up @@ -4761,6 +4779,7 @@ throughput issues caused by flow control.
[RFC 9001]: https://www.rfc-editor.org/rfc/rfc9001
[RFC 9002]: https://www.rfc-editor.org/rfc/rfc9002
[RFC 9114]: https://www.rfc-editor.org/rfc/rfc9114
[RFC 9114 section 4.1.1]: https://www.rfc-editor.org/rfc/rfc9114.html#section-4.1.1
[RFC 9204]: https://www.rfc-editor.org/rfc/rfc9204
[RFC 9218]: https://www.rfc-editor.org/rfc/rfc9218
[RFC 9220]: https://www.rfc-editor.org/rfc/rfc9220
Expand Down
17 changes: 17 additions & 0 deletions lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,23 @@ class QuicStream {
* @param {any} error
* @param {QuicStreamDestroyOptions} [options]
*/
/**
* Cancels the stream: abruptly terminates both directions and signals
* to the peer that the request or response was deliberately abandoned.
* When the negotiated application protocol defines a cancellation code
* (HTTP/3: `H3_REQUEST_CANCELLED`, RFC 9114 section 4.1.1) it is sent
* on the RESET_STREAM / STOP_SENDING frames; other applications use
* their "no error" code.
* @param {string} [reason] Optional human-readable reason.
*/
cancel(reason) {
assertIsQuicStream(this);
if (this.#inner.destroying || this.destroyed) return;
const code =
getQuicSessionState(this.#inner.session).requestCancelledCode;
this.destroy(undefined, { code, reason });
}

destroy(error, options = kEmptyObject) {
assertIsQuicStream(this);
const inner = this.#inner;
Expand Down
9 changes: 9 additions & 0 deletions lib/internal/quic/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const {
IDX_STATE_SESSION_NO_ERROR_CODE,
IDX_STATE_SESSION_INTERNAL_ERROR_CODE,
IDX_STATE_SESSION_REQUEST_REJECTED_CODE,
IDX_STATE_SESSION_REQUEST_CANCELLED_CODE,
IDX_STATE_SESSION_MAX_DATAGRAM_SIZE,
IDX_STATE_SESSION_LAST_DATAGRAM_ID,
IDX_STATE_SESSION_MAX_PENDING_DATAGRAMS,
Expand Down Expand Up @@ -125,6 +126,7 @@ assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined);
assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined);
assert(IDX_STATE_SESSION_REQUEST_REJECTED_CODE !== undefined);
assert(IDX_STATE_SESSION_REQUEST_CANCELLED_CODE !== undefined);
assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined);
assert(IDX_STATE_SESSION_LAST_DATAGRAM_ID !== undefined);
assert(IDX_STATE_ENDPOINT_BOUND !== undefined);
Expand Down Expand Up @@ -557,6 +559,13 @@ class QuicSessionState {
handle, this.#offset + IDX_STATE_SESSION_REQUEST_REJECTED_CODE, kIsLittleEndian);
}

get requestCancelledCode() {
const handle = this.#handle;
if (handle === undefined) return undefined;
return DataViewPrototypeGetBigUint64(
handle, this.#offset + IDX_STATE_SESSION_REQUEST_CANCELLED_CODE, kIsLittleEndian);
}

/** @type {number} */
get maxDatagramSize() {
const handle = this.#handle;
Expand Down
5 changes: 5 additions & 0 deletions src/quic/application.cc
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,11 @@ class DefaultApplication final : public Session::Application {
return GetNoErrorCode();
}

// Raw QUIC has no "request cancelled" semantic; reuse the no-error code.
error_code GetRequestCancelledCode() const override {
return GetNoErrorCode();
}

void EarlyDataRejected() override {
// Destroy all open streams — ngtcp2 has already discarded their
// internal state when it rejected the early data. Use the
Expand Down
7 changes: 7 additions & 0 deletions src/quic/application.h
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class Session::Application : public MemoryRetainer {
// "no error" code.
virtual error_code GetRequestRejectedCode() const = 0;

// The "request cancelled" code is sent on RESET_STREAM / STOP_SENDING
// when an endpoint deliberately abandons a request or response (e.g.
// stream.cancel()). For HTTP/3 this is NGHTTP3_H3_REQUEST_CANCELLED
// (0x10c); other applications have no such semantic and reuse the
// "no error" code.
virtual error_code GetRequestCancelledCode() const = 0;

// Called after Session::Receive processes a packet, outside all callback
// scopes. Applications can use this to handle deferred operations that
// require calling into JS (e.g., HTTP/3 GOAWAY processing).
Expand Down
4 changes: 4 additions & 0 deletions src/quic/http3.cc
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ class Http3ApplicationImpl final : public Session::Application {
return NGHTTP3_H3_REQUEST_REJECTED;
}

error_code GetRequestCancelledCode() const override {
return NGHTTP3_H3_REQUEST_CANCELLED;
}

void EarlyDataRejected() override {
// When 0-RTT is rejected, destroy the nghttp3 connection and all
// open streams — ngtcp2 has discarded their internal state.
Expand Down
2 changes: 2 additions & 0 deletions src/quic/session.cc
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) {
V(NO_ERROR_CODE, no_error_code, error_code) \
V(INTERNAL_ERROR_CODE, internal_error_code, error_code) \
V(REQUEST_REJECTED_CODE, request_rejected_code, error_code) \
V(REQUEST_CANCELLED_CODE, request_cancelled_code, error_code) \
V(MAX_DATAGRAM_SIZE, max_datagram_size, uint16_t) \
V(LAST_DATAGRAM_ID, last_datagram_id, datagram_id) \
V(MAX_PENDING_DATAGRAMS, max_pending_datagrams, uint16_t)
Expand Down Expand Up @@ -2656,6 +2657,7 @@ void Session::SetApplication(std::unique_ptr<Application> app) {
impl_->state()->no_error_code = app->GetNoErrorCode();
impl_->state()->internal_error_code = app->GetInternalErrorCode();
impl_->state()->request_rejected_code = app->GetRequestRejectedCode();
impl_->state()->request_cancelled_code = app->GetRequestCancelledCode();
impl_->application_ = std::move(app);
}

Expand Down
102 changes: 102 additions & 0 deletions test/parallel/test-quic-stream-cancel.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Flags: --experimental-quic --no-warnings

// stream.cancel() abruptly terminates both directions of a stream. On a
// session whose application protocol defines a cancellation code (HTTP/3),
// the RESET_STREAM / STOP_SENDING frames carry H3_REQUEST_CANCELLED
// (RFC 9114 section 4.1.1); other applications use their "no error" code.
// Refs: https://github.com/nodejs/node/issues/65509

import { hasQuic, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';

if (!hasQuic) {
skip('QUIC is not enabled');
}

const { listen, connect } = await import('node:quic');
const { createPrivateKey } = await import('node:crypto');

const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
const cert = fixtures.readKey('agent1-cert.pem');

// RFC 9114 H3_REQUEST_CANCELLED.
const H3_REQUEST_CANCELLED = 0x10cn;

// --- An h3 server abandoning a request signals H3_REQUEST_CANCELLED ---
{
const clientSawReset = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onerror = () => {};
}), {
sni: { '*': { keys: [key], certs: [cert] } },
onheaders: mustCall(function() {
// The server abandons the request without responding.
this.cancel();
assert.strictEqual(this.destroyed, true);
// Cancelling again is a no-op.
this.cancel();
}),
});

const clientSession = await connect(serverEndpoint.address, {
servername: 'localhost',
verifyPeer: 'manual',
});
await clientSession.opened;

const stream = await clientSession.createBidirectionalStream({
headers: {
':method': 'GET',
':path': '/test',
':scheme': 'https',
':authority': 'localhost',
},
});
stream.onerror = () => {};
stream.onreset = mustCall((err) => {
assert.strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
assert.strictEqual(err.errorCode, H3_REQUEST_CANCELLED);
clientSawReset.resolve();
});

await clientSawReset.promise;
await clientSession.close();
await serverEndpoint.close();
}

// --- On a non-h3 application, cancel() uses the "no error" code (0) ---
{
const clientSawReset = Promise.withResolvers();
const serverEndpoint = await listen(mustCall((serverSession) => {
serverSession.onerror = () => {};
serverSession.onstream = mustCall((stream) => {
stream.cancel();
});
}), {
sni: { '*': { keys: [key], certs: [cert] } },
alpn: ['test-proto'],
});

const clientSession = await connect(serverEndpoint.address, {
servername: 'localhost',
alpn: 'test-proto',
verifyPeer: 'manual',
});
await clientSession.opened;

const stream = await clientSession.createBidirectionalStream({
body: 'data',
});
stream.onerror = () => {};
stream.onreset = mustCall((err) => {
// A reset carrying the "no error" code is surfaced without an error
// object: the peer terminated the stream cleanly.
assert.strictEqual(err, undefined);
clientSawReset.resolve();
});

await clientSawReset.promise;
await clientSession.close();
await serverEndpoint.close();
}
Loading