Repository: denoland/deno
Pull request: fix(ext/node): support http2 createConnection sockets

Fixes the regression reported in https://github.com/denoland/deno/issues/26907#issuecomment-4039469375

### Problem

The HTTP/2 rewrite (#32418) broke `@grpc/grpc-js` for all plaintext connections. grpc-js uses `http2.connect()` with `createConnection`, which creates the socket via `net.connect()`. In Deno, this connects through `op_net_connect_tcp` instead of `uv_tcp_connect`, so `uv_tcp_t.internal_stream` is never set. `poll_tcp_handle` skips reads on handles without `internal_stream` -- every gRPC call hangs with DEADLINE_EXCEEDED.

### Fix

in `setupHandle`, detect non-native sockets (`kUseNativeWrap` is false), dup the fd from the Deno `TcpStreamResource`, and call `uv_tcp_open` to set `internal_stream` before `consume_stream` takes over. On failure, destroy the socket with an explicit error instead of falling back to the broken JS data pump.

Verified with `@grpc/grpc-js` 1.12.6 and 1.14.3 against both local and remote gRPC servers.

### Repro

```ts
// repro.ts
const grpc = await import("npm:@grpc/grpc-js@1.14.3");
const protoLoader = await import("npm:@grpc/proto-loader@0.8.0");

const pkgDef = grpc.loadPackageDefinition(protoLoader.fromJSON({
  nested: {
    hello: {
      nested: {
        HelloService: {
          methods: {
            SayHello: {
              requestType: "HelloRequest",
              responseType: "HelloResponse",
            },
          },
        },
        HelloRequest: { fields: { greeting: { type: "string", id: 1 } } },
        HelloResponse: { fields: { reply: { type: "string", id: 1 } } },
      },
    },
  },
}));
const client = new pkgDef.hello.HelloService(
  "grpcb.in:9000",
  grpc.credentials.createInsecure(),
);

const deadline = new Date(Date.now() + 5000);
setTimeout(() => {
  console.error("TIMEOUT");
  Deno.exit(2);
}, 7000);

client.SayHello({ greeting: "test" }, { deadline }, (err, res) => {
  if (err) console.error("ERROR:", err.code, err.message);
  else console.log("OK:", JSON.stringify(res));
  Deno.exit(err ? 1 : 0);
});
```

```sh
for deno in "docker run --rm denoland/deno:latest" "cargo run --bin deno --"; do
  echo "=== $deno ==="
  $deno eval "$(cat repro.ts)" 2>&1 | grep -E "^(OK:|ERROR:|TIMEOUT)"
done
```

```
=== docker run --rm denoland/deno:latest ===
ERROR: 4 4 DEADLINE_EXCEEDED: Deadline exceeded after 5.001s,name resolution: 0.007s,metadata filters: 0.001s,Waiting for LB pick
=== cargo run --bin deno -- ===
OK: {"reply":"hello test"}
```

### Remark

The PR now enables two `createConnection` tests that were `ignore`d before.

#### Disclaimer

I used AI heavily in this PR. But everything is manually checked and verified by me.

Changed files:
filename: ext/net/io.rs
patch: @@ -122,6 +122,21 @@ impl Resource for TcpStreamResource {
 }
 
 impl TcpStreamResource {
+  pub fn dup_raw_fd(self: &Rc<Self>) -> Option<i32> {
+    let wr = RcRef::map(self, |r| &r.wr).try_borrow()?;
+    let sock = SockRef::from(wr.as_ref().as_ref()).try_clone().ok()?;
+    #[cfg(unix)]
+    {
+      use std::os::unix::io::IntoRawFd;
+      Some(sock.into_raw_fd())
+    }
+    #[cfg(windows)]
+    {
+      use std::os::windows::io::IntoRawSocket;
+      i32::try_from(sock.into_raw_socket()).ok()
+    }
+  }
+
   pub fn set_nodelay(self: Rc<Self>, nodelay: bool) -> Result<(), MapError> {
     self.map_socket(Box::new(move |socket| socket.set_nodelay(nodelay)))
   }

filename: ext/node/ops/libuv_stream.rs
patch: @@ -9,6 +9,7 @@ use std::ptr;
 use deno_core::GarbageCollected;
 use deno_core::JsBuffer;
 use deno_core::OpState;
+use deno_core::ResourceId;
 use deno_core::op2;
 use deno_core::uv_compat;
 use deno_core::uv_compat::UvBuf;
@@ -20,6 +21,7 @@ use deno_core::uv_compat::UvStream;
 use deno_core::uv_compat::UvTcp;
 use deno_core::uv_compat::UvWrite;
 use deno_core::v8;
+use deno_net::io::TcpStreamResource;
 use deno_permissions::PermissionsContainer;
 use socket2::SockAddr as Socket2SockAddr;
 
@@ -372,6 +374,26 @@ impl TCP {
     }
   }
 
+  #[fast]
+  fn open_from_rid(&self, state: &mut OpState, #[smi] rid: ResourceId) -> i32 {
+    let tcp = self.raw();
+    if tcp.is_null() {
+      return -1;
+    }
+    let fd = state
+      .resource_table
+      .get::<TcpStreamResource>(rid)
+      .ok()
+      .and_then(|r| r.dup_raw_fd());
+    match fd {
+      // SAFETY: tcp is a valid initialized handle (null-checked above),
+      // fd is a valid dup'd descriptor. uv_tcp_open takes ownership of
+      // fd in all cases (closes on failure via std_stream drop).
+      Some(fd) => unsafe { uv_compat::uv_tcp_open(tcp, fd) },
+      None => -1,
+    }
+  }
+
   #[nofast]
   fn bind(
     &self,

filename: ext/node/polyfills/http2.ts
patch: @@ -2660,6 +2660,15 @@ function setupHandle(socket, type, options) {
   const tcpHandle = socket._handle;
   const nativeHandle = tcpHandle?._nativeHandle;
 
+  if (nativeHandle && !tcpHandle[kUseNativeWrap]) {
+    const streamBase = tcpHandle[kStreamBaseField];
+    const rid = streamBase?.rid ?? streamBase?.[internalRidSymbol];
+    if (typeof rid !== "number" || nativeHandle.openFromRid(rid) < 0) {
+      socket.destroy(new Error("Failed to open TCP handle from resource"));
+      return;
+    }
+  }
+
   if (nativeHandle) {
     // Cache socket address info before detaching the native handle,
     // since getpeername/getsockname won't work after detach.

filename: tests/unit_node/http2_test.ts
patch: @@ -75,10 +75,7 @@ for (const url of ["http://localhost:4246", "https://localhost:4247"]) {
   });
 }
 
-Deno.test(`[node/http2 client createConnection]`, {
-  // TODO(littledivy): custom createConnection sockets not yet supported
-  ignore: true,
-}, async () => {
+Deno.test(`[node/http2 client createConnection]`, async () => {
   const url = "http://127.0.0.1:4246";
   const createConnDeferred = Promise.withResolvers<void>();
   // Create a server to respond to the HTTP2 requests
@@ -123,10 +120,7 @@ Deno.test(`[node/http2 client createConnection]`, {
 });
 
 // https://github.com/denoland/deno/issues/29956
-Deno.test(`[node/http2 client body overflow]`, {
-  // TODO(littledivy): custom createConnection sockets not yet supported
-  ignore: true,
-}, async () => {
+Deno.test(`[node/http2 client body overflow]`, async () => {
   const url = "http://127.0.0.1:4246";
   const createConnDeferred = Promise.withResolvers<void>();
   // Create a server to respond to the HTTP2 requests