Repository: denoland/deno
Pull request: fix(ext/node): allow passing `FileHandle` as `fd` argument for `WriteStream` and `ReadStream`

Towards #29972

Allows the following tests to pass:
- https://github.com/nodejs/node/blob/v25.8.1/test/parallel/test-fs-read-stream-file-handle.js
- https://github.com/nodejs/node/blob/v25.8.1/test/parallel/test-fs-write-stream-file-handle.js
- https://github.com/nodejs/node/blob/v25.8.1/test/parallel/test-fs-write-stream-file-handle-2.js

Changed files:
filename: ext/node/polyfills/internal/fs/handle.ts
patch: @@ -98,8 +98,8 @@ const kRefs = Symbol("kRefs");
 const kClosePromise = Symbol("kClosePromise");
 const kCloseResolve = Symbol("kCloseResolve");
 const kCloseReject = Symbol("kCloseReject");
-const kRef = Symbol("kRef");
-const kUnref = Symbol("kUnref");
+export const kRef = Symbol("kRef");
+export const kUnref = Symbol("kUnref");
 const kLocked = Symbol("kLocked");
 
 const ftruncatePromise = promisify(ftruncateCb);
@@ -309,11 +309,11 @@ export class FileHandle extends EventEmitter {
   }
 
   createReadStream(options?: CreateReadStreamOptions): ReadStream {
-    return new ReadStream(undefined, { ...options, fd: this.fd });
+    return new ReadStream(undefined, { ...options, fd: this });
   }
 
   createWriteStream(options?: CreateWriteStreamOptions): WriteStream {
-    return new WriteStream(undefined, { ...options, fd: this.fd });
+    return new WriteStream(undefined, { ...options, fd: this });
   }
 
   readLines(options?: CreateReadStreamOptions): ReadlineInterface {

filename: ext/node/polyfills/internal/fs/streams.mjs
patch: @@ -4,18 +4,21 @@
 import { primordials } from "ext:core/mod.js";
 const {
   Array,
+  FunctionPrototypeBind,
   MathMin,
   NumberPOSITIVE_INFINITY,
   ObjectDefineProperty,
   ObjectPrototypeIsPrototypeOf,
   ObjectSetPrototypeOf,
+  PromisePrototypeThen,
   Promise,
   ReflectApply,
   StringPrototypeToString,
   Symbol,
 } = primordials;
 import {
   ERR_INVALID_ARG_TYPE,
+  ERR_METHOD_NOT_IMPLEMENTED,
   ERR_OUT_OF_RANGE,
 } from "ext:deno_node/internal/errors.ts";
 import { kEmptyObject } from "ext:deno_node/internal/util.mjs";
@@ -36,10 +39,12 @@ import {
 import { finished, Readable, Writable } from "node:stream";
 import { toPathIfFileURL } from "ext:deno_node/internal/url.ts";
 import { nextTick } from "ext:deno_node/_next_tick.ts";
+import { FileHandle, kRef, kUnref } from "ext:deno_node/internal/fs/handle.ts";
+
 const kIoDone = Symbol("kIoDone");
 const kIsPerformingIO = Symbol("kIsPerformingIO");
-
 const kFs = Symbol("kFs");
+const kHandle = Symbol("kHandle");
 
 function _construct(callback) {
   // deno-lint-ignore no-this-alias
@@ -87,6 +92,46 @@ function _construct(callback) {
     );
   }
 }
+/**
+ * @param {import("node:fs/promises").FileHandle} handle
+ */
+const FileHandleOperations = (handle) => {
+  return {
+    open: (_path, _flags, _mode, _cb) => {
+      throw new ERR_METHOD_NOT_IMPLEMENTED("open()");
+    },
+    close: (_fd, cb) => {
+      handle[kUnref]();
+      PromisePrototypeThen(handle.close(), () => cb(), cb);
+    },
+    fsync: (_fd, cb) => {
+      PromisePrototypeThen(handle.sync(), () => cb(), cb);
+    },
+    read: (_fd, buf, offset, length, pos, cb) => {
+      PromisePrototypeThen(
+        handle.read(buf, offset, length, pos),
+        // deno-lint-ignore prefer-primordials
+        (r) => cb(null, r.bytesRead, r.buffer),
+        (err) => cb(err, 0, buf),
+      );
+    },
+    write: (_fd, buf, offset, length, pos, cb) => {
+      PromisePrototypeThen(
+        handle.write(buf, offset, length, pos),
+        // deno-lint-ignore prefer-primordials
+        (r) => cb(null, r.bytesWritten, r.buffer),
+        (err) => cb(err, 0, buf),
+      );
+    },
+    writev: (_fd, buffers, pos, cb) => {
+      PromisePrototypeThen(
+        handle.writev(buffers, pos),
+        (r) => cb(null, r.bytesWritten, r.buffers),
+        (err) => cb(err, 0, buffers),
+      );
+    },
+  };
+};
 
 function close(stream, err, cb) {
   if (!stream.fd) {
@@ -99,23 +144,35 @@ function close(stream, err, cb) {
   }
 }
 
-// TODO(bartlomieju): looks like this impl is completely out of sync with Node.js...
 function importFd(stream, options) {
   if (typeof options.fd === "number") {
     // When fd is a raw descriptor, we must keep our fingers crossed
     // that the descriptor won't get closed, or worse, replaced with
     // another one
     // https://github.com/nodejs/node/issues/35862
-    if (ObjectPrototypeIsPrototypeOf(ReadStream.prototype, stream)) {
-      stream[kFs] = options.fs || fs;
-    }
-    if (ObjectPrototypeIsPrototypeOf(WriteStream.prototype, stream)) {
-      stream[kFs] = options.fs || fs;
-    }
+    stream[kFs] = options.fs || fs;
     return options.fd;
+  } else if (
+    typeof options.fd === "object" &&
+    ObjectPrototypeIsPrototypeOf(FileHandle.prototype, options.fd)
+  ) {
+    // When fd is a FileHandle we can listen for 'close' events
+    if (options.fs) {
+      // FileHandle is not supported with custom fs operations
+      throw new ERR_METHOD_NOT_IMPLEMENTED("FileHandle with fs");
+    }
+    stream[kHandle] = options.fd;
+    stream[kFs] = FileHandleOperations(stream[kHandle]);
+    stream[kHandle][kRef]();
+    options.fd.on("close", FunctionPrototypeBind(stream.close, stream));
+    return options.fd.fd;
   }
 
-  throw new ERR_INVALID_ARG_TYPE("options.fd", ["number"], options.fd);
+  throw new ERR_INVALID_ARG_TYPE(
+    "options.fd",
+    ["number", "FileHandle"],
+    options.fd,
+  );
 }
 
 export function ReadStream(path, options) {

filename: tests/node_compat/config.jsonc
patch: @@ -819,6 +819,7 @@
     "parallel/test-fs-read-stream-encoding.js": {},
     "parallel/test-fs-read-stream-fd-leak.js": {},
     "parallel/test-fs-read-stream-fd.js": {},
+    "parallel/test-fs-read-stream-file-handle.js": {},
     "parallel/test-fs-read-stream-inherit.js": {},
     "parallel/test-fs-read-stream-patch-open.js": {},
     "parallel/test-fs-read-stream-resume.js": {},
@@ -865,6 +866,8 @@
     "parallel/test-fs-write-no-fd.js": {},
     "parallel/test-fs-write-optional-params.js": {},
     "parallel/test-fs-write-stream-encoding.js": {},
+    "parallel/test-fs-write-stream-file-handle.js": {},
+    "parallel/test-fs-write-stream-file-handle-2.js": {},
     "parallel/test-fs-write-sync-optional-params.js": {},
     "parallel/test-fs-writev.js": {},
     "parallel/test-fs-writev-promises.js": {},