diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1394be10ad9ba..3612b3a3e9ca4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,7 +236,7 @@ jobs: ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db - key: 8-cargo-home-${{ matrix.os }}-${{ hashFiles('Cargo.lock') }} + key: 9-cargo-home-${{ matrix.os }}-${{ hashFiles('Cargo.lock') }} # In main branch, always creates fresh cache - name: Cache build output (main) @@ -252,7 +252,7 @@ jobs: !./target/*/*.zip !./target/*/*.tar.gz key: | - 8-cargo-target-${{ matrix.os }}-${{ matrix.profile }}-${{ github.sha }} + 9-cargo-target-${{ matrix.os }}-${{ matrix.profile }}-${{ github.sha }} # Restore cache from the latest 'main' branch build. - name: Cache build output (PR) @@ -268,7 +268,7 @@ jobs: !./target/*/*.tar.gz key: never_saved restore-keys: | - 8-cargo-target-${{ matrix.os }}-${{ matrix.profile }}- + 9-cargo-target-${{ matrix.os }}-${{ matrix.profile }}- # Don't save cache after building PRs or branches other than 'main'. - name: Skip save cache (PR) diff --git a/cli/diagnostics.rs b/cli/diagnostics.rs index 8181c5fa0165b..24d7ab0e7c386 100644 --- a/cli/diagnostics.rs +++ b/cli/diagnostics.rs @@ -66,6 +66,12 @@ const UNSTABLE_DENO_PROPS: &[&str] = &[ "umask", "utime", "utimeSync", + "spawnChild", + "Child", + "spawn", + "spawnSync", + "ChildStatus", + "SpawnOutput", ]; static MSG_MISSING_PROPERTY_DENO: Lazy = Lazy::new(|| { diff --git a/cli/dts/lib.deno.unstable.d.ts b/cli/dts/lib.deno.unstable.d.ts index 0ad0704690925..6d5ad3af3c38c 100644 --- a/cli/dts/lib.deno.unstable.d.ts +++ b/cli/dts/lib.deno.unstable.d.ts @@ -1361,6 +1361,145 @@ declare namespace Deno { export function upgradeHttp( request: Request, ): Promise<[Deno.Conn, Uint8Array]>; + + export interface SpawnOptions { + /** 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 `opt.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 setuid call will cause the spawn to fail. + */ + uid?: number; + /** Similar to `uid`, but sets the group ID of the child process. */ + gid?: number; + + /** Defaults to "null". */ + stdin?: "piped" | "inherit" | "null"; + /** Defaults to "piped". */ + stdout?: "piped" | "inherit" | "null"; + /** Defaults to "piped". */ + stderr?: "piped" | "inherit" | "null"; + } + + /** + * Spawns a child process. + * + * If stdin is set to "piped", the stdin WritableStream needs to be closed manually. + * + * ```ts + * const child = Deno.spawnChild(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('Hello World')", + * ], + * stdin: "piped", + * }); + * + * // open a file and pipe the subprocess output to it. + * child.stdout.pipeTo(Deno.openSync("output").writable); + * + * // manually close stdin + * child.stdin.close(); + * const status = await child.status; + * ``` + */ + export function spawnChild( + command: string | URL, + options?: T, + ): Child; + + export class Child { + readonly stdin: T["stdin"] extends "piped" ? WritableStream + : null; + readonly stdout: T["stdout"] extends "inherit" | "null" ? null + : ReadableStream; + readonly stderr: T["stderr"] extends "inherit" | "null" ? null + : ReadableStream; + + readonly pid: number; + /** Get the status of the child. */ + readonly status: Promise; + + /** Waits for the child to exit completely, returning all its output and status. */ + output(): Promise>; + /** Kills the process with given Signal. */ + kill(signo: Signal): void; + } + + /** + * Executes a subprocess, waiting for it to finish and + * collecting all of its output. + * The stdio options are ignored. + * + * ```ts + * const { status, stdout, stderr } = await Deno.spawn(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('hello'); console.error('world')", + * ], + * }); + * console.assert(status.code === 0); + * console.assert("hello\n" === new TextDecoder().decode(stdout)); + * console.assert("world\n" === new TextDecoder().decode(stderr)); + * ``` + */ + export function spawn( + command: string | URL, + options?: T, + ): Promise>; + + /** + * Synchronously executes a subprocess, waiting for it to finish and + * collecting all of its output. + * The stdio options are ignored. + * + * * ```ts + * const { status, stdout, stderr } = Deno.spawnSync(Deno.execPath(), { + * args: [ + * "eval", + * "console.log('hello'); console.error('world')", + * ], + * }); + * console.assert(status.code === 0); + * console.assert("hello\n" === new TextDecoder().decode(stdout)); + * console.assert("world\n" === new TextDecoder().decode(stderr)); + * ``` + */ + export function spawnSync( + command: string | URL, + options?: T, + ): SpawnOutput; + + export type ChildStatus = + | { + success: true; + code: 0; + signal: null; + } + | { + success: false; + code: number; + signal: number | null; + }; + + export interface SpawnOutput { + status: ChildStatus; + stdout: T["stdout"] extends "inherit" | "null" ? null : Uint8Array; + stderr: T["stderr"] extends "inherit" | "null" ? null : Uint8Array; + } } declare function fetch( diff --git a/cli/tests/unit/command_test.ts b/cli/tests/unit/command_test.ts new file mode 100644 index 0000000000000..f7213f03447cb --- /dev/null +++ b/cli/tests/unit/command_test.ts @@ -0,0 +1,687 @@ +// 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 spawnWithCwdIsAsync() { + 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 = Deno.spawnChild(Deno.execPath(), { + cwd, + args: ["run", "--allow-read", programFile], + stdout: "inherit", + stderr: "inherit", + }); + + // 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 spawnStdinPiped() { + const child = Deno.spawnChild(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", + }); + + assert(child.stdin !== null); + assert(child.stdout === null); + assert(child.stderr === null); + + 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 spawnStdoutPiped() { + const child = Deno.spawnChild(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + stderr: "null", + }); + + assert(child.stdin === null); + assert(child.stdout !== null); + assert(child.stderr === null); + + 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 spawnStderrPiped() { + const child = Deno.spawnChild(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('hello'))", + ], + stderr: "piped", + stdout: "null", + }); + + assert(child.stdin === null); + assert(child.stdout === null); + assert(child.stderr !== null); + + 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 spawnRedirectStdoutStderr() { + const tempDir = await Deno.makeTempDir(); + const fileName = tempDir + "/redirected_stdio.txt"; + const file = await Deno.open(fileName, { + create: true, + write: true, + }); + + const child = Deno.spawnChild(Deno.execPath(), { + args: [ + "eval", + "Deno.stderr.write(new TextEncoder().encode('error\\n')); Deno.stdout.write(new TextEncoder().encode('output\\n'));", + ], + }); + 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 spawnRedirectStdin() { + 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 = Deno.spawnChild(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", + }); + 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 spawnKillSuccess() { + const child = Deno.spawnChild(Deno.execPath(), { + args: ["eval", "setTimeout(() => {}, 10000)"], + stdout: "null", + stderr: "null", + }); + + 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, 9); + } + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnKillFailed() { + const child = Deno.spawnChild(Deno.execPath(), { + args: ["eval", "setTimeout(() => {}, 5000)"], + stdout: "null", + stderr: "null", + }); + + assertThrows(() => { + // @ts-expect-error testing runtime error of bad signal + child.kill("foobar"); + }, TypeError); + + await child.status; + }, +); + +Deno.test( + { permissions: { read: true, run: false } }, + async function spawnPermissions() { + await assertRejects(async () => { + await Deno.spawn(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }); + }, Deno.errors.PermissionDenied); + }, +); + +Deno.test( + { permissions: { read: true, run: false } }, + function spawnSyncPermissions() { + assertThrows(() => { + Deno.spawnSync(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }); + }, Deno.errors.PermissionDenied); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnSuccess() { + const { status } = await Deno.spawn(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }); + + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncSuccess() { + const { status } = Deno.spawnSync(Deno.execPath(), { + args: ["eval", "console.log('hello world')"], + }); + + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnUrl() { + const { status, stdout } = await Deno.spawn( + new URL(`file:///${Deno.execPath()}`), + { + args: ["eval", "console.log('hello world')"], + }, + ); + + assertEquals(new TextDecoder().decode(stdout), "hello world\n"); + + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncUrl() { + const { status, stdout } = Deno.spawnSync( + new URL(`file:///${Deno.execPath()}`), + { + args: ["eval", "console.log('hello world')"], + }, + ); + + assertEquals(new TextDecoder().decode(stdout), "hello world\n"); + + assertEquals(status.success, true); + assertEquals(status.code, 0); + assertEquals(status.signal, null); + }, +); + +Deno.test({ permissions: { run: true } }, async function spawnNotFound() { + await assertRejects( + () => Deno.spawn("this file hopefully doesn't exist"), + Deno.errors.NotFound, + ); +}); + +Deno.test({ permissions: { run: true } }, function spawnSyncNotFound() { + assertThrows( + () => Deno.spawnSync("this file hopefully doesn't exist"), + Deno.errors.NotFound, + ); +}); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnFailedWithCode() { + const { status } = await Deno.spawn(Deno.execPath(), { + args: ["eval", "Deno.exit(41 + 1)"], + }); + assertEquals(status.success, false); + assertEquals(status.code, 42); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncFailedWithCode() { + const { status } = Deno.spawnSync(Deno.execPath(), { + args: ["eval", "Deno.exit(41 + 1)"], + }); + assertEquals(status.success, false); + assertEquals(status.code, 42); + assertEquals(status.signal, null); + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + }, + async function spawnFailedWithSignal() { + const { status } = await Deno.spawn(Deno.execPath(), { + args: ["eval", "--unstable", "Deno.kill(Deno.pid, 'SIGKILL')"], + }); + assertEquals(status.success, false); + if (Deno.build.os === "windows") { + assertEquals(status.code, 1); + assertEquals(status.signal, null); + } else { + assertEquals(status.code, 128 + 9); + assertEquals(status.signal, 9); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + }, + function spawnSyncFailedWithSignal() { + const { status } = Deno.spawnSync(Deno.execPath(), { + args: ["eval", "--unstable", "Deno.kill(Deno.pid, 'SIGKILL')"], + }); + assertEquals(status.success, false); + if (Deno.build.os === "windows") { + assertEquals(status.code, 1); + assertEquals(status.signal, null); + } else { + assertEquals(status.code, 128 + 9); + assertEquals(status.signal, 9); + } + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnOutput() { + const { stdout } = await Deno.spawn(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + }); + + const s = new TextDecoder().decode(stdout); + assertEquals(s, "hello"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncOutput() { + const { stdout } = Deno.spawnSync(Deno.execPath(), { + args: [ + "eval", + "await Deno.stdout.write(new TextEncoder().encode('hello'))", + ], + }); + + const s = new TextDecoder().decode(stdout); + assertEquals(s, "hello"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnStderrOutput() { + const { stderr } = await Deno.spawn(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('error'))", + ], + }); + + const s = new TextDecoder().decode(stderr); + assertEquals(s, "error"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncStderrOutput() { + const { stderr } = Deno.spawnSync(Deno.execPath(), { + args: [ + "eval", + "await Deno.stderr.write(new TextEncoder().encode('error'))", + ], + }); + + const s = new TextDecoder().decode(stderr); + assertEquals(s, "error"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnOverrideStdio() { + const { stdout, stderr } = await Deno.spawn(Deno.execPath(), { + args: [ + "eval", + "console.log('hello'); console.error('world')", + ], + stdin: "piped", + stdout: "null", + stderr: "null", + }); + + // @ts-ignore: for testing + assertEquals(new TextDecoder().decode(stdout), "hello\n"); + // @ts-ignore: for testing + assertEquals(new TextDecoder().decode(stderr), "world\n"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnSyncOverrideStdio() { + const { stdout, stderr } = Deno.spawnSync(Deno.execPath(), { + args: [ + "eval", + "console.log('hello'); console.error('world')", + ], + stdin: "piped", + stdout: "null", + stderr: "null", + }); + + // @ts-ignore: for testing + assertEquals(new TextDecoder().decode(stdout), "hello\n"); + // @ts-ignore: for testing + assertEquals(new TextDecoder().decode(stderr), "world\n"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + async function spawnEnv() { + const { stdout } = await Deno.spawn(Deno.execPath(), { + args: [ + "eval", + "Deno.stdout.write(new TextEncoder().encode(Deno.env.get('FOO') + Deno.env.get('BAR')))", + ], + env: { + FOO: "0123", + BAR: "4567", + }, + }); + const s = new TextDecoder().decode(stdout); + assertEquals(s, "01234567"); + }, +); + +Deno.test( + { permissions: { run: true, read: true } }, + function spawnEnv() { + const { stdout } = Deno.spawnSync(Deno.execPath(), { + args: [ + "eval", + "Deno.stdout.write(new TextEncoder().encode(Deno.env.get('FOO') + Deno.env.get('BAR')))", + ], + env: { + FOO: "0123", + BAR: "4567", + }, + }); + const s = new TextDecoder().decode(stdout); + assertEquals(s, "01234567"); + }, +); + +Deno.test( + { permissions: { run: true, read: true, env: true } }, + async function spawnClearEnv() { + const { stdout } = await Deno.spawn(Deno.execPath(), { + args: [ + "eval", + "-p", + "JSON.stringify(Deno.env.toObject())", + ], + clearEnv: true, + env: { + FOO: "23147", + }, + }); + + 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 spawnSyncClearEnv() { + const { stdout } = Deno.spawnSync(Deno.execPath(), { + args: [ + "eval", + "-p", + "JSON.stringify(Deno.env.toObject())", + ], + clearEnv: true, + env: { + FOO: "23147", + }, + }); + + 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 spawnUid() { + const { stdout } = await Deno.spawn("id", { + args: ["-u"], + }); + + const currentUid = new TextDecoder().decode(stdout); + + if (currentUid !== "0") { + await assertRejects(async () => { + await Deno.spawn("echo", { + args: ["fhqwhgads"], + uid: 0, + }); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + function spawnSyncUid() { + const { stdout } = Deno.spawnSync("id", { + args: ["-u"], + }); + + const currentUid = new TextDecoder().decode(stdout); + + if (currentUid !== "0") { + assertThrows(() => { + Deno.spawnSync("echo", { + args: ["fhqwhgads"], + uid: 0, + }); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + async function spawnGid() { + const { stdout } = await Deno.spawn("id", { + args: ["-g"], + }); + + const currentGid = new TextDecoder().decode(stdout); + + if (currentGid !== "0") { + await assertRejects(async () => { + await Deno.spawn("echo", { + args: ["fhqwhgads"], + gid: 0, + }); + }, Deno.errors.PermissionDenied); + } + }, +); + +Deno.test( + { + permissions: { run: true, read: true }, + ignore: Deno.build.os === "windows", + }, + function spawnSyncGid() { + const { stdout } = Deno.spawnSync("id", { + args: ["-g"], + }); + + const currentGid = new TextDecoder().decode(stdout); + + if (currentGid !== "0") { + assertThrows(() => { + Deno.spawnSync("echo", { + args: ["fhqwhgads"], + gid: 0, + }); + }, Deno.errors.PermissionDenied); + } + }, +); diff --git a/cli/tests/unit/process_test.ts b/cli/tests/unit/process_test.ts index e4e2cc3c52661..5acb9222682d0 100644 --- a/cli/tests/unit/process_test.ts +++ b/cli/tests/unit/process_test.ts @@ -557,8 +557,9 @@ Deno.test( const obj = JSON.parse(new TextDecoder().decode(await p.output())); - // 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. + // 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)); diff --git a/ext/web/lib.deno_web.d.ts b/ext/web/lib.deno_web.d.ts index 8c845ced105f9..13ad113fe2a94 100644 --- a/ext/web/lib.deno_web.d.ts +++ b/ext/web/lib.deno_web.d.ts @@ -630,6 +630,7 @@ interface WritableStreamErrorCallback { interface WritableStream { readonly locked: boolean; abort(reason?: any): Promise; + close(): Promise; getWriter(): WritableStreamDefaultWriter; } diff --git a/runtime/js/40_spawn.js b/runtime/js/40_spawn.js new file mode 100644 index 0000000000000..c55ce657de3c3 --- /dev/null +++ b/runtime/js/40_spawn.js @@ -0,0 +1,206 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +"use strict"; + +((window) => { + const core = window.Deno.core; + const { pathFromURL } = window.__bootstrap.util; + const { illegalConstructorKey } = window.__bootstrap.webUtil; + const { + ArrayPrototypeMap, + ObjectEntries, + String, + TypeError, + Uint8Array, + PromiseAll, + } = window.__bootstrap.primordials; + const { readableStreamForRid, writableStreamForRid } = + window.__bootstrap.streamUtils; + + function spawnChild(command, { + args = [], + cwd = undefined, + clearEnv = false, + env = {}, + uid = undefined, + gid = undefined, + stdin = "null", + stdout = "piped", + stderr = "piped", + } = {}) { + const child = core.opSync("op_spawn_child", { + cmd: pathFromURL(command), + args: ArrayPrototypeMap(args, String), + cwd: pathFromURL(cwd), + clearEnv, + env: ObjectEntries(env), + uid, + gid, + stdin, + stdout, + stderr, + }); + return new Child(illegalConstructorKey, child); + } + + async function collectOutput(readableStream) { + if (!(readableStream instanceof ReadableStream)) { + return null; + } + + const bufs = []; + let size = 0; + for await (const chunk of readableStream) { + bufs.push(chunk); + size += chunk.byteLength; + } + + const buffer = new Uint8Array(size); + let offset = 0; + for (const chunk of bufs) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + + return buffer; + } + + class Child { + #rid; + + #pid; + get pid() { + return this.#pid; + } + + #stdinRid; + #stdin = null; + get stdin() { + return this.#stdin; + } + + #stdoutRid; + #stdout = null; + get stdout() { + return this.#stdout; + } + + #stderrRid; + #stderr = null; + get stderr() { + return this.#stderr; + } + + constructor(key = null, { + rid, + pid, + stdinRid, + stdoutRid, + stderrRid, + } = null) { + if (key !== illegalConstructorKey) { + throw new TypeError("Illegal constructor."); + } + + this.#rid = rid; + this.#pid = pid; + + if (stdinRid !== null) { + this.#stdinRid = stdinRid; + this.#stdin = writableStreamForRid(stdinRid); + } + + if (stdoutRid !== null) { + this.#stdoutRid = stdoutRid; + this.#stdout = readableStreamForRid(stdoutRid); + } + + if (stderrRid !== null) { + this.#stderrRid = stderrRid; + this.#stderr = readableStreamForRid(stderrRid); + } + + this.#status = core.opAsync("op_spawn_wait", this.#rid).then((res) => { + this.#rid = null; + return res; + }); + } + + #status; + get status() { + return this.#status; + } + + async output() { + if (this.#rid === null) { + throw new TypeError("Child process has already terminated."); + } + if (this.#stdout?.locked) { + throw new TypeError( + "Can't collect output because stdout is locked", + ); + } + if (this.#stderr?.locked) { + throw new TypeError( + "Can't collect output because stderr is locked", + ); + } + + const [status, stdout, stderr] = await PromiseAll([ + this.#status, + collectOutput(this.#stdout), + collectOutput(this.#stderr), + ]); + + return { + status, + stdout, + stderr, + }; + } + + kill(signo) { + if (this.#rid === null) { + throw new TypeError("Child process has already terminated."); + } + core.opSync("op_kill", this.#pid, signo); + } + } + + function spawn(command, options) { // TODO(@crowlKats): more options (like input)? + return spawnChild(command, { + ...options, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + } + + function spawnSync(command, { + args = [], + cwd = undefined, + clearEnv = false, + env = {}, + uid = undefined, + gid = undefined, + } = {}) { // TODO(@crowlKats): more options (like input)? + return core.opSync("op_spawn_sync", { + cmd: pathFromURL(command), + args: ArrayPrototypeMap(args, String), + cwd: pathFromURL(cwd), + clearEnv, + env: ObjectEntries(env), + uid, + gid, + stdin: "null", + stdout: "piped", + stderr: "piped", + }); + } + + window.__bootstrap.spawn = { + Child, + spawnChild, + spawn, + spawnSync, + }; +})(this); diff --git a/runtime/js/90_deno_ns.js b/runtime/js/90_deno_ns.js index ddaecd7c9fc24..61e894f8a003c 100644 --- a/runtime/js/90_deno_ns.js +++ b/runtime/js/90_deno_ns.js @@ -151,5 +151,9 @@ funlockSync: __bootstrap.fs.funlockSync, refTimer: __bootstrap.timers.refTimer, unrefTimer: __bootstrap.timers.unrefTimer, + Child: __bootstrap.spawn.Child, + spawnChild: __bootstrap.spawn.spawnChild, + spawn: __bootstrap.spawn.spawn, + spawnSync: __bootstrap.spawn.spawnSync, }; })(this); diff --git a/runtime/ops/io.rs b/runtime/ops/io.rs index b8449af86b743..34cd541d5cd33 100644 --- a/runtime/ops/io.rs +++ b/runtime/ops/io.rs @@ -134,6 +134,10 @@ where stream.shutdown().await?; Ok(()) } + + pub fn into_inner(self) -> S { + self.stream.into_inner() + } } #[derive(Debug)] @@ -178,6 +182,10 @@ where .await?; Ok((nread, buf)) } + + pub fn into_inner(self) -> S { + self.stream.into_inner() + } } pub type ChildStdinResource = WriteOnlyResource; diff --git a/runtime/ops/mod.rs b/runtime/ops/mod.rs index 750dfe0f2c6bd..526c36d6305af 100644 --- a/runtime/ops/mod.rs +++ b/runtime/ops/mod.rs @@ -9,6 +9,7 @@ pub mod permissions; pub mod process; pub mod runtime; pub mod signal; +pub mod spawn; pub mod tty; mod utils; pub mod web_worker; diff --git a/runtime/ops/spawn.rs b/runtime/ops/spawn.rs new file mode 100644 index 0000000000000..196a7eed65f2d --- /dev/null +++ b/runtime/ops/spawn.rs @@ -0,0 +1,263 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +use super::io::ChildStderrResource; +use super::io::ChildStdinResource; +use super::io::ChildStdoutResource; +use crate::permissions::Permissions; +use deno_core::error::AnyError; +use deno_core::op; +use deno_core::Extension; +use deno_core::OpState; +use deno_core::Resource; +use deno_core::ResourceId; +use deno_core::ZeroCopyBuf; +use serde::Deserialize; +use serde::Serialize; +use std::borrow::Cow; +use std::cell::RefCell; +use std::process::ExitStatus; +use std::rc::Rc; + +#[cfg(unix)] +use std::os::unix::prelude::ExitStatusExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +pub fn init() -> Extension { + Extension::builder() + .ops(vec![ + op_spawn_child::decl(), + op_spawn_wait::decl(), + op_spawn_sync::decl(), + ]) + .build() +} + +struct ChildResource(tokio::process::Child); + +impl Resource for ChildResource { + fn name(&self) -> Cow { + "child".into() + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stdio { + Inherit, + Piped, + Null, +} + +fn subprocess_stdio_map(s: &Stdio) -> Result { + match s { + Stdio::Inherit => Ok(std::process::Stdio::inherit()), + Stdio::Piped => Ok(std::process::Stdio::piped()), + Stdio::Null => Ok(std::process::Stdio::null()), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnArgs { + cmd: String, + args: Vec, + cwd: Option, + clear_env: bool, + env: Vec<(String, String)>, + #[cfg(unix)] + gid: Option, + #[cfg(unix)] + uid: Option, + + #[serde(flatten)] + stdio: ChildStdio, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChildStdio { + stdin: Stdio, + stdout: Stdio, + stderr: Stdio, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChildStatus { + success: bool, + code: i32, + signal: Option, +} + +impl From for ChildStatus { + fn from(status: ExitStatus) -> Self { + let code = status.code(); + #[cfg(unix)] + let signal = status.signal(); + #[cfg(not(unix))] + let signal = None; + + if let Some(signal) = signal { + ChildStatus { + success: false, + code: 128 + signal, + signal: Some(signal), + } + } else { + let code = code.expect("Should have either an exit code or a signal."); + + ChildStatus { + success: code == 0, + code, + signal: None, + } + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpawnOutput { + status: ChildStatus, + stdout: Option, + stderr: Option, +} + +fn create_command( + state: &mut OpState, + args: SpawnArgs, +) -> Result { + super::check_unstable(state, "Deno.spawn"); + state.borrow_mut::().run.check(&args.cmd)?; + + let mut command = std::process::Command::new(args.cmd); + command.args(args.args); + + if let Some(cwd) = args.cwd { + command.current_dir(cwd); + } + + if args.clear_env { + command.env_clear(); + } + command.envs(args.env); + + #[cfg(unix)] + if let Some(gid) = args.gid { + super::check_unstable(state, "Deno.spawn.gid"); + command.gid(gid); + } + #[cfg(unix)] + if let Some(uid) = args.uid { + super::check_unstable(state, "Deno.spawn.uid"); + command.uid(uid); + } + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + libc::setgroups(0, std::ptr::null()); + Ok(()) + }); + } + + command.stdin(subprocess_stdio_map(&args.stdio.stdin)?); + command.stdout(subprocess_stdio_map(&args.stdio.stdout)?); + command.stderr(subprocess_stdio_map(&args.stdio.stderr)?); + + Ok(command) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Child { + rid: ResourceId, + pid: u32, + stdin_rid: Option, + stdout_rid: Option, + stderr_rid: Option, +} + +#[op] +fn op_spawn_child( + state: &mut OpState, + args: SpawnArgs, +) -> Result { + let mut command = tokio::process::Command::from(create_command(state, args)?); + // TODO(@crowlkats): allow detaching processes. + // currently deno will orphan a process when exiting with an error or Deno.exit() + // We want to kill child when it's closed + command.kill_on_drop(true); + + let mut child = command.spawn()?; + let pid = child.id().expect("Process ID should be set."); + + let stdin_rid = child + .stdin + .take() + .map(|stdin| state.resource_table.add(ChildStdinResource::from(stdin))); + + let stdout_rid = child + .stdout + .take() + .map(|stdout| state.resource_table.add(ChildStdoutResource::from(stdout))); + + let stderr_rid = child + .stderr + .take() + .map(|stderr| state.resource_table.add(ChildStderrResource::from(stderr))); + + let child_rid = state.resource_table.add(ChildResource(child)); + + Ok(Child { + rid: child_rid, + pid, + stdin_rid, + stdout_rid, + stderr_rid, + }) +} + +#[op] +async fn op_spawn_wait( + state: Rc>, + rid: ResourceId, +) -> Result { + let resource = state + .borrow_mut() + .resource_table + .take::(rid)?; + Ok( + Rc::try_unwrap(resource) + .ok() + .unwrap() + .0 + .wait() + .await? + .into(), + ) +} + +#[op] +fn op_spawn_sync( + state: &mut OpState, + args: SpawnArgs, +) -> Result { + let stdout = matches!(args.stdio.stdout, Stdio::Piped); + let stderr = matches!(args.stdio.stderr, Stdio::Piped); + let output = create_command(state, args)?.output()?; + + Ok(SpawnOutput { + status: output.status.into(), + stdout: if stdout { + Some(output.stdout.into()) + } else { + None + }, + stderr: if stderr { + Some(output.stderr.into()) + } else { + None + }, + }) +} diff --git a/runtime/web_worker.rs b/runtime/web_worker.rs index f4c040aa4fb1e..ac103addae422 100644 --- a/runtime/web_worker.rs +++ b/runtime/web_worker.rs @@ -427,6 +427,7 @@ impl WebWorker { .enabled(options.use_deno_namespace), ops::permissions::init().enabled(options.use_deno_namespace), ops::process::init().enabled(options.use_deno_namespace), + ops::spawn::init().enabled(options.use_deno_namespace), ops::signal::init().enabled(options.use_deno_namespace), ops::tty::init().enabled(options.use_deno_namespace), deno_http::init().enabled(options.use_deno_namespace), diff --git a/runtime/worker.rs b/runtime/worker.rs index fa147a7e669b8..37047570362f9 100644 --- a/runtime/worker.rs +++ b/runtime/worker.rs @@ -132,6 +132,7 @@ impl MainWorker { options.create_web_worker_cb.clone(), options.web_worker_preload_module_cb.clone(), ), + ops::spawn::init(), ops::fs_events::init(), ops::fs::init(), ops::io::init(),