Skip to content

Commit d12ea83

Browse files
divybotlittledivy
andauthored
fix(ext/node): wire HTTP/2 PING ack callbacks and emit payload buffer (#33794)
Enables `test-http2-onping` in node_compat suite. Co-authored-by: Divy Srivastava <me@littledivy.com>
1 parent 2b39f9c commit d12ea83

3 files changed

Lines changed: 60 additions & 10 deletions

File tree

ext/node/ops/http2/session.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -834,12 +834,12 @@ unsafe extern "C" fn on_frame_recv_callback(
834834
// destroy the session with NghttpError(NGHTTP2_ERR_PROTO).
835835
if session.pending_pings > 0 {
836836
session.pending_pings -= 1;
837-
handle_ping_frame(session);
837+
handle_ping_frame(session, frame, true);
838838
} else {
839839
handle_unsolicited_ping_ack(session);
840840
}
841841
} else {
842-
handle_ping_frame(session);
842+
handle_ping_frame(session, frame, false);
843843
}
844844
} else if ft == ffi::NGHTTP2_ALTSVC as u32 {
845845
handle_alt_svc_frame(session, frame);
@@ -997,22 +997,43 @@ fn handle_settings_frame(session: &Session) {
997997
callback.call(scope, recv.into(), &[]);
998998
}
999999

1000-
fn handle_ping_frame(session: &Session) {
1000+
fn handle_ping_frame(
1001+
session: &Session,
1002+
frame: *const ffi::nghttp2_frame,
1003+
is_ack: bool,
1004+
) {
10011005
let mut isolate =
10021006
// SAFETY: isolate pointer is valid for the session's lifetime
10031007
unsafe { v8::Isolate::from_raw_isolate_ptr(session.isolate) };
10041008
v8::scope!(let scope, &mut isolate);
10051009
let context = v8::Local::new(scope, session.context.clone());
10061010
let scope = &mut v8::ContextScope::new(scope, context);
10071011

1012+
// SAFETY: frame is a valid PING frame per nghttp2 callback contract
1013+
let opaque = unsafe { (*frame).ping.opaque_data };
1014+
let array_buffer = v8::ArrayBuffer::new(scope, opaque.len());
1015+
let backing_store = array_buffer.get_backing_store();
1016+
if let Some(backing_data) = backing_store.data() {
1017+
// SAFETY: src and dst are valid, non-overlapping, dst has room for 8 bytes
1018+
unsafe {
1019+
std::ptr::copy_nonoverlapping(
1020+
opaque.as_ptr(),
1021+
backing_data.as_ptr() as *mut u8,
1022+
opaque.len(),
1023+
);
1024+
}
1025+
}
1026+
let payload =
1027+
v8::Uint8Array::new(scope, array_buffer, 0, opaque.len()).unwrap();
1028+
10081029
let state = session.op_state.borrow();
10091030
let callbacks = state.borrow::<SessionCallbacks>();
10101031
let recv = v8::Local::new(scope, &session.this);
10111032
let callback = v8::Local::new(scope, &callbacks.ping_frame_cb);
10121033
drop(state);
10131034

1014-
let arg = v8::null(scope);
1015-
callback.call(scope, recv.into(), &[arg.into()]);
1035+
let is_ack_v = v8::Boolean::new(scope, is_ack);
1036+
callback.call(scope, recv.into(), &[payload.into(), is_ack_v.into()]);
10161037
}
10171038

10181039
/// Inbound PING ACK with no matching outstanding PING. Mirrors Node's

ext/node/polyfills/http2.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -664,14 +664,31 @@ const proxySocketHandler = {
664664
},
665665
};
666666

667-
function onPing(payload) {
667+
function onPing(payload, isAck) {
668668
const session = this[kOwner];
669669
if (session.destroyed) {
670670
return;
671671
}
672672
session[kUpdateTimer]();
673673
debugSessionObj(session, "new ping received");
674-
session.emit("ping", payload);
674+
// Convert the Uint8Array payload to a Buffer so userland sees a Buffer
675+
// instance (matches Node.js behaviour and allows deepStrictEqual against
676+
// Buffers passed by callers).
677+
const buf = payload && Buffer.from(
678+
payload.buffer,
679+
payload.byteOffset,
680+
payload.byteLength,
681+
);
682+
if (isAck) {
683+
// Inbound PING ACK - resolve the oldest outstanding ping callback in
684+
// submission order (RFC 7540 6.7 requires PINGs to be ack'd in order).
685+
// The Rust binding has already validated this is a solicited ACK
686+
// (unsolicited ACKs are routed through the internal-error callback).
687+
const cb = session[kState].pendingPings.shift();
688+
if (cb) cb(true, 0.0, buf);
689+
return;
690+
}
691+
session.emit("ping", buf);
675692
}
676693

677694
// Called when the stream is closed either by sending or receiving an
@@ -3618,12 +3635,23 @@ class Http2Session extends EventEmitter {
36183635
// didn't supply one.
36193636
const buf = payload || Buffer.alloc(8);
36203637

3638+
const ret = this[kHandle].ping(buf);
3639+
if (ret !== 0) {
3640+
process.nextTick(cb, false, 0.0, payload);
3641+
return false;
3642+
}
36213643
// Track the pending ping so it can be cancelled when the session is
36223644
// destroyed before the PING ACK arrives. Matches Node's
3623-
// ClearOutstandingPings in src/node_http2.cc.
3645+
// ClearOutstandingPings in src/node_http2.cc. The wrapped pingCallback
3646+
// (registered via Http2Session#ping) is consumed FIFO when an inbound
3647+
// PING ACK is delivered to onPing below.
36243648
this[kState].pendingPings.push(cb);
3625-
3626-
return this[kHandle].ping(buf, cb);
3649+
// The native ping op queues the PING frame inside nghttp2 but the
3650+
// session's actual transport is owned by the JS socket (setupHandle
3651+
// overrides handle.sendPending), so we must trigger the flush ourselves
3652+
// for the PING to leave this peer.
3653+
scheduleSendPending(this);
3654+
return true;
36273655
}
36283656

36293657
[kInspect](depth, opts) {

tests/node_compat/config.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,6 +1776,7 @@
17761776
"parallel/test-http2-multistream-destroy-on-read-tls.js": {},
17771777
"parallel/test-http2-no-more-streams.js": {},
17781778
"parallel/test-http2-no-wanttrailers-listener.js": {},
1779+
"parallel/test-http2-onping.js": {},
17791780
"parallel/test-http2-options-max-headers-block-length.js": {},
17801781
"parallel/test-http2-options-max-headers-exceeds-nghttp2.js": {},
17811782
"parallel/test-http2-options-server-request.js": {},

0 commit comments

Comments
 (0)