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
13 changes: 13 additions & 0 deletions src/js/node/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,12 @@ var access = function access(path, mode, callback) {
if (!callback) {
throw $ERR_INVALID_ARG_TYPE("callback", "function", callback);
}
// Validate `position`: must be null/undefined, a number, or a bigint.
// Mirrors Node.js `validatePosition` (lib/internal/fs/utils.js), which
// rejects objects, strings, booleans, etc. with TypeError.
if (position != null && typeof position !== "number" && typeof position !== "bigint") {
throw $ERR_INVALID_ARG_TYPE("position", ["integer", "bigint"], position);
}
Comment on lines +375 to +380
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In current Node.js, how do fs.read()andfs.readSync()validatepositionwhen it is1.5, NaN, Infinity, or -Infinity?

💡 Result:

In current Node.js (e.g., v25.x), both fs.read() and fs.readSync() run the same check:

  • If position == null (null or undefined), Node uses -1 (meaning “use & advance the current file position”).
  • Otherwise Node calls validatePosition(position, 'position', length), which for number positions calls validateInteger(position, 'position', -1). (raw.githubusercontent.com)
  • validateInteger() throws ERR_OUT_OF_RANGE if the value is not a JavaScript integer (Number.isInteger(value) must be true). (raw.githubusercontent.com)

So for the specific inputs (for both fs.read() and fs.readSync()):

position value Result
1.5 throws RangeError [ERR_OUT_OF_RANGE] (“position” must be an integer)
NaN throws RangeError [ERR_OUT_OF_RANGE] (“position” must be an integer)
Infinity throws RangeError [ERR_OUT_OF_RANGE] (“position” must be an integer)
-Infinity throws RangeError [ERR_OUT_OF_RANGE] (“position” must be an integer)

Citations:


Add integer and finite-value checks for numeric positions.

The validation currently accepts any number or bigint, but Node.js fs.read() and fs.readSync() reject fractional and non-finite numbers. For numeric position, add a check equivalent to Number.isInteger(position) to reject 1.5, NaN, Infinity, and -Infinity with ERR_OUT_OF_RANGE. Both paths (lines 375–380 and 583–588) share this gap; consider extracting a shared validatePosition helper to avoid duplication and ensure consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/js/node/fs.ts` around lines 375 - 380, The numeric `position` validation
currently permits non-integer and non-finite numbers; update the checks so that
when typeof position === "number" you also require Number.isInteger(position)
and isFinite(position), and if that fails throw ERR_OUT_OF_RANGE (not
ERR_INVALID_ARG_TYPE); extract a shared helper (e.g.,
validatePosition(position)) in src/js/node/fs.ts to encapsulate the
null/undefined, bigint, number, integer and finite checks and replace the
duplicated blocks used for fs.read()/fs.readSync() validation so both use the
new helper and preserve existing use of $ERR_INVALID_ARG_TYPE for wrong types
and ERR_OUT_OF_RANGE for out-of-range numeric values.

fs.read(fd, buffer, offset, length, position).then(
bytesRead => void callback(null, bytesRead, buffer),
err => callback(err),
Expand Down Expand Up @@ -574,6 +580,13 @@ var access = function access(path, mode, callback) {
({ offset = 0, length = buffer.byteLength - offset, position = null } = offsetOrOptions ?? {});
}

// Validate `position`: must be null/undefined, a number, or a bigint.
// Mirrors Node.js `validatePosition` (lib/internal/fs/utils.js), which
// rejects objects, strings, booleans, etc. with TypeError.
if (position != null && typeof position !== "number" && typeof position !== "bigint") {
throw $ERR_INVALID_ARG_TYPE("position", ["integer", "bigint"], position);
}

return fs.readSync(fd, buffer, offset, length, position);
},
writeSync = fs.writeSync.bind(fs),
Expand Down
39 changes: 26 additions & 13 deletions src/js/node/tty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ Object.defineProperty(ReadStream, "prototype", {
get() {
const Prototype = Object.create(fs.ReadStream.prototype);

// tty.ReadStream.prototype.isTTY must be `true` by convention, matching
// Node.js (lib/tty.js). Instance values set by the constructor still
// override this for fds that are not actually a TTY.
Prototype.isTTY = true;

// Add ref/unref methods to make tty.ReadStream behave like Node.js
// where TTY streams have socket-like behavior
Prototype.ref = function () {
Expand Down Expand Up @@ -124,10 +129,18 @@ function WriteStream(fd): void {

Object.defineProperty(WriteStream, "prototype", {
get() {
const Real = fs.WriteStream.prototype;
Object.defineProperty(WriteStream, "prototype", { value: Real });

WriteStream.prototype._refreshSize = function () {
// Use Object.create so the tty.WriteStream methods below do not leak onto
// `fs.WriteStream.prototype`. This also gives tty.WriteStream a distinct
// prototype that can own its own `isTTY` default without polluting fs.
const Prototype = Object.create(fs.WriteStream.prototype);
Object.defineProperty(WriteStream, "prototype", { value: Prototype });

// tty.WriteStream.prototype.isTTY must be `true` by convention, matching
// Node.js (lib/tty.js). Instance values set by the constructor still
// override this for fds that are not actually a TTY.
Prototype.isTTY = true;

Prototype._refreshSize = function () {
const oldCols = this.columns;
const oldRows = this.rows;
const windowSizeArray = [0, 0];
Expand All @@ -140,30 +153,30 @@ Object.defineProperty(WriteStream, "prototype", {
}
};

WriteStream.prototype.clearLine = function (dir, cb) {
Prototype.clearLine = function (dir, cb) {
return require("node:readline").clearLine(this, dir, cb);
};

WriteStream.prototype.clearScreenDown = function (cb) {
Prototype.clearScreenDown = function (cb) {
return require("node:readline").clearScreenDown(this, cb);
};

WriteStream.prototype.cursorTo = function (x, y, cb) {
Prototype.cursorTo = function (x, y, cb) {
return require("node:readline").cursorTo(this, x, y, cb);
};

// The `getColorDepth` API got inspired by multiple sources such as
// https://github.com/chalk/supports-color,
// https://github.com/isaacs/color-support.
WriteStream.prototype.getColorDepth = function (env = process.env) {
Prototype.getColorDepth = function (env = process.env) {
return require("internal/tty").getColorDepth(env);
};

WriteStream.prototype.getWindowSize = function () {
Prototype.getWindowSize = function () {
return [this.columns, this.rows];
};

WriteStream.prototype.hasColors = function (count, env) {
Prototype.hasColors = function (count, env) {
if (env === undefined && (count === undefined || (typeof count === "object" && count !== null))) {
env = count;
count = 16;
Expand All @@ -174,21 +187,21 @@ Object.defineProperty(WriteStream, "prototype", {
return count <= 2 ** this.getColorDepth(env);
};

WriteStream.prototype.moveCursor = function (dx, dy, cb) {
Prototype.moveCursor = function (dx, dy, cb) {
return require("node:readline").moveCursor(this, dx, dy, cb);
};

// Add Symbol.asyncIterator to make tty.WriteStream compatible with code
// that expects stdout/stderr to be async iterable (like in Node.js where they're Duplex)
WriteStream.prototype[Symbol.asyncIterator] = function () {
Prototype[Symbol.asyncIterator] = function () {
// Since WriteStream is write-only, we return an empty async iterator
// This matches the behavior of Node.js Duplex streams used for stdout/stderr
return (async function* () {
// stdout/stderr don't produce readable data, so yield nothing
})();
};

return Real;
return Prototype;
},
enumerable: true,
configurable: true,
Expand Down
6 changes: 6 additions & 0 deletions src/js/node/worker_threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ function injectFakeEmitter(Class) {

Class.prototype.prependListener = Class.prototype.on;
Class.prototype.prependOnceListener = Class.prototype.once;

// Node.js EventEmitter exposes `addListener`/`removeListener` as aliases of
// `on`/`off`. MessagePort in node:worker_threads is expected to implement
// the same surface, so code that checks `"removeListener" in port` works.
Class.prototype.addListener = Class.prototype.on;
Class.prototype.removeListener = Class.prototype.off;
Comment on lines +112 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

parentPort still shadows these aliases with DOM-style methods.

These prototype aliases fix real MessagePort instances, but fakeParentPort() later defines its own addListener / removeListener properties on the instance. Inside workers, that shadows the new aliases, so parentPort.addListener("message", fn) still uses raw DOM listener semantics and removeListener() will not remove handlers added via on().

Suggested follow-up
-  Object.defineProperty(fake, "removeListener", {
-    value: self.removeEventListener.bind(self),
-    enumerable: false,
-  });
-
-  Object.defineProperty(fake, "addListener", {
-    value: self.addEventListener.bind(self),
-    enumerable: false,
-  });
+  Object.defineProperty(fake, "removeListener", {
+    value: MessagePort.prototype.removeListener,
+    enumerable: false,
+  });
+
+  Object.defineProperty(fake, "addListener", {
+    value: MessagePort.prototype.addListener,
+    enumerable: false,
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/js/node/worker_threads.ts` around lines 112 - 116, The instance methods
defined by fakeParentPort are shadowing the prototype aliases
(Class.prototype.addListener and Class.prototype.removeListener), so
parentPort.addListener/removeListener still use DOM semantics and removeListener
can't remove handlers added via on/off; update fakeParentPort() to either stop
defining its own addListener/removeListener properties or replace them with
functions that delegate to the on/off methods (e.g., addListener -> this.on,
removeListener -> this.off), ensuring parentPort.addListener/removeListener use
the same behavior as Class.prototype.addListener/removeListener and that
removeListener correctly removes listeners added via on().

}

const _MessagePort = globalThis.MessagePort;
Expand Down
35 changes: 35 additions & 0 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,41 @@ describe("readSync", () => {
expect(readSync(2147483640, Buffer.alloc(0))).toBe(0);
expect(readSync(2147483640, Buffer.alloc(10), 0, 0, 0)).toBe(0);
});

it("throws TypeError when position is an object", () => {
// Matches Node.js `validatePosition` (lib/internal/fs/utils.js):
// non-null `position` must be a number or bigint, never a plain object.
const dest = join(tmpdir(), "readSync-invalid-position.txt");
rmSync(dest, { force: true });
writeFileSync(dest, "");
const fd = openSync(dest, "r+");
try {
const buf = new Uint8Array(0);
expect(() => readSync(fd, buf, 0, buf.length, { not: "a number" } as any)).toThrow(TypeError);
expect(() => readSync(fd, buf, 0, buf.length, "0" as any)).toThrow(TypeError);
expect(() => readSync(fd, buf, 0, buf.length, true as any)).toThrow(TypeError);
} finally {
closeSync(fd);
rmSync(dest, { force: true });
}
});

it("accepts null, undefined, number, and bigint for position", () => {
const dest = join(tmpdir(), "readSync-valid-position.txt");
rmSync(dest, { force: true });
writeFileSync(dest, "Hello");
const fd = openSync(dest, "r");
try {
const buf = Buffer.alloc(5);
expect(() => readSync(fd, buf, 0, 5, null)).not.toThrow();
expect(() => readSync(fd, buf, 0, 5, undefined)).not.toThrow();
expect(() => readSync(fd, buf, 0, 5, 0)).not.toThrow();
expect(() => readSync(fd, buf, 0, 5, 0n as any)).not.toThrow();
} finally {
closeSync(fd);
rmSync(dest, { force: true });
}
});
});

it("writevSync", () => {
Expand Down
38 changes: 37 additions & 1 deletion test/js/node/tty.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import { isWindows } from "harness";
import { WriteStream } from "node:tty";
import fs from "node:fs";
import { ReadStream, WriteStream } from "node:tty";

describe("WriteStream.prototype.getColorDepth", () => {
it("iTerm ancient", () => {
Expand All @@ -24,3 +25,38 @@ describe("WriteStream.prototype.getColorDepth", () => {
expect(WriteStream.prototype.getColorDepth.call(undefined, {})).toBe(isWindows ? 24 : 1);
});
});

describe("tty.{ReadStream,WriteStream}.prototype.isTTY", () => {
it("WriteStream.prototype.isTTY is true", () => {
// Matches Node.js: `tty.WriteStream.prototype.isTTY === true`.
expect(WriteStream.prototype.isTTY).toBe(true);
});

it("ReadStream.prototype.isTTY is true", () => {
// Matches Node.js: `tty.ReadStream.prototype.isTTY === true`.
expect(ReadStream.prototype.isTTY).toBe(true);
});

it("does not pollute fs.WriteStream.prototype", () => {
// tty.WriteStream inherits from fs.WriteStream but must not add tty-only
// members to fs.WriteStream.prototype. `isTTY` and the cursor helpers
// should only exist on the tty subclass prototype.
expect(Object.hasOwn(fs.WriteStream.prototype, "isTTY")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "clearLine")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "clearScreenDown")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "cursorTo")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "getColorDepth")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "hasColors")).toBe(false);
expect(Object.hasOwn(fs.WriteStream.prototype, "moveCursor")).toBe(false);

// fs.WriteStream instances must not inherit isTTY from anywhere either.
expect((fs.WriteStream.prototype as any).isTTY).toBeUndefined();
});

it("tty.WriteStream.prototype is distinct from fs.WriteStream.prototype", () => {
expect(WriteStream.prototype).not.toBe(fs.WriteStream.prototype);
// ...but still inherits from it, so `instanceof fs.WriteStream` holds for
// tty.WriteStream instances.
expect(Object.getPrototypeOf(WriteStream.prototype)).toBe(fs.WriteStream.prototype);
});
});
51 changes: 51 additions & 0 deletions test/js/node/worker_threads/worker_threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,57 @@ test("all worker_threads worker instance properties are present", async () => {
await worker.terminate();
});

test("MessagePort exposes full EventEmitter alias surface", () => {
// Regression test for oven-sh/bun#29022. MessagePort.prototype was missing
// `addListener`/`removeListener` aliases, so code that does
// `if ("removeListener" in port)` (e.g. undici, node-fetch) would
// incorrectly skip the cleanup path.
const { port1, port2 } = new MessageChannel();
try {
for (const name of [
"on",
"off",
"once",
"emit",
"addListener",
"removeListener",
"prependListener",
"prependOnceListener",
] as const) {
expect(name in port1).toBe(true);
expect(typeof port1[name]).toBe("function");
}

// `addListener`/`removeListener` must be live aliases of `on`/`off`, as in
// Node.js EventEmitter, so a listener attached via one name can be removed
// via the other.
let received = 0;
const handler = () => {
received++;
};
port1.addListener("message", handler);
port2.postMessage("hello");
// Give the event loop a tick so the message is delivered.
return new Promise<void>(resolve => {
setTimeout(() => {
expect(received).toBe(1);
port1.removeListener("message", handler);
port2.postMessage("world");
setTimeout(() => {
expect(received).toBe(1);
port1.close();
port2.close();
resolve();
}, 10);
}, 10);
});
} catch (err) {
port1.close();
port2.close();
throw err;
}
});
Comment on lines +135 to +184
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace the timer-based waits with event awaits.

The two setTimeout() calls make this regression test timing-dependent, and a failed expect() inside those callbacks will not fail the returned promise as cleanly as an awaited event path. await once(port1, "message") for each postMessage() would make the test deterministic and keep failures attached to the test itself.

Proposed rewrite
-test("MessagePort exposes full EventEmitter alias surface", () => {
+test("MessagePort exposes full EventEmitter alias surface", async () => {
   const { port1, port2 } = new MessageChannel();
   try {
     for (const name of [
       "on",
       "off",
       "once",
       "emit",
       "addListener",
       "removeListener",
       "prependListener",
       "prependOnceListener",
     ] as const) {
       expect(name in port1).toBe(true);
       expect(typeof port1[name]).toBe("function");
     }

     let received = 0;
     const handler = () => {
       received++;
     };
     port1.addListener("message", handler);
-    port2.postMessage("hello");
-    // Give the event loop a tick so the message is delivered.
-    return new Promise<void>(resolve => {
-      setTimeout(() => {
-        expect(received).toBe(1);
-        port1.removeListener("message", handler);
-        port2.postMessage("world");
-        setTimeout(() => {
-          expect(received).toBe(1);
-          port1.close();
-          port2.close();
-          resolve();
-        }, 10);
-      }, 10);
-    });
-  } catch (err) {
+    const firstMessage = once(port1, "message");
+    port2.postMessage("hello");
+    await firstMessage;
+    expect(received).toBe(1);
+
+    port1.removeListener("message", handler);
+
+    const secondMessage = once(port1, "message");
+    port2.postMessage("world");
+    await secondMessage;
+    expect(received).toBe(1);
+  } finally {
     port1.close();
     port2.close();
-    throw err;
   }
 });

As per coding guidelines: Do not use setTimeout in tests; instead await the condition to be met as you are testing the CONDITION, not TIME PASSING.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("MessagePort exposes full EventEmitter alias surface", () => {
// Regression test for oven-sh/bun#29022. MessagePort.prototype was missing
// `addListener`/`removeListener` aliases, so code that does
// `if ("removeListener" in port)` (e.g. undici, node-fetch) would
// incorrectly skip the cleanup path.
const { port1, port2 } = new MessageChannel();
try {
for (const name of [
"on",
"off",
"once",
"emit",
"addListener",
"removeListener",
"prependListener",
"prependOnceListener",
] as const) {
expect(name in port1).toBe(true);
expect(typeof port1[name]).toBe("function");
}
// `addListener`/`removeListener` must be live aliases of `on`/`off`, as in
// Node.js EventEmitter, so a listener attached via one name can be removed
// via the other.
let received = 0;
const handler = () => {
received++;
};
port1.addListener("message", handler);
port2.postMessage("hello");
// Give the event loop a tick so the message is delivered.
return new Promise<void>(resolve => {
setTimeout(() => {
expect(received).toBe(1);
port1.removeListener("message", handler);
port2.postMessage("world");
setTimeout(() => {
expect(received).toBe(1);
port1.close();
port2.close();
resolve();
}, 10);
}, 10);
});
} catch (err) {
port1.close();
port2.close();
throw err;
}
});
test("MessagePort exposes full EventEmitter alias surface", async () => {
// Regression test for oven-sh/bun#29022. MessagePort.prototype was missing
// `addListener`/`removeListener` aliases, so code that does
// `if ("removeListener" in port)` (e.g. undici, node-fetch) would
// incorrectly skip the cleanup path.
const { port1, port2 } = new MessageChannel();
try {
for (const name of [
"on",
"off",
"once",
"emit",
"addListener",
"removeListener",
"prependListener",
"prependOnceListener",
] as const) {
expect(name in port1).toBe(true);
expect(typeof port1[name]).toBe("function");
}
// `addListener`/`removeListener` must be live aliases of `on`/`off`, as in
// Node.js EventEmitter, so a listener attached via one name can be removed
// via the other.
let received = 0;
const handler = () => {
received++;
};
port1.addListener("message", handler);
const firstMessage = once(port1, "message");
port2.postMessage("hello");
await firstMessage;
expect(received).toBe(1);
port1.removeListener("message", handler);
const secondMessage = once(port1, "message");
port2.postMessage("world");
await secondMessage;
expect(received).toBe(1);
} finally {
port1.close();
port2.close();
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/worker_threads/worker_threads.test.ts` around lines 135 - 184,
The test currently uses setTimeout callbacks after postMessage to wait for
delivery which makes it timing-dependent; replace both timer-based waits with
awaiting the "message" event via once(port1, "message") (import once from
"events") after each postMessage so the assertions run after the awaited event,
then perform removeListener and postMessage again and await once to verify no
additional delivery; keep the port1.close()/port2.close() cleanup (still in the
catch or finally) and convert the test to async so you can await the once()
calls directly.


test("threadId module and worker property is consistent", async () => {
const worker1 = new Worker(new URL("./worker-thread-id.ts", import.meta.url).href);
expect(threadId).toBe(0);
Expand Down