From 52dc3ef1a4db10ce35345c84e07bbae0c463ba18 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Sun, 13 Nov 2022 20:00:24 +0100 Subject: [PATCH] feat(unstable): "Deno.Command()" API (#16516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartek Iwańczuk --- cli/diagnostics.rs | 6 + cli/dts/lib.deno.unstable.d.ts | 209 +++++++- cli/tests/unit/command_test.ts | 844 +++++++++++++++++++++++++++++++++ runtime/js/40_spawn.js | 128 +++++ runtime/js/90_deno_ns.js | 1 + 5 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 cli/tests/unit/command_test.ts diff --git a/cli/diagnostics.rs b/cli/diagnostics.rs index 69ff8ae251..05502dca46 100644 --- a/cli/diagnostics.rs +++ b/cli/diagnostics.rs @@ -33,8 +33,14 @@ const UNSTABLE_DENO_PROPS: &[&str] = &[ "Child", "spawn", "spawnSync", + "SpawnOptions", "ChildStatus", "SpawnOutput", + "command", + "Command", + "CommandOptions", + "CommandStatus", + "CommandOutput", "serve", "ServeInit", "ServeTlsInit", diff --git a/cli/dts/lib.deno.unstable.d.ts b/cli/dts/lib.deno.unstable.d.ts index f193563931..12a02d457d 100644 --- a/cli/dts/lib.deno.unstable.d.ts +++ b/cli/dts/lib.deno.unstable.d.ts @@ -1395,6 +1395,8 @@ declare namespace Deno { export function upgradeHttpRaw(request: Request): [Deno.Conn, Uint8Array]; /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * Options which can be set when calling {@linkcode Deno.spawn}, * {@linkcode Deno.spawnSync}, and {@linkcode Deno.spawnChild}. @@ -1454,6 +1456,8 @@ declare namespace Deno { } /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * Spawns a child process. * @@ -1488,6 +1492,8 @@ declare namespace Deno { ): Child; /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * The interface for handling a child process returned from * {@linkcode Deno.spawnChild}. @@ -1518,6 +1524,8 @@ declare namespace Deno { } /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * Executes a subprocess, waiting for it to finish and collecting all of its * output. @@ -1531,7 +1539,7 @@ declare namespace Deno { * const { code, stdout, stderr } = await Deno.spawn(Deno.execPath(), { * args: [ * "eval", - * "console.log('hello'); console.error('world')", + * "console.log('hello'); console.error('world')", * ], * }); * console.assert(code === 0); @@ -1547,6 +1555,8 @@ declare namespace Deno { ): Promise; /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * Synchronously executes a subprocess, waiting for it to finish and * collecting all of its output. @@ -1560,7 +1570,7 @@ declare namespace Deno { * const { code, stdout, stderr } = Deno.spawnSync(Deno.execPath(), { * args: [ * "eval", - * "console.log('hello'); console.error('world')", + * "console.log('hello'); console.error('world')", * ], * }); * console.assert(code === 0); @@ -1576,6 +1586,8 @@ declare namespace Deno { ): SpawnOutput; /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * @category Sub Process */ @@ -1591,6 +1603,8 @@ declare namespace Deno { } /** **UNSTABLE**: New API, yet to be vetted. + * + * @deprecated Use the Deno.Command API instead. * * The interface returned from calling {@linkcode Deno.spawn} or * {@linkcode Deno.spawnSync} which represents the result of spawning the @@ -1604,6 +1618,197 @@ declare namespace Deno { /** The buffered output from the child processes `stderr`. */ readonly stderr: Uint8Array; } + + /** **UNSTABLE**: New API, yet to be vetted. + * + * Create a child process. + * + * If any stdio options are not set to `"piped"`, accessing the corresponding + * field on the `Command` or its `CommandOutput` will throw a `TypeError`. + * + * If `stdin` is set to `"piped"`, the `stdin` {@linkcode WritableStream} + * needs to be closed manually. + * + * ```ts + * const command = new Deno.Command(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('Hello World')", + * ], + * stdin: "piped", + * }); + * command.spawn(); + * + * // open a file and pipe the subprocess output to it. + * command.stdout.pipeTo(Deno.openSync("output").writable); + * + * // manually close stdin + * command.stdin.close(); + * const status = await command.status; + * ``` + * + * ```ts + * const command = new Deno.Command(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('hello'); console.error('world')", + * ], + * }); + * const { code, stdout, stderr } = await command.output(); + * console.assert(code === 0); + * console.assert("hello\n" === new TextDecoder().decode(stdout)); + * console.assert("world\n" === new TextDecoder().decode(stderr)); + * ``` + * + * ```ts + * const command = new Deno.Command(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('hello'); console.error('world')", + * ], + * }); + * const { code, stdout, stderr } = command.outputSync(); + * console.assert(code === 0); + * console.assert("hello\n" === new TextDecoder().decode(stdout)); + * console.assert("world\n" === new TextDecoder().decode(stderr)); + * ``` + * + * @category Sub Process + */ + export class Command { + get stdin(): WritableStream; + get stdout(): ReadableStream; + get stderr(): ReadableStream; + readonly pid: number; + /** Get the status of the child process. */ + readonly status: Promise; + + constructor(command: string | URL, options?: CommandOptions); + /** + * Executes the {@linkcode Deno.Command}, waiting for it to finish and + * collecting all of its output. + * If `spawn()` was called, calling this function will collect the remaining + * output. + * + * Will throw an error if `stdin: "piped"` is set. + * + * If options `stdout` or `stderr` are not set to `"piped"`, accessing the + * corresponding field on {@linkcode Deno.CommandOutput} will throw a `TypeError`. + */ + output(): Promise; + /** + * Synchronously executes the {@linkcode Deno.Command}, waiting for it to + * finish and collecting all of its output. + * + * Will throw an error if `stdin: "piped"` is set. + * + * If options `stdout` or `stderr` are not set to `"piped"`, accessing the + * corresponding field on {@linkcode Deno.CommandOutput} will throw a `TypeError`. + */ + outputSync(): CommandOutput; + /** + * Spawns a streamable subprocess, allowing to use the other methods. + */ + spawn(): void; + + /** Kills the process with given {@linkcode Deno.Signal}. Defaults to + * `"SIGTERM"`. */ + kill(signo?: Signal): void; + + /** Ensure that the status of the child process prevents the Deno process + * from exiting. */ + ref(): void; + /** Ensure that the status of the child process does not block the Deno + * process from exiting. */ + unref(): void; + } + + /** **UNSTABLE**: New API, yet to be vetted. + * + * Options which can be set when calling {@linkcode Deno.command}. + * + * @category Sub Process + */ + export interface CommandOptions { + /** Arguments to pass to the process. */ + args?: string[]; + /** + * The working directory of the process. + * + * If not specified, the `cwd` of the parent process is used. + */ + cwd?: string | URL; + /** + * Clear environmental variables from parent process. + * + * Doesn't guarantee that only `env` variables are present, as the OS may + * set environmental variables for processes. + */ + clearEnv?: boolean; + /** Environmental variables to pass to the subprocess. */ + env?: Record; + /** + * Sets the child process’s user ID. This translates to a setuid call in the + * child process. Failure in the set uid call will cause the spawn to fail. + */ + uid?: number; + /** Similar to `uid`, but sets the group ID of the child process. */ + gid?: number; + /** + * An {@linkcode AbortSignal} that allows closing the process using the + * corresponding {@linkcode AbortController} by sending the process a + * SIGTERM signal. + * + * Ignored by {@linkcode Command.outputSync}. + */ + signal?: AbortSignal; + + /** How `stdin` of the spawned process should be handled. + * + * Defaults to `"null"`. */ + stdin?: "piped" | "inherit" | "null"; + /** How `stdout` of the spawned process should be handled. + * + * Defaults to `"piped"`. */ + stdout?: "piped" | "inherit" | "null"; + /** How `stderr` of the spawned process should be handled. + * + * Defaults to "piped". */ + stderr?: "piped" | "inherit" | "null"; + + /** Skips quoting and escaping of the arguments on Windows. This option + * is ignored on non-windows platforms. Defaults to `false`. */ + windowsRawArguments?: boolean; + } + + /** **UNSTABLE**: New API, yet to be vetted. + * + * @category Sub Process + */ + export interface CommandStatus { + /** If the child process exits with a 0 status code, `success` will be set + * to `true`, otherwise `false`. */ + success: boolean; + /** The exit code of the child process. */ + code: number; + /** The signal associated with the child process. */ + signal: Signal | null; + } + + /** **UNSTABLE**: New API, yet to be vetted. + * + * The interface returned from calling {@linkcode Command.output} or + * {@linkcode Command.outputSync} which represents the result of spawning the + * child process. + * + * @category Sub Process + */ + export interface CommandOutput extends ChildStatus { + /** The buffered output from the child process' `stdout`. */ + readonly stdout: Uint8Array; + /** The buffered output from the child process' `stderr`. */ + readonly stderr: Uint8Array; + } } /** **UNSTABLE**: New API, yet to be vetted. diff --git a/cli/tests/unit/command_test.ts b/cli/tests/unit/command_test.ts new file mode 100644 index 0000000000..e49cdc46c3 --- /dev/null +++ b/cli/tests/unit/command_test.ts @@ -0,0 +1,844 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +import { + assert, + assertEquals, + assertRejects, + assertStringIncludes, + assertThrows, +} from "./test_util.ts"; + +Deno.test( + { permissions: { write: true, run: true, read: true } }, + async function commandWithCwdIsAsync() { + const enc = new TextEncoder(); + const cwd = await Deno.makeTempDir({ prefix: "deno_command_test" }); + + const exitCodeFile = "deno_was_here"; + const programFile = "poll_exit.ts"; + const program = ` +async function tryExit() { + try { + const code = parseInt(await Deno.readTextFile("${exitCodeFile}")); + Deno.exit(code); + } catch { + // Retry if we got here before deno wrote the file. + setTimeout(tryExit, 0.01); + } +} + +tryExit(); +`; + + Deno.writeFileSync(`${cwd}/${programFile}`, enc.encode(program)); + + const child = new Deno.Command(Deno.execPath(), { + cwd, + args: ["run", "--allow-read", programFile], + stdout: "inherit", + stderr: "inherit", + }); + child.spawn(); + + // Write the expected exit code *after* starting deno. + // This is how we verify that `Child` is actually asynchronous. + const code = 84; + Deno.writeFileSync(`${cwd}/${exitCodeFile}`, enc.encode(`${code}`)); + + const status = await child.status; + await Deno.remove(cwd, { recursive: true }); + assertEquals(status.success, false); + assertEquals(status.code, code); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandStdinPiped() { + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "if (new TextDecoder().decode(await Deno.readAll(Deno.stdin)) !== 'hello') throw new Error('Expected \\'hello\\'')", + ], + stdin: "piped", + stdout: "null", + stderr: "null", + }); + child.spawn(); + + assertThrows(() => child.stdout, TypeError, "stdout is not piped"); + assertThrows(() => child.stderr, TypeError, "stderr is not piped"); + + const msg = new TextEncoder().encode("hello"); + const writer = child.stdin.getWriter(); + await writer.write(msg); + writer.releaseLock(); + + await child.stdin.close(); + const status = await child.status; + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandStdoutPiped() { + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + stderr: "null", + }); + child.spawn(); + + assertThrows(() => child.stdin, TypeError, "stdin is not piped"); + assertThrows(() => child.stderr, TypeError, "stderr is not piped"); + + const readable = child.stdout.pipeThrough(new TextDecoderStream()); + const reader = readable.getReader(); + const res = await reader.read(); + assert(!res.done); + assertEquals(res.value, "hello"); + + const resEnd = await reader.read(); + assert(resEnd.done); + assertEquals(resEnd.value, undefined); + reader.releaseLock(); + + const status = await child.status; + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandStderrPiped() { + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('hello'))", + ], + stdout: "null", + }); + child.spawn(); + + assertThrows(() => child.stdin, TypeError, "stdin is not piped"); + assertThrows(() => child.stdout, TypeError, "stdout is not piped"); + + const readable = child.stderr.pipeThrough(new TextDecoderStream()); + const reader = readable.getReader(); + const res = await reader.read(); + assert(!res.done); + assertEquals(res.value, "hello"); + + const resEnd = await reader.read(); + assert(resEnd.done); + assertEquals(resEnd.value, undefined); + reader.releaseLock(); + + const status = await child.status; + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, write: true, read: true } }, + async function commandRedirectStdoutStderr() { + const tempDir = await Deno.makeTempDir(); + const fileName = tempDir + "/redirected_stdio.txt"; + const file = await Deno.open(fileName, { + create: true, + write: true, + }); + + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "Deno.stderr.write(new TextEncoder().encode('error\\n')); Deno.stdout.write(new TextEncoder().encode('output\\n'));", + ], + }); + child.spawn(); + await child.stdout.pipeTo(file.writable, { + preventClose: true, + }); + await child.stderr.pipeTo(file.writable); + await child.status; + + const fileContents = await Deno.readFile(fileName); + const decoder = new TextDecoder(); + const text = decoder.decode(fileContents); + + assertStringIncludes(text, "error"); + assertStringIncludes(text, "output"); + }, +); + +Deno.test( + { permissions: { run: true, write: true, read: true } }, + async function commandRedirectStdin() { + const tempDir = await Deno.makeTempDir(); + const fileName = tempDir + "/redirected_stdio.txt"; + const encoder = new TextEncoder(); + await Deno.writeFile(fileName, encoder.encode("hello")); + const file = await Deno.open(fileName); + + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "if (new TextDecoder().decode(await Deno.readAll(Deno.stdin)) !== 'hello') throw new Error('Expected \\'hello\\'')", + ], + stdin: "piped", + stdout: "null", + stderr: "null", + }); + child.spawn(); + await file.readable.pipeTo(child.stdin, { + preventClose: true, + }); + + await child.stdin.close(); + const status = await child.status; + assertEquals(status.code, 0); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandKillSuccess() { + const child = new Deno.Command(Deno.execPath(), { + args: ["eval", "setTimeout(() => {}, 10000)"], + stdout: "null", + stderr: "null", + }); + child.spawn(); + + child.kill("SIGKILL"); + const status = await child.status; + + assertEquals(status.success, false); + if (Deno.build.os === "windows") { + assertEquals(status.code, 1); + assertEquals(status.signal, null); + } else { + assertEquals(status.code, 137); + assertEquals(status.signal, "SIGKILL"); + } + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandKillFailed() { + const child = new Deno.Command(Deno.execPath(), { + args: ["eval", "setTimeout(() => {}, 5000)"], + stdout: "null", + stderr: "null", + }); + child.spawn(); + + assertThrows(() => { + // @ts-expect-error testing runtime error of bad signal + child.kill("foobar"); + }, TypeError); + + await child.status; + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandKillOptional() { + const child = new Deno.Command(Deno.execPath(), { + args: ["eval", "setTimeout(() => {}, 10000)"], + stdout: "null", + stderr: "null", + }); + child.spawn(); + + child.kill(); + const status = await child.status; + + assertEquals(status.success, false); + if (Deno.build.os === "windows") { + assertEquals(status.code, 1); + assertEquals(status.signal, null); + } else { + assertEquals(status.code, 143); + assertEquals(status.signal, "SIGTERM"); + } + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandAbort() { + const ac = new AbortController(); + const child = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "setTimeout(console.log, 1e8)", + ], + signal: ac.signal, + stdout: "null", + stderr: "null", + }); + child.spawn(); + queueMicrotask(() => ac.abort()); + const status = await child.status; + assertEquals(status.success, false); + if (Deno.build.os === "windows") { + assertEquals(status.code, 1); + assertEquals(status.signal, null); + } else { + assertEquals(status.success, false); + assertEquals(status.code, 143); + } + }, +); + +Deno.test( + { permissions: { read: true, run: false } }, + async function commandPermissions() { + await assertRejects(async () => { + await new Deno.Command(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }).output(); + }, Deno.errors.PermissionDenied); + }, +); + +Deno.test( + { permissions: { read: true, run: false } }, + function commandSyncPermissions() { + assertThrows(() => { + new Deno.Command(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }).outputSync(); + }, Deno.errors.PermissionDenied); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandSuccess() { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }).output(); + + assertEquals(output.success, true); + assertEquals(output.code, 0); + assertEquals(output.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncSuccess() { + const output = new Deno.Command(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }).outputSync(); + + assertEquals(output.success, true); + assertEquals(output.code, 0); + assertEquals(output.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandUrl() { + const output = await new Deno.Command( + new URL(`file:///${Deno.execPath()}`), + { + args: ["eval", "console.log('hello world')"], + }, + ).output(); + + assertEquals(new TextDecoder().decode(output.stdout), "hello world\n"); + + assertEquals(output.success, true); + assertEquals(output.code, 0); + assertEquals(output.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncUrl() { + const output = new Deno.Command( + new URL(`file:///${Deno.execPath()}`), + { + args: ["eval", "console.log('hello world')"], + }, + ).outputSync(); + + assertEquals(new TextDecoder().decode(output.stdout), "hello world\n"); + + assertEquals(output.success, true); + assertEquals(output.code, 0); + assertEquals(output.signal, null); + }, +); + +Deno.test({ permissions: { run: true } }, function commandNotFound() { + assertThrows( + () => new Deno.Command("this file hopefully doesn't exist").output(), + Deno.errors.NotFound, + ); +}); + +Deno.test({ permissions: { run: true } }, function commandSyncNotFound() { + assertThrows( + () => new Deno.Command("this file hopefully doesn't exist").outputSync(), + Deno.errors.NotFound, + ); +}); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandFailedWithCode() { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "Deno.exit(41 + 1)"], + }).output(); + assertEquals(output.success, false); + assertEquals(output.code, 42); + assertEquals(output.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncFailedWithCode() { + const output = new Deno.Command(Deno.execPath(), { + args: ["eval", "Deno.exit(41 + 1)"], + }).outputSync(); + assertEquals(output.success, false); + assertEquals(output.code, 42); + assertEquals(output.signal, null); + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + }, + async function commandFailedWithSignal() { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--unstable", "Deno.kill(Deno.pid, 'SIGKILL')"], + }).output(); + assertEquals(output.success, false); + if (Deno.build.os === "windows") { + assertEquals(output.code, 1); + assertEquals(output.signal, null); + } else { + assertEquals(output.code, 128 + 9); + assertEquals(output.signal, "SIGKILL"); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + }, + function commandSyncFailedWithSignal() { + const output = new Deno.Command(Deno.execPath(), { + args: ["eval", "--unstable", "Deno.kill(Deno.pid, 'SIGKILL')"], + }).outputSync(); + assertEquals(output.success, false); + if (Deno.build.os === "windows") { + assertEquals(output.code, 1); + assertEquals(output.signal, null); + } else { + assertEquals(output.code, 128 + 9); + assertEquals(output.signal, "SIGKILL"); + } + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandOutput() { + const { stdout } = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + }).output(); + + const s = new TextDecoder().decode(stdout); + assertEquals(s, "hello"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncOutput() { + const { stdout } = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + }).outputSync(); + + const s = new TextDecoder().decode(stdout); + assertEquals(s, "hello"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandStderrOutput() { + const { stderr } = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('error'))", + ], + }).output(); + + const s = new TextDecoder().decode(stderr); + assertEquals(s, "error"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncStderrOutput() { + const { stderr } = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('error'))", + ], + }).outputSync(); + + const s = new TextDecoder().decode(stderr); + assertEquals(s, "error"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function commandEnv() { + const { stdout } = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "Deno.stdout.write(new TextEncoder().encode(Deno.env.get('FOO') + Deno.env.get('BAR')))", + ], + env: { + FOO: "0123", + BAR: "4567", + }, + }).output(); + const s = new TextDecoder().decode(stdout); + assertEquals(s, "01234567"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function commandSyncEnv() { + const { stdout } = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "Deno.stdout.write(new TextEncoder().encode(Deno.env.get('FOO') + Deno.env.get('BAR')))", + ], + env: { + FOO: "0123", + BAR: "4567", + }, + }).outputSync(); + const s = new TextDecoder().decode(stdout); + assertEquals(s, "01234567"); + }, +); + +Deno.test( + { permissions: { run: true, read: true, env: true } }, + async function commandClearEnv() { + const { stdout } = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "-p", + "JSON.stringify(Deno.env.toObject())", + ], + clearEnv: true, + env: { + FOO: "23147", + }, + }).output(); + + const obj = JSON.parse(new TextDecoder().decode(stdout)); + + // can't check for object equality because the OS may set additional env + // vars for processes, so we check if PATH isn't present as that is a common + // env var across OS's and isn't set for processes. + assertEquals(obj.FOO, "23147"); + assert(!("PATH" in obj)); + }, +); + +Deno.test( + { permissions: { run: true, read: true, env: true } }, + function commandSyncClearEnv() { + const { stdout } = new Deno.Command(Deno.execPath(), { + args: [ + "eval", + "-p", + "JSON.stringify(Deno.env.toObject())", + ], + clearEnv: true, + env: { + FOO: "23147", + }, + }).outputSync(); + + const obj = JSON.parse(new TextDecoder().decode(stdout)); + + // can't check for object equality because the OS may set additional env + // vars for processes, so we check if PATH isn't present as that is a common + // env var across OS's and isn't set for processes. + assertEquals(obj.FOO, "23147"); + assert(!("PATH" in obj)); + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + async function commandUid() { + const { stdout } = await new Deno.Command("id", { + args: ["-u"], + }).output(); + + const currentUid = new TextDecoder().decode(stdout); + + if (currentUid !== "0") { + await assertRejects(async () => { + await new Deno.Command("echo", { + args: ["fhqwhgads"], + uid: 0, + }).output(); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + function commandSyncUid() { + const { stdout } = new Deno.Command("id", { + args: ["-u"], + }).outputSync(); + + const currentUid = new TextDecoder().decode(stdout); + + if (currentUid !== "0") { + assertThrows(() => { + new Deno.Command("echo", { + args: ["fhqwhgads"], + uid: 0, + }).outputSync(); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + async function commandGid() { + const { stdout } = await new Deno.Command("id", { + args: ["-g"], + }).output(); + + const currentGid = new TextDecoder().decode(stdout); + + if (currentGid !== "0") { + await assertRejects(async () => { + await new Deno.Command("echo", { + args: ["fhqwhgads"], + gid: 0, + }).output(); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + function commandSyncGid() { + const { stdout } = new Deno.Command("id", { + args: ["-g"], + }).outputSync(); + + const currentGid = new TextDecoder().decode(stdout); + + if (currentGid !== "0") { + assertThrows(() => { + new Deno.Command("echo", { + args: ["fhqwhgads"], + gid: 0, + }).outputSync(); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test(function commandStdinPipedFails() { + assertThrows( + () => + new Deno.Command("id", { + stdin: "piped", + }).output(), + TypeError, + "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead", + ); +}); + +Deno.test(function spawnSyncStdinPipedFails() { + assertThrows( + () => + new Deno.Command("id", { + stdin: "piped", + }).outputSync(), + TypeError, + "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead", + ); +}); + +Deno.test( + // TODO(bartlomieju): this test became flaky on Windows CI + // raising "PermissionDenied" instead of "NotFound". + { + ignore: Deno.build.os === "windows", + permissions: { write: true, run: true, read: true }, + }, + async function commandChildUnref() { + const enc = new TextEncoder(); + const cwd = await Deno.makeTempDir({ prefix: "deno_command_test" }); + + const programFile = "unref.ts"; + const program = ` +const child = await new Deno.Command(Deno.execPath(), { + cwd: Deno.args[0], + stdout: "piped", + args: ["run", "-A", "--unstable", Deno.args[1]], +});child.spawn(); +const readable = child.stdout.pipeThrough(new TextDecoderStream()); +const reader = readable.getReader(); +// set up an interval that will end after reading a few messages from stdout, +// to verify that stdio streams are properly unrefed +let count = 0; +let interval; +interval = setInterval(async () => { + count += 1; + if (count > 10) { + clearInterval(interval); + console.log("cleared interval"); + } + const res = await reader.read(); + if (res.done) { + throw new Error("stream shouldn't be done"); + } + if (res.value.trim() != "hello from interval") { + throw new Error("invalid message received"); + } +}, 120); +console.log("spawned pid", child.pid); +child.unref(); +`; + + const childProgramFile = "unref_child.ts"; + const childProgram = ` +setInterval(() => { + console.log("hello from interval"); +}, 100); +`; + Deno.writeFileSync(`${cwd}/${programFile}`, enc.encode(program)); + Deno.writeFileSync(`${cwd}/${childProgramFile}`, enc.encode(childProgram)); + // In this subprocess we are spawning another subprocess which has + // an infite interval set. Following call would never resolve unless + // child process gets unrefed. + const { success, stdout, stderr } = await new Deno.Command( + Deno.execPath(), + { + cwd, + args: ["run", "-A", "--unstable", programFile, cwd, childProgramFile], + }, + ).output(); + + assert(success); + const stdoutText = new TextDecoder().decode(stdout); + const stderrText = new TextDecoder().decode(stderr); + assert(stderrText.length == 0); + const [line1, line2] = stdoutText.split("\n"); + const pidStr = line1.split(" ").at(-1); + assert(pidStr); + assertEquals(line2, "cleared interval"); + const pid = Number.parseInt(pidStr, 10); + await Deno.remove(cwd, { recursive: true }); + // Child process should have been killed when parent process exits. + assertThrows(() => { + Deno.kill(pid, "SIGTERM"); + }, Deno.errors.NotFound); + }, +); + +Deno.test( + { ignore: Deno.build.os !== "windows" }, + async function commandWindowsRawArguments() { + let { success, stdout } = await new Deno.Command("cmd", { + args: ["/d", "/s", "/c", '"deno ^"--version^""'], + windowsRawArguments: true, + }).output(); + assert(success); + let stdoutText = new TextDecoder().decode(stdout); + assertStringIncludes(stdoutText, "deno"); + assertStringIncludes(stdoutText, "v8"); + assertStringIncludes(stdoutText, "typescript"); + + ({ success, stdout } = new Deno.Command("cmd", { + args: ["/d", "/s", "/c", '"deno ^"--version^""'], + windowsRawArguments: true, + }).outputSync()); + assert(success); + stdoutText = new TextDecoder().decode(stdout); + assertStringIncludes(stdoutText, "deno"); + assertStringIncludes(stdoutText, "v8"); + assertStringIncludes(stdoutText, "typescript"); + }, +); + +Deno.test( + { permissions: { read: true, run: true } }, + async function commandWithPromisePrototypeThenOverride() { + const originalThen = Promise.prototype.then; + try { + Promise.prototype.then = () => { + throw new Error(); + }; + await new Deno.Command(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }).output(); + } finally { + Promise.prototype.then = originalThen; + } + }, +); diff --git a/runtime/js/40_spawn.js b/runtime/js/40_spawn.js index 4d2fb1607e..e262a13254 100644 --- a/runtime/js/40_spawn.js +++ b/runtime/js/40_spawn.js @@ -291,8 +291,136 @@ }; } + class Command { + #command; + #options; + + #child; + + #consumed; + + constructor(command, options) { + this.#command = command; + this.#options = options; + } + + output() { + if (this.#child) { + return this.#child.output(); + } else { + if (this.#consumed) { + throw new TypeError( + "Command instance is being or has already been consumed.", + ); + } + if (this.#options?.stdin === "piped") { + throw new TypeError( + "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead", + ); + } + + this.#consumed = true; + return Deno.spawn(this.#command, this.#options); + } + } + + outputSync() { + if (this.#consumed) { + throw new TypeError( + "Command instance is being or has already been consumed.", + ); + } + if (this.#child) { + throw new TypeError("Was spawned"); + } + if (this.#options?.stdin === "piped") { + throw new TypeError( + "Piped stdin is not supported for this function, use 'Deno.Command.spawn()' instead", + ); + } + + this.#consumed = true; + return Deno.spawnSync(this.#command, this.#options); + } + + spawn() { + if (this.#consumed) { + throw new TypeError( + "Command instance is being or has already been consumed.", + ); + } + + this.#consumed = true; + this.#child = Deno.spawnChild(this.#command, this.#options); + } + + get stdin() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + return this.#child.stdin; + } + + get stdout() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + return this.#child.stdout; + } + + get stderr() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + return this.#child.stderr; + } + + get status() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + return this.#child.status; + } + + get pid() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + return this.#child.pid; + } + + kill(signo = "SIGTERM") { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + this.#child.kill(signo); + } + + ref() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + this.#child.ref(); + } + + unref() { + if (!this.#child) { + throw new TypeError("Wasn't spawned"); + } + + this.#child.unref(); + } + } + window.__bootstrap.spawn = { Child, + Command, createSpawn, createSpawnChild, createSpawnSync, diff --git a/runtime/js/90_deno_ns.js b/runtime/js/90_deno_ns.js index 033ad421ea..16dd5c72f7 100644 --- a/runtime/js/90_deno_ns.js +++ b/runtime/js/90_deno_ns.js @@ -149,6 +149,7 @@ spawnChild: __bootstrap.spawn.spawnChild, spawn: __bootstrap.spawn.spawn, spawnSync: __bootstrap.spawn.spawnSync, + Command: __bootstrap.spawn.Command, serve: __bootstrap.flash.serve, upgradeHttp: __bootstrap.http.upgradeHttp, upgradeHttpRaw: __bootstrap.flash.upgradeHttpRaw,