Skip to content

Commit

Permalink
fix(node/process): null is not returned when reaching end-of-file in …
Browse files Browse the repository at this point in the history
…stdin (#3113)
  • Loading branch information
PolarETech authored Feb 3, 2023
1 parent 0170bc2 commit e6592e4
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 19 deletions.
138 changes: 119 additions & 19 deletions node/_process/streams.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
cursorTo,
moveCursor,
} from "../internal/readline/callbacks.mjs";
import { Readable, Writable } from "../stream.ts";
import { Duplex, Readable, Writable } from "../stream.ts";
import { stdio } from "./stdio.mjs";
import { fs as fsConstants } from "../internal_binding/constants.ts";

// https://github.com/nodejs/node/blob/00738314828074243c9a52a228ab4c68b04259ef/lib/internal/bootstrap/switches/is_main_thread.js#L41
function createWritableStdioStream(writer, name) {
Expand Down Expand Up @@ -101,27 +102,126 @@ export const stdout = stdio.stdout = createWritableStdioStream(
"stdout",
);

// TODO(PolarETech): This function should be replaced by
// `guessHandleType()` in "../internal_binding/util.ts".
// https://github.com/nodejs/node/blob/v18.12.1/src/node_util.cc#L257
function _guessStdinType(fd) {
if (typeof fd !== "number" || fd < 0) return "UNKNOWN";
if (Deno.isatty?.(fd)) return "TTY";

try {
const fileInfo = Deno.fstatSync?.(fd);

// https://github.com/nodejs/node/blob/v18.12.1/deps/uv/src/unix/tty.c#L333
if (Deno.build.os !== "windows") {
switch (fileInfo.mode & fsConstants.S_IFMT) {
case fsConstants.S_IFREG:
case fsConstants.S_IFCHR:
return "FILE";
case fsConstants.S_IFIFO:
return "PIPE";
case fsConstants.S_IFSOCK:
// TODO(PolarETech): Need a better way to identify "TCP".
// Currently, unable to exclude UDP.
return "TCP";
default:
return "UNKNOWN";
}
}

// https://github.com/nodejs/node/blob/v18.12.1/deps/uv/src/win/handle.c#L31
if (fileInfo.isFile) {
// TODO(PolarETech): Need a better way to identify a piped stdin on Windows.
// On Windows, `Deno.fstatSync(rid).isFile` returns true even for a piped stdin.
// Therefore, a piped stdin cannot be distinguished from a file by this property.
// The mtime, atime, and birthtime of the file are "2339-01-01T00:00:00.000Z",
// so use the property as a workaround.
if (fileInfo.birthtime.valueOf() === 11644473600000) return "PIPE";
return "FILE";
}
} catch (e) {
// TODO(PolarETech): Need a better way to identify a character file on Windows.
// "EISDIR" error occurs when stdin is "null" on Windows,
// so use the error as a workaround.
if (Deno.build.os === "windows" && e.code === "EISDIR") return "FILE";
}

return "UNKNOWN";
}

const _read = function (size) {
const p = Buffer.alloc(size || 16 * 1024);
Deno.stdin?.read(p).then((length) => {
this.push(length === null ? null : p.slice(0, length));
}, (error) => {
this.destroy(error);
});
};

/** https://nodejs.org/api/process.html#process_process_stdin */
export const stdin = stdio.stdin = new Readable({
highWaterMark: 0,
emitClose: false,
read(size) {
const p = Buffer.alloc(size || 16 * 1024);

if (!Deno.stdin) {
this.destroy(
new Error("Deno.stdin is not available in this environment"),
);
return;
// https://github.com/nodejs/node/blob/v18.12.1/lib/internal/bootstrap/switches/is_main_thread.js#L189
export const stdin = stdio.stdin = (() => {
const fd = Deno.stdin?.rid;
let _stdin;
const stdinType = _guessStdinType(fd);

switch (stdinType) {
case "FILE": {
// Since `fs.ReadStream` cannot be imported before process initialization,
// use `Readable` instead.
// https://github.com/nodejs/node/blob/v18.12.1/lib/internal/bootstrap/switches/is_main_thread.js#L200
// https://github.com/nodejs/node/blob/v18.12.1/lib/internal/fs/streams.js#L148
_stdin = new Readable({
highWaterMark: 64 * 1024,
autoDestroy: false,
read: _read,
});
break;
}
case "TTY":
case "PIPE":
case "TCP": {
// TODO(PolarETech):
// For TTY, `new Duplex()` should be replaced `new tty.ReadStream()` if possible.
// There are two problems that need to be resolved.
// 1. Using them here introduces a circular dependency.
// 2. Creating a tty.ReadStream() is not currently supported.
// https://github.com/nodejs/node/blob/v18.12.1/lib/internal/bootstrap/switches/is_main_thread.js#L194
// https://github.com/nodejs/node/blob/v18.12.1/lib/tty.js#L47

Deno.stdin.read(p).then((length) => {
this.push(length === null ? null : p.slice(0, length));
}, (error) => {
this.destroy(error);
});
},
});
// For PIPE and TCP, `new Duplex()` should be replaced `new net.Socket()` if possible.
// There are two problems that need to be resolved.
// 1. Using them here introduces a circular dependency.
// 2. Creating a net.Socket() from a fd is not currently supported.
// https://github.com/nodejs/node/blob/v18.12.1/lib/internal/bootstrap/switches/is_main_thread.js#L206
// https://github.com/nodejs/node/blob/v18.12.1/lib/net.js#L329
_stdin = new Duplex({
readable: stdinType === "TTY" ? undefined : true,
writable: stdinType === "TTY" ? undefined : false,
readableHighWaterMark: stdinType === "TTY" ? 0 : undefined,
allowHalfOpen: false,
emitClose: false,
autoDestroy: true,
decodeStrings: false,
read: _read,
});

if (stdinType !== "TTY") {
// Make sure the stdin can't be `.end()`-ed
_stdin._writableState.ended = true;
}
break;
}
default: {
// Provide a dummy contentless input for e.g. non-console
// Windows applications.
_stdin = new Readable({ read() {} });
_stdin.push(null);
}
}

return _stdin;
})();
stdin.on("close", () => Deno.stdin?.close());
stdin.fd = Deno.stdin?.rid ?? -1;
Object.defineProperty(stdin, "isTTY", {
Expand Down
161 changes: 161 additions & 0 deletions node/process_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,167 @@ Deno.test({
},
});

Deno.test({
name: "process.stdin readable with a TTY",
// TODO(PolarETech): Run this test even in non tty environment
ignore: !Deno.isatty(Deno.stdin.rid),
async fn() {
const promise = deferred();
const expected = ["foo", "bar", null, "end"];
const data: (string | null)[] = [];

process.stdin.setEncoding("utf8");
process.stdin.on("readable", () => {
data.push(process.stdin.read());
});
process.stdin.on("end", () => {
data.push("end");
});

process.stdin.push("foo");
process.nextTick(() => {
process.stdin.push("bar");
process.nextTick(() => {
process.stdin.push(null);
promise.resolve();
});
});

await promise;
assertEquals(process.stdin.readableHighWaterMark, 0);
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdin readable with piping a file",
async fn() {
const expected = ["65536", "foo", "bar", "null", "end"];
const scriptPath = "./node/testdata/process_stdin.ts";
const filePath = "./node/testdata/process_stdin_dummy.txt";

const shell = Deno.build.os === "windows" ? "cmd.exe" : "/bin/sh";
const cmd = `"${Deno.execPath()}" run ${scriptPath} < ${filePath}`;
const args = Deno.build.os === "windows" ? ["/d", "/c", cmd] : ["-c", cmd];

const p = new Deno.Command(shell, {
args,
stdin: "null",
stdout: "piped",
stderr: "null",
windowsRawArguments: true,
});

const { stdout } = await p.output();
const data = new TextDecoder().decode(stdout).trim().split("\n");
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdin readable with piping a stream",
async fn() {
const expected = ["16384", "foo", "bar", "null", "end"];
const scriptPath = "./node/testdata/process_stdin.ts";

const command = new Deno.Command(Deno.execPath(), {
args: ["run", scriptPath],
stdin: "piped",
stdout: "piped",
stderr: "null",
});
const child = command.spawn();

const writer = await child.stdin.getWriter();
writer.ready
.then(() => writer.write(new TextEncoder().encode("foo\nbar")))
.then(() => writer.releaseLock())
.then(() => child.stdin.close());

const { stdout } = await child.output();
const data = new TextDecoder().decode(stdout).trim().split("\n");
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdin readable with piping a socket",
ignore: Deno.build.os === "windows",
async fn() {
const expected = ["16384", "foo", "bar", "null", "end"];
const scriptPath = "./node/testdata/process_stdin.ts";

const listener = Deno.listen({ hostname: "127.0.0.1", port: 9000 });
listener.accept().then(async (conn) => {
await conn.write(new TextEncoder().encode("foo\nbar"));
conn.close();
listener.close();
});

const shell = "/bin/bash";
const cmd =
`"${Deno.execPath()}" run ${scriptPath} < /dev/tcp/127.0.0.1/9000`;
const args = ["-c", cmd];

const p = new Deno.Command(shell, {
args,
stdin: "null",
stdout: "piped",
stderr: "null",
});

const { stdout } = await p.output();
const data = new TextDecoder().decode(stdout).trim().split("\n");
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdin readable with null",
async fn() {
const expected = ["65536", "null", "end"];
const scriptPath = "./node/testdata/process_stdin.ts";

const command = new Deno.Command(Deno.execPath(), {
args: ["run", scriptPath],
stdin: "null",
stdout: "piped",
stderr: "null",
});

const { stdout } = await command.output();
const data = new TextDecoder().decode(stdout).trim().split("\n");
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdin readable with unsuitable stdin",
// TODO(PolarETech): Prepare a similar test that can be run on Windows
ignore: Deno.build.os === "windows",
async fn() {
const expected = ["16384", "null", "end"];
const scriptPath = "./node/testdata/process_stdin.ts";
const directoryPath = "./node/testdata/";

const shell = "/bin/bash";
const cmd = `"${Deno.execPath()}" run ${scriptPath} < ${directoryPath}`;
const args = ["-c", cmd];

const p = new Deno.Command(shell, {
args,
stdin: "null",
stdout: "piped",
stderr: "null",
windowsRawArguments: true,
});

const { stdout } = await p.output();
const data = new TextDecoder().decode(stdout).trim().split("\n");
assertEquals(data, expected);
},
});

Deno.test({
name: "process.stdout",
fn() {
Expand Down
11 changes: 11 additions & 0 deletions node/testdata/process_stdin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import process from "../process.ts";

console.log(process.stdin.readableHighWaterMark);

process.stdin.setEncoding("utf8");
process.stdin.on("readable", () => {
console.log(process.stdin.read());
});
process.stdin.on("end", () => {
console.log("end");
});
2 changes: 2 additions & 0 deletions node/testdata/process_stdin_dummy.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
foo
bar

0 comments on commit e6592e4

Please sign in to comment.