From 421a9297334f10e9b838969476eab9c5064fa4ec Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 18 Jul 2026 04:10:59 -0700 Subject: [PATCH] fix(http): OutgoingMessage destroyed getter + destroy()/write-after-destroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new http.OutgoingMessage().destroyed` read back `undefined` (Node: `false`), `destroy()` was a no-op, and `write()` after `destroy()` silently buffered instead of erroring — so Node's own `test-http-outgoing-destroy` failed at its first assertion. Track a `destroyed` flag on `ServerResponse` (the handle backing a bare `OutgoingMessage`): `destroy()` sets it, the `destroyed` getter reads it, and `write(chunk, cb)` on a destroyed message invokes `cb` with an `ERR_STREAM_DESTROYED` error and returns `false` without buffering or emitting `'error'`. The getter is wired through both dispatch front-doors — perry-stdlib's `js_handle_{property,method}_dispatch` name gates and perry-ext-http-server's `is_server_response_member` / dispatch arms. The stdlib property gate is the one that actually forwards the read at runtime under auto-optimize, and `destroyed` was missing from it, so the read never reached the ext dispatch. Part of the node:http behavioral parity tail (#4975). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../perry-ext-http-server/src/dispatch_ext.rs | 2 ++ .../src/handle_dispatch.rs | 28 +++++++++++++++++-- crates/perry-ext-http-server/src/response.rs | 23 +++++++++++++++ .../src/common/dispatch/method_dispatch.rs | 1 + .../src/common/dispatch/property_dispatch.rs | 1 + 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/perry-ext-http-server/src/dispatch_ext.rs b/crates/perry-ext-http-server/src/dispatch_ext.rs index afcd854608..cb60837477 100644 --- a/crates/perry-ext-http-server/src/dispatch_ext.rs +++ b/crates/perry-ext-http-server/src/dispatch_ext.rs @@ -214,6 +214,7 @@ fn is_server_response_member(name: &str) -> bool { | "writableEnded" | "writableFinished" | "finished" + | "destroyed" | "writableCorked" | "writableHighWaterMark" | "writableLength" @@ -235,6 +236,7 @@ fn is_server_response_member(name: &str) -> bool { | "__get_writableEnded" | "__get_writableFinished" | "__get_finished" + | "__get_destroyed" | "__get_sendDate" | "__set_sendDate" | "__get_strictContentLength" diff --git a/crates/perry-ext-http-server/src/handle_dispatch.rs b/crates/perry-ext-http-server/src/handle_dispatch.rs index 851675a356..4ac6ed1fc7 100644 --- a/crates/perry-ext-http-server/src/handle_dispatch.rs +++ b/crates/perry-ext-http-server/src/handle_dispatch.rs @@ -22,7 +22,9 @@ //! //! Issue #2153. -use perry_ffi::{alloc_string, get_handle, js_object_alloc_with_shape, JsValue, StringHeader}; +use perry_ffi::{ + alloc_string, get_handle, get_handle_mut, js_object_alloc_with_shape, JsValue, StringHeader, +}; use crate::http2_server::Http2SecureServer; use crate::https_server::HttpsServer; @@ -788,7 +790,16 @@ pub unsafe extern "C" fn js_ext_http_server_response_dispatch_method( js_node_http_res_write_processing(handle); undef } - "destroy" => self_ref, + // #4975: `outgoingMessage.destroy()` flips the `destroyed` flag (read + // back by the `destroyed` getter) and returns `this`. A subsequent + // `write()` then errors its callback with `ERR_STREAM_DESTROYED` + // rather than buffering (see `js_node_http_res_write_with_cb`). + "destroy" => { + if let Some(sr) = get_handle_mut::(handle) { + sr.destroyed = true; + } + self_ref + } "assignSocket" if !args.is_empty() => { crate::response::js_node_http_res_assign_socket(handle, args[0]); undef @@ -834,6 +845,13 @@ pub unsafe extern "C" fn js_ext_http_server_response_dispatch_method( "__get_writableEnded" => bool_value(js_node_http_res_writable_ended(handle) != 0), "__get_writableFinished" => bool_value(js_node_http_res_writable_finished(handle) != 0), "__get_finished" | "finished" => bool_value(js_node_http_res_finished(handle) != 0), + // #4975: `outgoingMessage.destroyed` getter, also reachable through the + // `__get_destroyed` codegen form. + "__get_destroyed" | "destroyed" => bool_value( + get_handle::(handle) + .map(|sr| sr.destroyed) + .unwrap_or(false), + ), "__get_sendDate" | "sendDate" => bool_value(js_node_http_res_send_date(handle) != 0), "__set_sendDate" if !args.is_empty() => { js_node_http_res_set_send_date(handle, args[0]); @@ -952,6 +970,12 @@ pub unsafe extern "C" fn js_ext_http_server_response_dispatch_property( "writableEnded" => bool_value(js_node_http_res_writable_ended(handle) != 0), "writableFinished" => bool_value(js_node_http_res_writable_finished(handle) != 0), "finished" => bool_value(js_node_http_res_finished(handle) != 0), + // #4975: `outgoingMessage.destroyed` — false until `destroy()`. + "destroyed" => bool_value( + get_handle::(handle) + .map(|sr| sr.destroyed) + .unwrap_or(false), + ), "writableCorked" => 0.0, "writableHighWaterMark" => 65_536.0, "writableLength" => get_handle::(handle) diff --git a/crates/perry-ext-http-server/src/response.rs b/crates/perry-ext-http-server/src/response.rs index a810a1b43c..f737feff2c 100644 --- a/crates/perry-ext-http-server/src/response.rs +++ b/crates/perry-ext-http-server/src/response.rs @@ -255,6 +255,11 @@ pub struct ServerResponse { /// #4904: `res.write(chunk, cb)` callbacks, invoked in order when the /// buffered body flushes on `.end()`. pub pending_write_callbacks: Vec, + /// #4975: `outgoingMessage.destroy()` state. Node's `OutgoingMessage` + /// (and its `ServerResponse` subclass) exposes a `destroyed` getter that + /// flips `true` after `destroy()`, and a post-destroy `write(chunk, cb)` + /// invokes `cb` with an `ERR_STREAM_DESTROYED` error instead of buffering. + pub destroyed: bool, } /// Owned shape produced by `.end()` — the per-request oneshot channel @@ -412,6 +417,7 @@ impl ServerResponse { standalone_socket: f64::from_bits(TAG_UNDEFINED), standalone_req_method: None, pending_write_callbacks: Vec::new(), + destroyed: false, } } @@ -1748,6 +1754,23 @@ pub extern "C" fn js_node_http_res_detach_socket(handle: i64, _socket: f64) { /// flushes on `.end()`, preserving call order (#4904). #[no_mangle] pub extern "C" fn js_node_http_res_write_with_cb(handle: i64, chunk: f64, callback: i64) -> i32 { + // #4975: `write()` after `destroy()` must not buffer; Node invokes the + // callback with an `ERR_STREAM_DESTROYED` error and returns `false` (no + // `'error'` event is emitted, so an `on('error', …)` listener stays silent). + if get_handle::(handle) + .map(|sr| sr.destroyed) + .unwrap_or(false) + { + if callback != 0 { + let err = perry_ffi::error_value_with_code( + "Cannot call write after a stream was destroyed", + "ERR_STREAM_DESTROYED", + perry_ffi::ErrorKind::Error, + ); + crate::http2_server::call1(callback, f64::from_bits(err.bits())); + } + return 0; + } let bytes = jsvalue_to_body_bytes(chunk); // Honor streaming mode (after `res.flushHeaders()` / a prior streamed // `res.write`) exactly like `js_node_http_res_write`: the chunk must go down diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 1a8020918c..911b198b45 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -683,6 +683,7 @@ pub unsafe extern "C" fn js_handle_method_dispatch( | "__get_writableEnded" | "__get_writableFinished" | "__get_finished" + | "__get_destroyed" | "__get_sendDate" | "__set_sendDate" | "__get_strictContentLength" diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index e546739eb9..4f3c5944d7 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -344,6 +344,7 @@ pub unsafe extern "C" fn js_handle_property_dispatch( | "writableEnded" | "writableFinished" | "finished" + | "destroyed" | "writableCorked" | "writableHighWaterMark" | "writableLength"