diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/modules/subprocess | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/modules/subprocess')
20 files changed, 4986 insertions, 0 deletions
diff --git a/toolkit/modules/subprocess/.eslintrc.js b/toolkit/modules/subprocess/.eslintrc.js new file mode 100644 index 0000000000..7640781589 --- /dev/null +++ b/toolkit/modules/subprocess/.eslintrc.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: "../../components/extensions/.eslintrc.js", + + rules: { + "no-console": "off", + }, +}; diff --git a/toolkit/modules/subprocess/Subprocess.sys.mjs b/toolkit/modules/subprocess/Subprocess.sys.mjs new file mode 100644 index 0000000000..ffbeb0acbb --- /dev/null +++ b/toolkit/modules/subprocess/Subprocess.sys.mjs @@ -0,0 +1,198 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * These modules are loosely based on the subprocess.jsm module created + * by Jan Gerber and Patrick Brunschwig, though the implementation + * differs drastically. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { SubprocessConstants } from "resource://gre/modules/subprocess/subprocess_common.sys.mjs"; + +const lazy = {}; + +if (AppConstants.platform == "win") { + ChromeUtils.defineESModuleGetters(lazy, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(lazy, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +function encodeEnvVar(name, value) { + if (typeof name === "string" && typeof value === "string") { + return `${name}=${value}`; + } + + let encoder = new TextEncoder(); + function encode(val) { + return typeof val === "string" ? encoder.encode(val) : val; + } + + return Uint8Array.of(...encode(name), ...encode("="), ...encode(value), 0); +} + +function platformSupportsDisclaimedSpawn() { + return AppConstants.isPlatformAndVersionAtLeast("macosx", 18); +} + +/** + * Allows for creation of and communication with OS-level sub-processes. + * + * @namespace + */ +export var Subprocess = { + /** + * Launches a process, and returns a handle to it. + * + * @param {object} options + * An object describing the process to launch. + * + * @param {string} options.command + * The full path of the executable to launch. Relative paths are not + * accepted, and `$PATH` is not searched. + * + * If a path search is necessary, the {@link Subprocess.pathSearch} method may + * be used to map a bare executable name to a full path. + * + * @param {string[]} [options.arguments] + * A list of strings to pass as arguments to the process. + * + * @param {object} [options.environment] An object containing a key + * and value for each environment variable to pass to the + * process. Values that are `=== null` are ignored. Only the + * object's own, enumerable properties are added to the environment. + * + * @param {boolean} [options.environmentAppend] If true, append the + * environment variables passed in `environment` to the existing set + * of environment variables. Values that are `=== null` are removed + * from the environment. Otherwise, the values in 'environment' + * constitute the entire set of environment variables passed to the + * new process. + * + * @param {string} [options.stderr] + * Defines how the process's stderr output is handled. One of: + * + * - `"ignore"`: (default) The process's standard error is not redirected. + * - `"stdout"`: The process's stderr is merged with its stdout. + * - `"pipe"`: The process's stderr is redirected to a pipe, which can be read + * from via its `stderr` property. + * + * @param {string} [options.workdir] + * The working directory in which to launch the new process. + * + * @param {boolean} [options.disclaim] + * macOS-specific option for 10.14+ OS versions. If true, enables a + * macOS-specific process launch option allowing the parent process to + * disclaim responsibility for the child process with respect to privacy/ + * security permission prompts and decisions. This option is ignored on + * platforms that do not support it. + * + * @returns {Promise<Process>} + * + * @throws {Error} + * May be rejected with an Error object if the process can not be + * launched. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_BAD_EXECUTABLE: The given command could not + * be found, or the file that it references is not executable. + * + * Note that if the process is successfully launched, but exits with + * a non-zero exit code, the promise will still resolve successfully. + */ + call(options) { + options = Object.assign({}, options); + + options.stderr = options.stderr || "ignore"; + options.workdir = options.workdir || null; + options.disclaim = options.disclaim || false; + + let environment = {}; + if (!options.environment || options.environmentAppend) { + environment = this.getEnvironment(); + } + + if (options.environment) { + Object.assign(environment, options.environment); + } + + options.environment = Object.entries(environment) + .map(([key, val]) => (val !== null ? encodeEnvVar(key, val) : null)) + .filter(s => s); + + options.arguments = Array.from(options.arguments || []); + + if (options.disclaim && !platformSupportsDisclaimedSpawn()) { + options.disclaim = false; + } + + return Promise.resolve( + lazy.SubprocessImpl.isExecutableFile(options.command) + ).then(isExecutable => { + if (!isExecutable) { + let error = new Error( + `File at path "${options.command}" does not exist, or is not executable` + ); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + options.arguments.unshift(options.command); + + return lazy.SubprocessImpl.call(options); + }); + }, + + /** + * Returns an object with a key-value pair for every variable in the process's + * current environment. + * + * @returns {object} + */ + getEnvironment() { + let environment = Object.create(null); + for (let [k, v] of lazy.SubprocessImpl.getEnvironment()) { + environment[k] = v; + } + return environment; + }, + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHSEP + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} command + * The name of the executable to find. + * @param {object} [environment] + * An object containing a key for each environment variable to be used + * in the search. If not provided, full the current process environment + * is used. + * @returns {Promise<string>} + */ + pathSearch(command, environment = this.getEnvironment()) { + // Promise.resolve lets us get around returning one of the Promise.jsm + // pseudo-promises returned by Task.jsm. + let path = lazy.SubprocessImpl.pathSearch(command, environment); + return Promise.resolve(path); + }, +}; + +Object.assign(Subprocess, SubprocessConstants); +Object.freeze(Subprocess); + +export function getSubprocessImplForTest() { + return lazy.SubprocessImpl; +} diff --git a/toolkit/modules/subprocess/docs/index.rst b/toolkit/modules/subprocess/docs/index.rst new file mode 100644 index 0000000000..983fe30d4d --- /dev/null +++ b/toolkit/modules/subprocess/docs/index.rst @@ -0,0 +1,226 @@ +.. _Subprocess: + +================= +Subprocess Module +================= + +The Subprocess module allows a caller to spawn a native host executable, and +communicate with it asynchronously over its standard input and output pipes. + +Processes are launched asynchronously ``Subprocess.call`` method, based +on the properties of a single options object. The method returns a promise +which resolves, once the process has successfully launched, to a ``Process`` +object, which can be used to communicate with and control the process. + +A simple Hello World invocation, which writes a message to a process, reads it +back, logs it, and waits for the process to exit looks something like: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/cat", + }); + + proc.stdin.write("Hello World!"); + + let result = await proc.stdout.readString(); + console.log(result); + + proc.stdin.close(); + let {exitCode} = await proc.wait(); + +Input and Output Redirection +============================ + +Communication with the child process happens entirely via one-way pipes tied +to its standard input, standard output, and standard error file descriptors. +While standard input and output are always redirected to pipes, standard error +is inherited from the parent process by default. Standard error can, however, +optionally be either redirected to its own pipe or merged into the standard +output pipe. + +The module is designed primarily for use with processes following a strict +IO protocol, with predictable message sizes. Its read operations, therefore, +either complete after reading the exact amount of data specified, or do not +complete at all. For cases where this is not desirable, ``read()`` and +``readString`` may be called without any length argument, and will return a +chunk of data of an arbitrary size. + + +Process and Pipe Lifecycles +=========================== + +Once the process exits, any buffered data from its output pipes may still be +read until the pipe is explicitly closed. Unless the pipe is explicitly +closed, however, any pending buffered data *must* be read from the pipe, or +the resources associated with the pipe will not be freed. + +Beyond this, no explicit cleanup is required for either processes or their +pipes. So long as the caller ensures that the process exits, and there is no +pending input to be read on its ``stdout`` or ``stderr`` pipes, all resources +will be freed automatically. + +The preferred way to ensure that a process exits is to close its input pipe +and wait for it to exit gracefully. Processes which haven't exited gracefully +by shutdown time, however, must be forcibly terminated: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/usr/bin/subprocess.py", + }); + + // Kill the process if it hasn't gracefully exited by shutdown time. + let blocker = () => proc.kill(); + + AsyncShutdown.profileBeforeChange.addBlocker( + "Subprocess: Killing hung process", + blocker); + + proc.wait().then(() => { + // Remove the shutdown blocker once we've exited. + AsyncShutdown.profileBeforeChange.removeBlocker(blocker); + + // Close standard output, in case there's any buffered data we haven't read. + proc.stdout.close(); + }); + + // Send a message to the process, and close stdin, so the process knows to + // exit. + proc.stdin.write(message); + proc.stdin.close(); + +In the simpler case of a short-running process which takes no input, and exits +immediately after producing output, it's generally enough to simply read its +output stream until EOF: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: await Subprocess.pathSearch("ifconfig"), + }); + + // Read all of the process output. + let result = ""; + let string; + while ((string = await proc.stdout.readString())) { + result += string; + } + console.log(result); + + // The output pipe is closed and no buffered data remains to be read. + // This means the process has exited, and no further cleanup is necessary. + + +Bidirectional IO +================ + +When performing bidirectional IO, special care needs to be taken to avoid +deadlocks. While all IO operations in the Subprocess API are asynchronous, +careless ordering of operations can still lead to a state where both processes +are blocked on a read or write operation at the same time. For example, + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/cat", + }); + + let size = 1024 * 1024; + await proc.stdin.write(new ArrayBuffer(size)); + + let result = await proc.stdout.read(size); + +The code attempts to write 1MB of data to an input pipe, and then read it back +from the output pipe. Because the data is big enough to fill both the input +and output pipe buffers, though, and because the code waits for the write +operation to complete before attempting any reads, the ``cat`` process will +block trying to write to its output indefinitely, and never finish reading the +data from its standard input. + +In order to avoid the deadlock, we need to avoid blocking on the write +operation: + +.. code-block:: javascript + + let size = 1024 * 1024; + proc.stdin.write(new ArrayBuffer(size)); + + let result = await proc.stdout.read(size); + +There is no silver bullet to avoiding deadlocks in this type of situation, +though. Any input operations that depend on output operations, or vice versa, +have the possibility of triggering deadlocks, and need to be thought out +carefully. + +Arguments +========= + +Arguments may be passed to the process in the form an array of strings. +Arguments are never split, or subjected to any sort of shell expansion, so the +target process will receive the exact arguments array as passed to +``Subprocess.call``. Argument 0 will always be the full path to the +executable, as passed via the ``command`` argument: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/sh", + arguments: ["-c", "echo -n $0"], + }); + + let output = await proc.stdout.readString(); + assert(output === "/bin/sh"); + + +Process Environment +=================== + +By default, the process is launched with the same environment variables and +working directory as the parent process, but either can be changed if +necessary. The working directory may be changed simply by passing a +``workdir`` option: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + workdir: "/tmp", + }); + + let output = await proc.stdout.readString(); + assert(output === "/tmp\n"); + +The process's environment variables can be changed using the ``environment`` +and ``environmentAppend`` options. By default, passing an ``environment`` +object replaces the process's entire environment with the properties in that +object: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + environment: {FOO: "BAR"}, + }); + + let output = await proc.stdout.readString(); + assert(output === "FOO=BAR\n"); + +In order to add variables to, or change variables from, the current set of +environment variables, the ``environmentAppend`` object must be passed in +addition: + +.. code-block:: javascript + + let proc = await Subprocess.call({ + command: "/bin/pwd", + environment: {FOO: "BAR"}, + environmentAppend: true, + }); + + let output = ""; + while ((string = await proc.stdout.readString())) { + output += string; + } + + assert(output.includes("FOO=BAR\n")); diff --git a/toolkit/modules/subprocess/moz.build b/toolkit/modules/subprocess/moz.build new file mode 100644 index 0000000000..8c79d7f20c --- /dev/null +++ b/toolkit/modules/subprocess/moz.build @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES += [ + "Subprocess.sys.mjs", +] + +EXTRA_JS_MODULES.subprocess += [ + "subprocess_common.sys.mjs", + "subprocess_shared.js", + "subprocess_worker_common.js", +] + +if CONFIG["OS_TARGET"] == "WINNT": + EXTRA_JS_MODULES.subprocess += [ + "subprocess_shared_win.js", + "subprocess_win.sys.mjs", + "subprocess_win.worker.js", + ] +else: + EXTRA_JS_MODULES.subprocess += [ + "subprocess_shared_unix.js", + "subprocess_unix.sys.mjs", + "subprocess_unix.worker.js", + ] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +SPHINX_TREES["toolkit_modules/subprocess"] = ["docs"] + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] diff --git a/toolkit/modules/subprocess/subprocess_common.sys.mjs b/toolkit/modules/subprocess/subprocess_common.sys.mjs new file mode 100644 index 0000000000..aa3a28fb7b --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_common.sys.mjs @@ -0,0 +1,711 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable mozilla/balanced-listeners */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +var obj = {}; +Services.scriptloader.loadSubScript( + "resource://gre/modules/subprocess/subprocess_shared.js", + obj +); + +const { ArrayBuffer_transfer } = obj; +export const SubprocessConstants = obj.SubprocessConstants; + +const BUFFER_SIZE = 32768; + +let nextResponseId = 0; + +/** + * Wraps a ChromeWorker so that messages sent to it return a promise which + * resolves when the message has been received and the operation it triggers is + * complete. + */ +export class PromiseWorker extends ChromeWorker { + constructor(url) { + super(url); + + this.listeners = new Map(); + this.pendingResponses = new Map(); + + this.addListener("close", this.onClose.bind(this)); + this.addListener("failure", this.onFailure.bind(this)); + this.addListener("success", this.onSuccess.bind(this)); + this.addListener("debug", this.onDebug.bind(this)); + + this.addEventListener("message", this.onmessage); + + this.shutdown = this.shutdown.bind(this); + lazy.AsyncShutdown.webWorkersShutdown.addBlocker( + "Subprocess.sys.mjs: Shut down IO worker", + this.shutdown + ); + } + + onClose() { + lazy.AsyncShutdown.webWorkersShutdown.removeBlocker(this.shutdown); + } + + shutdown() { + return this.call("shutdown", []); + } + + /** + * Adds a listener for the given message from the worker. Any message received + * from the worker with a `data.msg` property matching the given `msg` + * parameter are passed to the given listener. + * + * @param {string} msg + * The message to listen for. + * @param {function(Event)} listener + * The listener to call when matching messages are received. + */ + addListener(msg, listener) { + if (!this.listeners.has(msg)) { + this.listeners.set(msg, new Set()); + } + this.listeners.get(msg).add(listener); + } + + /** + * Removes the given message listener. + * + * @param {string} msg + * The message to stop listening for. + * @param {function(Event)} listener + * The listener to remove. + */ + removeListener(msg, listener) { + let listeners = this.listeners.get(msg); + if (listeners) { + listeners.delete(listener); + + if (!listeners.size) { + this.listeners.delete(msg); + } + } + } + + onmessage(event) { + let { msg } = event.data; + let listeners = this.listeners.get(msg) || new Set(); + + for (let listener of listeners) { + try { + listener(event.data); + } catch (e) { + console.error(e); + } + } + } + + /** + * Called when a message sent to the worker has failed, and rejects its + * corresponding promise. + * + * @private + */ + onFailure({ msgId, error }) { + this.pendingResponses.get(msgId).reject(error); + this.pendingResponses.delete(msgId); + } + + /** + * Called when a message sent to the worker has succeeded, and resolves its + * corresponding promise. + * + * @private + */ + onSuccess({ msgId, data }) { + this.pendingResponses.get(msgId).resolve(data); + this.pendingResponses.delete(msgId); + } + + onDebug({ message }) { + dump(`Worker debug: ${message}\n`); + } + + /** + * Calls the given method in the worker, and returns a promise which resolves + * or rejects when the method has completed. + * + * @param {string} method + * The name of the method to call. + * @param {Array} args + * The arguments to pass to the method. + * @param {Array} [transferList] + * A list of objects to transfer to the worker, rather than cloning. + * @returns {Promise} + */ + call(method, args, transferList = []) { + let msgId = nextResponseId++; + + return new Promise((resolve, reject) => { + this.pendingResponses.set(msgId, { resolve, reject }); + + let message = { + msg: method, + msgId, + args, + }; + + this.postMessage(message, transferList); + }); + } +} + +/** + * Represents an input or output pipe connected to a subprocess. + * + * @property {integer} fd + * The file descriptor number of the pipe on the child process's side. + * @readonly + */ +class Pipe { + /** + * @param {Process} process + * The child process that this pipe is connected to. + * @param {integer} fd + * The file descriptor number of the pipe on the child process's side. + * @param {integer} id + * The internal ID of the pipe, which ties it to the corresponding Pipe + * object on the Worker side. + */ + constructor(process, fd, id) { + this.id = id; + this.fd = fd; + this.processId = process.id; + this.worker = process.worker; + + /** + * @property {boolean} closed + * True if the file descriptor has been closed, and can no longer + * be read from or written to. Pending IO operations may still + * complete, but new operations may not be initiated. + * @readonly + */ + this.closed = false; + } + + /** + * Closes the end of the pipe which belongs to this process. + * + * @param {boolean} force + * If true, the pipe is closed immediately, regardless of any pending + * IO operations. If false, the pipe is closed after any existing + * pending IO operations have completed. + * @returns {Promise<object>} + * Resolves to an object with no properties once the pipe has been + * closed. + */ + close(force = false) { + this.closed = true; + return this.worker.call("close", [this.id, force]); + } +} + +/** + * Represents an output-only pipe, to which data may be written. + */ +class OutputPipe extends Pipe { + constructor(...args) { + super(...args); + + this.encoder = new TextEncoder(); + } + + /** + * Writes the given data to the stream. + * + * When given an array buffer or typed array, ownership of the buffer is + * transferred to the IO worker, and it may no longer be used from this + * thread. + * + * @param {ArrayBuffer|TypedArray|string} buffer + * Data to write to the stream. + * @returns {Promise<object>} + * Resolves to an object with a `bytesWritten` property, containing + * the number of bytes successfully written, once the operation has + * completed. + * + * @throws {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * all of the data in `buffer` could be written to it. + */ + write(buffer) { + if (typeof buffer === "string") { + buffer = this.encoder.encode(buffer); + } + + if (Cu.getClassName(buffer, true) !== "ArrayBuffer") { + if (buffer.byteLength === buffer.buffer.byteLength) { + buffer = buffer.buffer; + } else { + buffer = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ); + } + } + + let args = [this.id, buffer]; + + return this.worker.call("write", args, [buffer]); + } +} + +/** + * Represents an input-only pipe, from which data may be read. + */ +class InputPipe extends Pipe { + constructor(...args) { + super(...args); + + this.buffers = []; + + /** + * @property {integer} dataAvailable + * The number of readable bytes currently stored in the input + * buffer. + * @readonly + */ + this.dataAvailable = 0; + + this.decoder = new TextDecoder(); + + this.pendingReads = []; + + this._pendingBufferRead = null; + + this.fillBuffer(); + } + + /** + * @property {integer} bufferSize + * The current size of the input buffer. This varies depending on + * the size of pending read operations. + * @readonly + */ + get bufferSize() { + if (this.pendingReads.length) { + return Math.max(this.pendingReads[0].length, BUFFER_SIZE); + } + return BUFFER_SIZE; + } + + /** + * Attempts to fill the input buffer. + * + * @private + */ + fillBuffer() { + let dataWanted = this.bufferSize - this.dataAvailable; + + if (!this._pendingBufferRead && dataWanted > 0) { + this._pendingBufferRead = this._read(dataWanted); + + this._pendingBufferRead.then(result => { + this._pendingBufferRead = null; + + if (result) { + this.onInput(result.buffer); + + this.fillBuffer(); + } + }); + } + } + + _read(size) { + let args = [this.id, size]; + + return this.worker.call("read", args).catch(e => { + this.closed = true; + + for (let { length, resolve, reject } of this.pendingReads.splice(0)) { + if ( + length === null && + e.errorCode === SubprocessConstants.ERROR_END_OF_FILE + ) { + resolve(new ArrayBuffer(0)); + } else { + reject(e); + } + } + }); + } + + /** + * Adds the given data to the end of the input buffer. + * + * @param {ArrayBuffer} buffer + * An input buffer to append to the current buffered input. + * @private + */ + onInput(buffer) { + this.buffers.push(buffer); + this.dataAvailable += buffer.byteLength; + this.checkPendingReads(); + } + + /** + * Checks the topmost pending read operations and fulfills as many as can be + * filled from the current input buffer. + * + * @private + */ + checkPendingReads() { + this.fillBuffer(); + + let reads = this.pendingReads; + while ( + reads.length && + this.dataAvailable && + reads[0].length <= this.dataAvailable + ) { + let pending = this.pendingReads.shift(); + + let length = pending.length || this.dataAvailable; + + let result; + let byteLength = this.buffers[0].byteLength; + if (byteLength == length) { + result = this.buffers.shift(); + } else if (byteLength > length) { + let buffer = this.buffers[0]; + + this.buffers[0] = buffer.slice(length); + result = ArrayBuffer_transfer(buffer, length); + } else { + result = ArrayBuffer_transfer(this.buffers.shift(), length); + let u8result = new Uint8Array(result); + + while (byteLength < length) { + let buffer = this.buffers[0]; + let u8buffer = new Uint8Array(buffer); + + let remaining = length - byteLength; + + if (buffer.byteLength <= remaining) { + this.buffers.shift(); + + u8result.set(u8buffer, byteLength); + } else { + this.buffers[0] = buffer.slice(remaining); + + u8result.set(u8buffer.subarray(0, remaining), byteLength); + } + + byteLength += Math.min(buffer.byteLength, remaining); + } + } + + this.dataAvailable -= result.byteLength; + pending.resolve(result); + } + } + + /** + * Reads exactly `length` bytes of binary data from the input stream, or, if + * length is not provided, reads the first chunk of data to become available. + * In the latter case, returns an empty array buffer on end of file. + * + * The read operation will not complete until enough data is available to + * fulfill the request. If the pipe closes without enough available data to + * fulfill the read, the operation fails, and any remaining buffered data is + * lost. + * + * @param {integer} [length] + * The number of bytes to read. + * @returns {Promise<ArrayBuffer>} + * + * @throws {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + read(length = null) { + if (length !== null && !(Number.isInteger(length) && length >= 0)) { + throw new RangeError("Length must be a non-negative integer"); + } + + if (length == 0) { + return Promise.resolve(new ArrayBuffer(0)); + } + + return new Promise((resolve, reject) => { + this.pendingReads.push({ length, resolve, reject }); + this.checkPendingReads(); + }); + } + + /** + * Reads exactly `length` bytes from the input stream, and parses them as + * UTF-8 JSON data. + * + * @param {integer} length + * The number of bytes to read. + * @returns {Promise<object>} + * + * @throws {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + * - Subprocess.ERROR_INVALID_JSON: The data read from the pipe + * could not be parsed as a valid JSON string. + */ + readJSON(length) { + if (!Number.isInteger(length) || length <= 0) { + throw new RangeError("Length must be a positive integer"); + } + + return this.readString(length).then(string => { + try { + return JSON.parse(string); + } catch (e) { + e.errorCode = SubprocessConstants.ERROR_INVALID_JSON; + throw e; + } + }); + } + + /** + * Reads a chunk of UTF-8 data from the input stream, and converts it to a + * JavaScript string. + * + * If `length` is provided, reads exactly `length` bytes. Otherwise, reads the + * first chunk of data to become available, and returns an empty string on end + * of file. In the latter case, the chunk is decoded in streaming mode, and + * any incomplete UTF-8 sequences at the end of a chunk are returned at the + * start of a subsequent read operation. + * + * @param {integer} [length] + * The number of bytes to read. + * @param {object} [options] + * An options object as expected by TextDecoder.decode. + * @returns {Promise<string>} + * + * @throws {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + readString(length = null, options = { stream: length === null }) { + if (length !== null && !(Number.isInteger(length) && length >= 0)) { + throw new RangeError("Length must be a non-negative integer"); + } + + return this.read(length).then(buffer => { + return this.decoder.decode(buffer, options); + }); + } + + /** + * Reads 4 bytes from the input stream, and parses them as an unsigned + * integer, in native byte order. + * + * @returns {Promise<integer>} + * + * @throws {object} + * May be rejected with an Error object, or an object with similar + * properties. The object will include an `errorCode` property with + * one of the following values if it was rejected for the + * corresponding reason: + * + * - Subprocess.ERROR_END_OF_FILE: The pipe was closed before + * enough input could be read to satisfy the request. + */ + readUint32() { + return this.read(4).then(buffer => { + return new Uint32Array(buffer)[0]; + }); + } +} + +/** + * @class Process + * @augments BaseProcess + */ + +/** + * Represents a currently-running process, and allows interaction with it. + */ +export class BaseProcess { + /** + * @param {PromiseWorker} worker + * The worker instance which owns the process. + * @param {integer} processId + * The internal ID of the Process object, which ties it to the + * corresponding process on the Worker side. + * @param {integer[]} fds + * An array of internal Pipe IDs, one for each standard file descriptor + * in the child process. + * @param {integer} pid + * The operating system process ID of the process. + */ + constructor(worker, processId, fds, pid) { + this.id = processId; + this.worker = worker; + + /** + * @property {integer} pid + * The process ID of the process, assigned by the operating system. + * @readonly + */ + this.pid = pid; + + this.exitCode = null; + + this.exitPromise = new Promise(resolve => { + this.worker.call("wait", [this.id]).then(({ exitCode }) => { + resolve(Object.freeze({ exitCode })); + this.exitCode = exitCode; + }); + }); + + if (fds[0] !== undefined) { + /** + * @property {OutputPipe} stdin + * A Pipe object which allows writing to the process's standard + * input. + * @readonly + */ + this.stdin = new OutputPipe(this, 0, fds[0]); + } + if (fds[1] !== undefined) { + /** + * @property {InputPipe} stdout + * A Pipe object which allows reading from the process's standard + * output. + * @readonly + */ + this.stdout = new InputPipe(this, 1, fds[1]); + } + if (fds[2] !== undefined) { + /** + * @property {InputPipe} [stderr] + * An optional Pipe object which allows reading from the + * process's standard error output. + * @readonly + */ + this.stderr = new InputPipe(this, 2, fds[2]); + } + } + + /** + * Spawns a process, and resolves to a BaseProcess instance on success. + * + * @param {object} options + * An options object as passed to `Subprocess.call`. + * + * @returns {Promise<BaseProcess>} + */ + static create(options) { + let worker = this.getWorker(); + + return worker.call("spawn", [options]).then(({ processId, fds, pid }) => { + return new this(worker, processId, fds, pid); + }); + } + + static get WORKER_URL() { + throw new Error("Not implemented"); + } + + static get WorkerClass() { + return PromiseWorker; + } + + /** + * Gets the current subprocess worker, or spawns a new one if it does not + * currently exist. + * + * @returns {PromiseWorker} + */ + static getWorker() { + if (!this._worker) { + this._worker = new this.WorkerClass(this.WORKER_URL); + } + return this._worker; + } + + /** + * Kills the process. + * + * @param {integer} [timeout=300] + * A timeout, in milliseconds, after which the process will be forcibly + * killed. On platforms which support it, the process will be sent + * a `SIGTERM` signal immediately, so that it has a chance to terminate + * gracefully, and a `SIGKILL` signal if it hasn't exited within + * `timeout` milliseconds. On other platforms (namely Windows), the + * process will be forcibly terminated immediately. + * + * @returns {Promise<object>} + * Resolves to an object with an `exitCode` property when the process + * has exited. + */ + kill(timeout = 300) { + // If the process has already exited, don't bother sending a signal. + if (this.exitCode != null) { + return this.wait(); + } + + let force = timeout <= 0; + this.worker.call("kill", [this.id, force]); + + if (!force) { + lazy.setTimeout(() => { + if (this.exitCode == null) { + this.worker.call("kill", [this.id, true]); + } + }, timeout); + } + + return this.wait(); + } + + /** + * Returns a promise which resolves to the process's exit code, once it has + * exited. + * + * @returns {Promise<object>} + * Resolves to an object with an `exitCode` property, containing the + * process's exit code, once the process has exited. + * + * On Unix-like systems, a negative exit code indicates that the + * process was killed by a signal whose signal number is the absolute + * value of the error code. On Windows, an exit code of -9 indicates + * that the process was killed via the {@linkcode BaseProcess#kill kill()} + * method. + */ + wait() { + return this.exitPromise; + } +} diff --git a/toolkit/modules/subprocess/subprocess_shared.js b/toolkit/modules/subprocess/subprocess_shared.js new file mode 100644 index 0000000000..d59a14a351 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared.js @@ -0,0 +1,108 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* exported ArrayBuffer_transfer, Library, SubprocessConstants */ + +// ctypes is either already available in the chrome worker scope, or defined +// in scope via loadSubScript. +/* global ctypes */ + +/** + * Returns a new ArrayBuffer whose contents have been taken from the `buffer`'s + * data and then is either truncated or zero-extended by `size`. If `size` is + * undefined, the `byteLength` of the `buffer` is used. This operation leaves + * `buffer` in a detached state. + * + * @param {ArrayBuffer} buffer + * @param {integer} [size = buffer.byteLength] + * @returns {ArrayBuffer} + */ +var ArrayBuffer_transfer = function (buffer, size = buffer.byteLength) { + let u8out = new Uint8Array(size); + let u8buffer = new Uint8Array(buffer, 0, Math.min(size, buffer.byteLength)); + u8out.set(u8buffer); + return u8out.buffer; +}; + +var libraries = {}; + +var Library = class Library { + constructor(name, names, definitions) { + if (name in libraries) { + return libraries[name]; + } + + for (let name of names) { + try { + if (!this.library) { + this.library = ctypes.open(name); + } + } catch (e) { + // Ignore errors until we've tried all the options. + } + } + if (!this.library) { + throw new Error("Could not load libc"); + } + + libraries[name] = this; + + for (let symbol of Object.keys(definitions)) { + this.declare(symbol, ...definitions[symbol]); + } + } + + declare(name, ...args) { + Object.defineProperty(this, name, { + configurable: true, + get() { + Object.defineProperty(this, name, { + configurable: true, + value: this.library.declare(name, ...args), + }); + + return this[name]; + }, + }); + } +}; + +/** + * Holds constants which apply to various Subprocess operations. + * + * @namespace + * @lends Subprocess + */ +var SubprocessConstants = { + /** + * @property {integer} ERROR_END_OF_FILE + * The operation failed because the end of the file was reached. + * @constant + */ + ERROR_END_OF_FILE: 0xff7a0001, + /** + * @property {integer} ERROR_INVALID_JSON + * The operation failed because an invalid JSON was encountered. + * @constant + */ + ERROR_INVALID_JSON: 0xff7a0002, + /** + * @property {integer} ERROR_BAD_EXECUTABLE + * The operation failed because the given file did not exist, or + * could not be executed. + * @constant + */ + ERROR_BAD_EXECUTABLE: 0xff7a0003, + /** + * @property {integer} ERROR_INVALID_OPTION + * The operation failed because an invalid option was provided. + * @constant + */ + ERROR_INVALID_OPTION: 0xff7a0004, +}; + +Object.freeze(SubprocessConstants); diff --git a/toolkit/modules/subprocess/subprocess_shared_unix.js b/toolkit/modules/subprocess/subprocess_shared_unix.js new file mode 100644 index 0000000000..345e5122e4 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_unix.js @@ -0,0 +1,116 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* exported LIBC, libc */ + +// ctypes is either already available in the chrome worker scope, or defined +// in scope via loadSubScript. +/* global ctypes */ + +// This file is loaded into the same scope as subprocess_shared.js. +/* import-globals-from subprocess_shared.js */ + +var LIBC = ChromeUtils.getLibcConstants(); + +const LIBC_CHOICES = ["a.out"]; + +const unix = { + pid_t: ctypes.int32_t, + + pollfd: new ctypes.StructType("pollfd", [ + { fd: ctypes.int }, + { events: ctypes.short }, + { revents: ctypes.short }, + ]), + + WEXITSTATUS(status) { + return (status >> 8) & 0xff; + }, + + WTERMSIG(status) { + return status & 0x7f; + }, +}; + +var libc = new Library("libc", LIBC_CHOICES, { + environ: [ctypes.char.ptr.ptr], + + // Darwin-only. + _NSGetEnviron: [ctypes.default_abi, ctypes.char.ptr.ptr.ptr], + + setenv: [ + ctypes.default_abi, + ctypes.int, + ctypes.char.ptr, + ctypes.char.ptr, + ctypes.int, + ], + + chdir: [ctypes.default_abi, ctypes.int, ctypes.char.ptr /* path */], + + close: [ctypes.default_abi, ctypes.int, ctypes.int /* fildes */], + + fcntl: [ + ctypes.default_abi, + ctypes.int, + ctypes.int /* fildes */, + ctypes.int /* cmd */, + ctypes.int /* ... */, + ], + + getcwd: [ + ctypes.default_abi, + ctypes.char.ptr, + ctypes.char.ptr /* buf */, + ctypes.size_t /* size */, + ], + + kill: [ + ctypes.default_abi, + ctypes.int, + unix.pid_t /* pid */, + ctypes.int /* signal */, + ], + + pipe: [ctypes.default_abi, ctypes.int, ctypes.int.array(2) /* pipefd */], + + poll: [ + ctypes.default_abi, + ctypes.int, + unix.pollfd.array() /* fds */, + ctypes.unsigned_int /* nfds */, + ctypes.int /* timeout */, + ], + + read: [ + ctypes.default_abi, + ctypes.ssize_t, + ctypes.int /* fildes */, + ctypes.char.ptr /* buf */, + ctypes.size_t /* nbyte */, + ], + + waitpid: [ + ctypes.default_abi, + unix.pid_t, + unix.pid_t /* pid */, + ctypes.int.ptr /* status */, + ctypes.int /* options */, + ], + + write: [ + ctypes.default_abi, + ctypes.ssize_t, + ctypes.int /* fildes */, + ctypes.char.ptr /* buf */, + ctypes.size_t /* nbyte */, + ], +}); + +unix.Fd = function (fd) { + return ctypes.CDataFinalizer(ctypes.int(fd), libc.close); +}; diff --git a/toolkit/modules/subprocess/subprocess_shared_win.js b/toolkit/modules/subprocess/subprocess_shared_win.js new file mode 100644 index 0000000000..964e647128 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_win.js @@ -0,0 +1,532 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* exported createPipe, libc, win32 */ + +// ctypes is either already available in the chrome worker scope, or defined +// in scope via loadSubScript. +/* global ctypes */ + +// This file is loaded into the same scope as subprocess_shared.js. +/* import-globals-from subprocess_shared.js */ + +const LIBC_CHOICES = ["kernel32.dll"]; + +var win32 = { + // On Windows 64, winapi_abi is an alias for default_abi. + WINAPI: ctypes.winapi_abi, + + VOID: ctypes.void_t, + + BYTE: ctypes.uint8_t, + WORD: ctypes.uint16_t, + DWORD: ctypes.uint32_t, + LONG: ctypes.long, + LARGE_INTEGER: ctypes.int64_t, + ULONGLONG: ctypes.uint64_t, + + UINT: ctypes.unsigned_int, + UCHAR: ctypes.unsigned_char, + + BOOL: ctypes.bool, + + HANDLE: ctypes.voidptr_t, + PVOID: ctypes.voidptr_t, + LPVOID: ctypes.voidptr_t, + + CHAR: ctypes.char, + WCHAR: ctypes.jschar, + + ULONG_PTR: ctypes.uintptr_t, + + SIZE_T: ctypes.size_t, + PSIZE_T: ctypes.size_t.ptr, +}; + +Object.assign(win32, { + DWORD_PTR: win32.ULONG_PTR, + + LPSTR: win32.CHAR.ptr, + LPWSTR: win32.WCHAR.ptr, + + LPBYTE: win32.BYTE.ptr, + LPDWORD: win32.DWORD.ptr, + LPHANDLE: win32.HANDLE.ptr, + + // This is an opaque type. + PROC_THREAD_ATTRIBUTE_LIST: ctypes.char.array(), + LPPROC_THREAD_ATTRIBUTE_LIST: ctypes.char.ptr, +}); + +Object.assign(win32, { + LPCSTR: win32.LPSTR, + LPCWSTR: win32.LPWSTR, + LPCVOID: win32.LPVOID, +}); + +Object.assign(win32, { + INVALID_HANDLE_VALUE: ctypes.cast(ctypes.int64_t(-1), win32.HANDLE), + NULL_HANDLE_VALUE: ctypes.cast(ctypes.uintptr_t(0), win32.HANDLE), + + CREATE_SUSPENDED: 0x00000004, + CREATE_NEW_CONSOLE: 0x00000010, + CREATE_UNICODE_ENVIRONMENT: 0x00000400, + CREATE_NO_WINDOW: 0x08000000, + CREATE_BREAKAWAY_FROM_JOB: 0x01000000, + EXTENDED_STARTUPINFO_PRESENT: 0x00080000, + + STARTF_USESTDHANDLES: 0x0100, + + DUPLICATE_CLOSE_SOURCE: 0x01, + DUPLICATE_SAME_ACCESS: 0x02, + + ERROR_HANDLE_EOF: 38, + ERROR_BROKEN_PIPE: 109, + ERROR_INSUFFICIENT_BUFFER: 122, + + FILE_ATTRIBUTE_NORMAL: 0x00000080, + FILE_FLAG_OVERLAPPED: 0x40000000, + + GENERIC_WRITE: 0x40000000, + + OPEN_EXISTING: 0x00000003, + + PIPE_TYPE_BYTE: 0x00, + + PIPE_ACCESS_INBOUND: 0x01, + PIPE_ACCESS_OUTBOUND: 0x02, + PIPE_ACCESS_DUPLEX: 0x03, + + PIPE_WAIT: 0x00, + PIPE_NOWAIT: 0x01, + + STILL_ACTIVE: 259, + + PROC_THREAD_ATTRIBUTE_HANDLE_LIST: 0x00020002, + + JobObjectBasicLimitInformation: 2, + JobObjectExtendedLimitInformation: 9, + + JOB_OBJECT_LIMIT_BREAKAWAY_OK: 0x00000800, + + // These constants are 32-bit unsigned integers, but Windows defines + // them as negative integers cast to an unsigned type. + STD_INPUT_HANDLE: -10 + 0x100000000, + STD_OUTPUT_HANDLE: -11 + 0x100000000, + STD_ERROR_HANDLE: -12 + 0x100000000, + + WAIT_TIMEOUT: 0x00000102, + WAIT_FAILED: 0xffffffff, +}); + +Object.assign(win32, { + JOBOBJECT_BASIC_LIMIT_INFORMATION: new ctypes.StructType( + "JOBOBJECT_BASIC_LIMIT_INFORMATION", + [ + { PerProcessUserTimeLimit: win32.LARGE_INTEGER }, + { PerJobUserTimeLimit: win32.LARGE_INTEGER }, + { LimitFlags: win32.DWORD }, + { MinimumWorkingSetSize: win32.SIZE_T }, + { MaximumWorkingSetSize: win32.SIZE_T }, + { ActiveProcessLimit: win32.DWORD }, + { Affinity: win32.ULONG_PTR }, + { PriorityClass: win32.DWORD }, + { SchedulingClass: win32.DWORD }, + ] + ), + + IO_COUNTERS: new ctypes.StructType("IO_COUNTERS", [ + { ReadOperationCount: win32.ULONGLONG }, + { WriteOperationCount: win32.ULONGLONG }, + { OtherOperationCount: win32.ULONGLONG }, + { ReadTransferCount: win32.ULONGLONG }, + { WriteTransferCount: win32.ULONGLONG }, + { OtherTransferCount: win32.ULONGLONG }, + ]), +}); + +Object.assign(win32, { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION: new ctypes.StructType( + "JOBOBJECT_EXTENDED_LIMIT_INFORMATION", + [ + { BasicLimitInformation: win32.JOBOBJECT_BASIC_LIMIT_INFORMATION }, + { IoInfo: win32.IO_COUNTERS }, + { ProcessMemoryLimit: win32.SIZE_T }, + { JobMemoryLimit: win32.SIZE_T }, + { PeakProcessMemoryUsed: win32.SIZE_T }, + { PeakJobMemoryUsed: win32.SIZE_T }, + ] + ), + + OVERLAPPED: new ctypes.StructType("OVERLAPPED", [ + { Internal: win32.ULONG_PTR }, + { InternalHigh: win32.ULONG_PTR }, + { Offset: win32.DWORD }, + { OffsetHigh: win32.DWORD }, + { hEvent: win32.HANDLE }, + ]), + + PROCESS_INFORMATION: new ctypes.StructType("PROCESS_INFORMATION", [ + { hProcess: win32.HANDLE }, + { hThread: win32.HANDLE }, + { dwProcessId: win32.DWORD }, + { dwThreadId: win32.DWORD }, + ]), + + SECURITY_ATTRIBUTES: new ctypes.StructType("SECURITY_ATTRIBUTES", [ + { nLength: win32.DWORD }, + { lpSecurityDescriptor: win32.LPVOID }, + { bInheritHandle: win32.BOOL }, + ]), + + STARTUPINFOW: new ctypes.StructType("STARTUPINFOW", [ + { cb: win32.DWORD }, + { lpReserved: win32.LPWSTR }, + { lpDesktop: win32.LPWSTR }, + { lpTitle: win32.LPWSTR }, + { dwX: win32.DWORD }, + { dwY: win32.DWORD }, + { dwXSize: win32.DWORD }, + { dwYSize: win32.DWORD }, + { dwXCountChars: win32.DWORD }, + { dwYCountChars: win32.DWORD }, + { dwFillAttribute: win32.DWORD }, + { dwFlags: win32.DWORD }, + { wShowWindow: win32.WORD }, + { cbReserved2: win32.WORD }, + { lpReserved2: win32.LPBYTE }, + { hStdInput: win32.HANDLE }, + { hStdOutput: win32.HANDLE }, + { hStdError: win32.HANDLE }, + ]), +}); + +Object.assign(win32, { + STARTUPINFOEXW: new ctypes.StructType("STARTUPINFOEXW", [ + { StartupInfo: win32.STARTUPINFOW }, + { lpAttributeList: win32.LPPROC_THREAD_ATTRIBUTE_LIST }, + ]), +}); + +var libc = new Library("libc", LIBC_CHOICES, { + AssignProcessToJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hJob */, + win32.HANDLE /* hProcess */, + ], + + CloseHandle: [win32.WINAPI, win32.BOOL, win32.HANDLE /* hObject */], + + CreateEventW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpEventAttributes */, + win32.BOOL /* bManualReset */, + win32.BOOL /* bInitialState */, + win32.LPWSTR /* lpName */, + ], + + CreateFileW: [ + win32.WINAPI, + win32.HANDLE, + win32.LPWSTR /* lpFileName */, + win32.DWORD /* dwDesiredAccess */, + win32.DWORD /* dwShareMode */, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpSecurityAttributes */, + win32.DWORD /* dwCreationDisposition */, + win32.DWORD /* dwFlagsAndAttributes */, + win32.HANDLE /* opt hTemplateFile */, + ], + + CreateJobObjectW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpJobAttributes */, + win32.LPWSTR /* lpName */, + ], + + CreateNamedPipeW: [ + win32.WINAPI, + win32.HANDLE, + win32.LPWSTR /* lpName */, + win32.DWORD /* dwOpenMode */, + win32.DWORD /* dwPipeMode */, + win32.DWORD /* nMaxInstances */, + win32.DWORD /* nOutBufferSize */, + win32.DWORD /* nInBufferSize */, + win32.DWORD /* nDefaultTimeOut */, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpSecurityAttributes */, + ], + + CreatePipe: [ + win32.WINAPI, + win32.BOOL, + win32.LPHANDLE /* out hReadPipe */, + win32.LPHANDLE /* out hWritePipe */, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpPipeAttributes */, + win32.DWORD /* nSize */, + ], + + CreateProcessW: [ + win32.WINAPI, + win32.BOOL, + win32.LPCWSTR /* lpApplicationName */, + win32.LPWSTR /* lpCommandLine */, + win32.SECURITY_ATTRIBUTES.ptr /* lpProcessAttributes */, + win32.SECURITY_ATTRIBUTES.ptr /* lpThreadAttributes */, + win32.BOOL /* bInheritHandle */, + win32.DWORD /* dwCreationFlags */, + win32.LPVOID /* opt lpEnvironment */, + win32.LPCWSTR /* opt lpCurrentDirectory */, + win32.STARTUPINFOW.ptr /* lpStartupInfo */, + win32.PROCESS_INFORMATION.ptr /* out lpProcessInformation */, + ], + + CreateSemaphoreW: [ + win32.WINAPI, + win32.HANDLE, + win32.SECURITY_ATTRIBUTES.ptr /* opt lpSemaphoreAttributes */, + win32.LONG /* lInitialCount */, + win32.LONG /* lMaximumCount */, + win32.LPCWSTR /* opt lpName */, + ], + + DeleteProcThreadAttributeList: [ + win32.WINAPI, + win32.VOID, + win32.LPPROC_THREAD_ATTRIBUTE_LIST /* in/out lpAttributeList */, + ], + + DuplicateHandle: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hSourceProcessHandle */, + win32.HANDLE /* hSourceHandle */, + win32.HANDLE /* hTargetProcessHandle */, + win32.LPHANDLE /* out lpTargetHandle */, + win32.DWORD /* dwDesiredAccess */, + win32.BOOL /* bInheritHandle */, + win32.DWORD /* dwOptions */, + ], + + FreeEnvironmentStringsW: [ + win32.WINAPI, + win32.BOOL, + win32.LPCWSTR /* lpszEnvironmentBlock */, + ], + + GetCurrentProcess: [win32.WINAPI, win32.HANDLE], + + GetCurrentProcessId: [win32.WINAPI, win32.DWORD], + + GetEnvironmentStringsW: [win32.WINAPI, win32.LPCWSTR], + + GetExitCodeProcess: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hProcess */, + win32.LPDWORD /* lpExitCode */, + ], + + GetOverlappedResult: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hFile */, + win32.OVERLAPPED.ptr /* lpOverlapped */, + win32.LPDWORD /* lpNumberOfBytesTransferred */, + win32.BOOL /* bWait */, + ], + + GetStdHandle: [win32.WINAPI, win32.HANDLE, win32.DWORD /* nStdHandle */], + + InitializeProcThreadAttributeList: [ + win32.WINAPI, + win32.BOOL, + win32.LPPROC_THREAD_ATTRIBUTE_LIST /* out opt lpAttributeList */, + win32.DWORD /* dwAttributeCount */, + win32.DWORD /* dwFlags */, + win32.PSIZE_T /* in/out lpSize */, + ], + + ReadFile: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hFile */, + win32.LPVOID /* out lpBuffer */, + win32.DWORD /* nNumberOfBytesToRead */, + win32.LPDWORD /* opt out lpNumberOfBytesRead */, + win32.OVERLAPPED.ptr /* opt in/out lpOverlapped */, + ], + + ReleaseSemaphore: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hSemaphore */, + win32.LONG /* lReleaseCount */, + win32.LONG.ptr /* opt out lpPreviousCount */, + ], + + ResumeThread: [win32.WINAPI, win32.DWORD, win32.HANDLE /* hThread */], + + SetInformationJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hJob */, + ctypes.int /* JobObjectInfoClass */, + win32.LPVOID /* lpJobObjectInfo */, + win32.DWORD /* cbJobObjectInfoLengt */, + ], + + TerminateJobObject: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hJob */, + win32.UINT /* uExitCode */, + ], + + TerminateProcess: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hProcess */, + win32.UINT /* uExitCode */, + ], + + UpdateProcThreadAttribute: [ + win32.WINAPI, + win32.BOOL, + win32.LPPROC_THREAD_ATTRIBUTE_LIST /* in/out lpAttributeList */, + win32.DWORD /* dwFlags */, + win32.DWORD_PTR /* Attribute */, + win32.PVOID /* lpValue */, + win32.SIZE_T /* cbSize */, + win32.PVOID /* out opt lpPreviousValue */, + win32.PSIZE_T /* opt lpReturnSize */, + ], + + WaitForMultipleObjects: [ + win32.WINAPI, + win32.DWORD, + win32.DWORD /* nCount */, + win32.HANDLE.ptr /* hHandles */, + win32.BOOL /* bWaitAll */, + win32.DWORD /* dwMilliseconds */, + ], + + WaitForSingleObject: [ + win32.WINAPI, + win32.DWORD, + win32.HANDLE /* hHandle */, + win32.BOOL /* bWaitAll */, + win32.DWORD /* dwMilliseconds */, + ], + + WriteFile: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE /* hFile */, + win32.LPCVOID /* lpBuffer */, + win32.DWORD /* nNumberOfBytesToRead */, + win32.LPDWORD /* opt out lpNumberOfBytesWritten */, + win32.OVERLAPPED.ptr /* opt in/out lpOverlapped */, + ], +}); + +let nextNamedPipeId = 0; + +win32.Handle = function (handle) { + return ctypes.CDataFinalizer(win32.HANDLE(handle), libc.CloseHandle); +}; + +win32.createPipe = function (secAttr, readFlags = 0, writeFlags = 0, size = 0) { + readFlags |= win32.PIPE_ACCESS_INBOUND; + writeFlags |= win32.FILE_ATTRIBUTE_NORMAL; + + if (size == 0) { + size = 4096; + } + + let pid = libc.GetCurrentProcessId(); + let pipeName = String.raw`\\.\Pipe\SubProcessPipe.${pid}.${nextNamedPipeId++}`; + + let readHandle = libc.CreateNamedPipeW( + pipeName, + readFlags, + win32.PIPE_TYPE_BYTE | win32.PIPE_WAIT, + 1 /* number of connections */, + size /* output buffer size */, + size /* input buffer size */, + 0 /* timeout */, + secAttr.address() + ); + + let isInvalid = handle => + String(handle) == String(win32.INVALID_HANDLE_VALUE); + + if (isInvalid(readHandle)) { + return []; + } + + let writeHandle = libc.CreateFileW( + pipeName, + win32.GENERIC_WRITE, + 0, + secAttr.address(), + win32.OPEN_EXISTING, + writeFlags, + null + ); + + if (isInvalid(writeHandle)) { + libc.CloseHandle(readHandle); + return []; + } + + return [win32.Handle(readHandle), win32.Handle(writeHandle)]; +}; + +win32.createThreadAttributeList = function (handles) { + try { + void libc.InitializeProcThreadAttributeList; + void libc.DeleteProcThreadAttributeList; + void libc.UpdateProcThreadAttribute; + } catch (e) { + // This is only supported in Windows Vista and later. + return null; + } + + let size = win32.SIZE_T(); + if ( + !libc.InitializeProcThreadAttributeList(null, 1, 0, size.address()) && + ctypes.winLastError != win32.ERROR_INSUFFICIENT_BUFFER + ) { + return null; + } + + let attrList = win32.PROC_THREAD_ATTRIBUTE_LIST(size.value); + + if (!libc.InitializeProcThreadAttributeList(attrList, 1, 0, size.address())) { + return null; + } + + let ok = libc.UpdateProcThreadAttribute( + attrList, + 0, + win32.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handles, + handles.constructor.size, + null, + null + ); + + if (!ok) { + libc.DeleteProcThreadAttributeList(attrList); + return null; + } + + return attrList; +}; diff --git a/toolkit/modules/subprocess/subprocess_unix.sys.mjs b/toolkit/modules/subprocess/subprocess_unix.sys.mjs new file mode 100644 index 0000000000..59b5873af2 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_unix.sys.mjs @@ -0,0 +1,203 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + BaseProcess, + PromiseWorker, +} from "resource://gre/modules/subprocess/subprocess_common.sys.mjs"; + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +var obj = { ctypes }; +Services.scriptloader.loadSubScript( + "resource://gre/modules/subprocess/subprocess_shared.js", + obj +); +Services.scriptloader.loadSubScript( + "resource://gre/modules/subprocess/subprocess_shared_unix.js", + obj +); + +const { SubprocessConstants, LIBC } = obj; + +// libc is exported for tests. +export var libc = obj.libc; + +class UnixPromiseWorker extends PromiseWorker { + constructor(...args) { + super(...args); + + let fds = ctypes.int.array(2)(); + let res = libc.pipe(fds); + if (res == -1) { + throw new Error("Unable to create pipe"); + } + + this.signalFd = fds[1]; + + libc.fcntl(fds[0], LIBC.F_SETFL, LIBC.O_NONBLOCK); + libc.fcntl(fds[0], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + + this.call("init", [{ signalFd: fds[0] }]); + } + + closePipe() { + if (this.signalFd) { + libc.close(this.signalFd); + this.signalFd = null; + } + } + + onClose() { + this.closePipe(); + super.onClose(); + } + + signalWorker() { + libc.write(this.signalFd, new ArrayBuffer(1), 1); + } + + postMessage(...args) { + this.signalWorker(); + return super.postMessage(...args); + } +} + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_unix.worker.js"; + } + + static get WorkerClass() { + return UnixPromiseWorker; + } +} + +// Convert a null-terminated char pointer into a sized char array, and then +// convert that into a JS typed array. +// The resulting array will not be null-terminated. +function ptrToUint8Array(input) { + let { cast, uint8_t } = ctypes; + + let len = 0; + for ( + let ptr = cast(input, uint8_t.ptr); + ptr.contents; + ptr = ptr.increment() + ) { + len++; + } + + let aryPtr = cast(input, uint8_t.array(len).ptr); + return new Uint8Array(aryPtr.contents); +} + +var SubprocessUnix = { + Process, + + call(options) { + return Process.create(options); + }, + + *getEnvironment() { + let environ; + if (Services.appinfo.OS === "Darwin") { + environ = libc._NSGetEnviron().contents; + } else { + environ = libc.environ; + } + + const EQUAL = "=".charCodeAt(0); + let decoder = new TextDecoder("utf-8", { fatal: true }); + + function decode(array) { + try { + return decoder.decode(array); + } catch (e) { + return array; + } + } + + for ( + let envp = environ; + !envp.isNull() && !envp.contents.isNull(); + envp = envp.increment() + ) { + let buf = ptrToUint8Array(envp.contents); + + for (let i = 0; i < buf.length; i++) { + if (buf[i] == EQUAL) { + yield [decode(buf.subarray(0, i)), decode(buf.subarray(i + 1))]; + break; + } + } + } + }, + + isExecutableFile: async function isExecutable(path) { + if (!PathUtils.isAbsolute(path)) { + return false; + } + + try { + let info = await IOUtils.stat(path); + + // FIXME: We really want access(path, X_OK) here, but IOUtils does not + // support it. + return info.type !== "directory" && info.permissions & 0o111; + } catch (e) { + return false; + } + }, + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHEXT + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} bin + * The name of the executable to find. + * @param {object} environment + * An object containing a key for each environment variable to be used + * in the search. + * @returns {Promise<string>} + */ + async pathSearch(bin, environment) { + if (PathUtils.isAbsolute(bin)) { + if (await this.isExecutableFile(bin)) { + return bin; + } + let error = new Error( + `File at path "${bin}" does not exist, or is not executable` + ); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + let dirs = []; + if (typeof environment.PATH === "string") { + dirs = environment.PATH.split(":"); + } + + for (let dir of dirs) { + let path = PathUtils.join(dir, bin); + + if (await this.isExecutableFile(path)) { + return path; + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }, +}; + +export var SubprocessImpl = SubprocessUnix; diff --git a/toolkit/modules/subprocess/subprocess_unix.worker.js b/toolkit/modules/subprocess/subprocess_unix.worker.js new file mode 100644 index 0000000000..85632d2398 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_unix.worker.js @@ -0,0 +1,611 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* exported Process */ + +/* import-globals-from subprocess_shared.js */ +/* import-globals-from subprocess_shared_unix.js */ +/* import-globals-from subprocess_worker_common.js */ +importScripts( + "resource://gre/modules/subprocess/subprocess_shared.js", + "resource://gre/modules/subprocess/subprocess_shared_unix.js", + "resource://gre/modules/subprocess/subprocess_worker_common.js" +); + +const POLL_TIMEOUT = 5000; + +let io; + +let nextPipeId = 0; + +class Pipe extends BasePipe { + constructor(process, fd) { + super(); + + this.process = process; + this.fd = fd; + this.id = nextPipeId++; + } + + get pollEvents() { + throw new Error("Not implemented"); + } + + /** + * Closes the file descriptor. + * + * @param {boolean} [force=false] + * If true, the file descriptor is closed immediately. If false, the + * file descriptor is closed after all current pending IO operations + * have completed. + * + * @returns {Promise<void>} + * Resolves when the file descriptor has been closed. + */ + close(force = false) { + if (!force && this.pending.length) { + this.closing = true; + return this.closedPromise; + } + + for (let { reject } of this.pending) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + reject(error); + } + this.pending.length = 0; + + if (!this.closed) { + this.fd.dispose(); + + this.closed = true; + this.resolveClosed(); + + io.pipes.delete(this.id); + io.updatePollFds(); + } + return this.closedPromise; + } + + /** + * Called when an error occurred while polling our file descriptor. + */ + onError() { + this.close(true); + this.process.wait(); + } +} + +class InputPipe extends Pipe { + /** + * A bit mask of poll() events which we currently wish to be notified of on + * this file descriptor. + */ + get pollEvents() { + if (this.pending.length) { + return LIBC.POLLIN; + } + return 0; + } + + /** + * Asynchronously reads at most `length` bytes of binary data from the file + * descriptor into an ArrayBuffer of the same size. Returns a promise which + * resolves when the operation is complete. + * + * @param {integer} length + * The number of bytes to read. + * + * @returns {Promise<ArrayBuffer>} + */ + read(length) { + if (this.closing || this.closed) { + throw new Error("Attempt to read from closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject, length }); + io.updatePollFds(); + }); + } + + /** + * Synchronously reads at most `count` bytes of binary data into an + * ArrayBuffer, and returns that buffer. If no data can be read without + * blocking, returns null instead. + * + * @param {integer} count + * The number of bytes to read. + * + * @returns {ArrayBuffer|null} + */ + readBuffer(count) { + let buffer = new ArrayBuffer(count); + + let read = +libc.read(this.fd, buffer, buffer.byteLength); + if (read < 0 && ctypes.errno != LIBC.EAGAIN) { + this.onError(); + } + + if (read <= 0) { + return null; + } + + if (read < buffer.byteLength) { + return ArrayBuffer_transfer(buffer, read); + } + + return buffer; + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + * + * @returns {boolean} + * True if any data was successfully read. + */ + onReady() { + let result = false; + let reads = this.pending; + while (reads.length) { + let { resolve, length } = reads[0]; + + let buffer = this.readBuffer(length); + if (buffer) { + result = true; + this.shiftPending(); + resolve(buffer); + } else { + break; + } + } + + if (!reads.length) { + io.updatePollFds(); + } + return result; + } +} + +class OutputPipe extends Pipe { + /** + * A bit mask of poll() events which we currently wish to be notified of on + * this file discriptor. + */ + get pollEvents() { + if (this.pending.length) { + return LIBC.POLLOUT; + } + return 0; + } + + /** + * Asynchronously writes the given buffer to our file descriptor, and returns + * a promise which resolves when the operation is complete. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {Promise<integer>} + * Resolves to the number of bytes written when the operation is + * complete. + */ + write(buffer) { + if (this.closing || this.closed) { + throw new Error("Attempt to write to closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject, buffer, length: buffer.byteLength }); + io.updatePollFds(); + }); + } + + /** + * Attempts to synchronously write the given buffer to our file descriptor. + * Writes only as many bytes as can be written without blocking, and returns + * the number of byes successfully written. + * + * Closes the file descriptor if an IO error occurs. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {integer} + * The number of bytes successfully written. + */ + writeBuffer(buffer) { + let bytesWritten = libc.write(this.fd, buffer, buffer.byteLength); + + if (bytesWritten < 0 && ctypes.errno != LIBC.EAGAIN) { + this.onError(); + } + + return bytesWritten; + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + */ + onReady() { + let writes = this.pending; + while (writes.length) { + let { buffer, resolve, length } = writes[0]; + + let written = this.writeBuffer(buffer); + + if (written == buffer.byteLength) { + resolve(length); + this.shiftPending(); + } else if (written > 0) { + writes[0].buffer = buffer.slice(written); + } else { + break; + } + } + + if (!writes.length) { + io.updatePollFds(); + } + } +} + +class Signal { + constructor(fd) { + this.fd = fd; + } + + cleanup() { + libc.close(this.fd); + this.fd = null; + } + + get pollEvents() { + return LIBC.POLLIN; + } + + /** + * Called when an error occurred while polling our file descriptor. + */ + onError() { + io.shutdown(); + } + + /** + * Called when one of the IO operations matching the `pollEvents` mask may be + * performed without blocking. + */ + onReady() { + let buffer = new ArrayBuffer(16); + let count = +libc.read(this.fd, buffer, buffer.byteLength); + if (count > 0) { + io.messageCount += count; + } + } +} + +class Process extends BaseProcess { + /** + * Each Process object opens an additional pipe from the target object, which + * will be automatically closed when the process exits, but otherwise + * carries no data. + * + * This property contains a bit mask of poll() events which we wish to be + * notified of on this descriptor. We're not expecting any input from this + * pipe, but we need to poll for input until the process exits in order to be + * notified when the pipe closes. + */ + get pollEvents() { + if (this.exitCode === null) { + return LIBC.POLLIN; + } + return 0; + } + + /** + * Kills the process with the given signal. + * + * @param {integer} signal + */ + kill(signal) { + libc.kill(this.pid, signal); + this.wait(); + } + + /** + * Initializes the IO pipes for use as standard input, output, and error + * descriptors in the spawned process. + * + * @param {object} options + * The Subprocess options object for this process. + * @returns {unix.Fd[]} + * The array of file descriptors belonging to the spawned process. + */ + initPipes(options) { + let stderr = options.stderr; + + let our_pipes = []; + let their_pipes = new Map(); + + let pipe = input => { + let fds = ctypes.int.array(2)(); + + let res = libc.pipe(fds); + if (res == -1) { + throw new Error("Unable to create pipe"); + } + + fds = Array.from(fds, unix.Fd); + + if (input) { + fds.reverse(); + } + + if (input) { + our_pipes.push(new InputPipe(this, fds[1])); + } else { + our_pipes.push(new OutputPipe(this, fds[1])); + } + + libc.fcntl(fds[0], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFD, LIBC.FD_CLOEXEC); + libc.fcntl(fds[1], LIBC.F_SETFL, LIBC.O_NONBLOCK); + + return fds[0]; + }; + + their_pipes.set(0, pipe(false)); + their_pipes.set(1, pipe(true)); + + if (stderr == "pipe") { + their_pipes.set(2, pipe(true)); + } else if (stderr == "stdout") { + their_pipes.set(2, their_pipes.get(1)); + } + + // Create an additional pipe that we can use to monitor for process exit. + their_pipes.set(3, pipe(true)); + this.fd = our_pipes.pop().fd; + + this.pipes = our_pipes; + + return their_pipes; + } + + spawn(options) { + let fds = this.initPipes(options); + + let launchOptions = { + environment: options.environment, + disclaim: options.disclaim, + fdMap: [], + }; + + // Check for truthiness to avoid chdir("null") + if (options.workdir) { + launchOptions.workdir = options.workdir; + } + + for (let [dst, src] of fds.entries()) { + launchOptions.fdMap.push({ src, dst }); + } + + try { + this.pid = IOUtils.launchProcess(options.arguments, launchOptions); + } finally { + for (let fd of new Set(fds.values())) { + fd.dispose(); + } + } + } + + /** + * Called when input is available on our sentinel file descriptor. + * + * @see pollEvents + */ + onReady() { + // We're not actually expecting any input on this pipe. If we get any, we + // can't poll the pipe any further without reading it. + if (this.wait() == undefined) { + this.kill(9); + } + } + + /** + * Called when an error occurred while polling our sentinel file descriptor. + * + * @see pollEvents + */ + onError() { + this.wait(); + } + + /** + * Attempts to wait for the process's exit status, without blocking. If + * successful, resolves the `exitPromise` to the process's exit value. + * + * @returns {integer|null} + * The process's exit status, if it has already exited. + */ + wait() { + if (this.exitCode !== null) { + return this.exitCode; + } + + let status = ctypes.int(); + + let res = libc.waitpid(this.pid, status.address(), LIBC.WNOHANG); + // If there's a failure here and we get any errno other than EINTR, it + // means that the process has been reaped by another thread (most likely + // the nspr process wait thread), and its actual exit status is not + // available to us. In that case, we have to assume success. + if (res == 0 || (res == -1 && ctypes.errno == LIBC.EINTR)) { + return null; + } + + let sig = unix.WTERMSIG(status.value); + if (sig) { + this.exitCode = -sig; + } else { + this.exitCode = unix.WEXITSTATUS(status.value); + } + + this.fd.dispose(); + io.updatePollFds(); + this.resolveExit(this.exitCode); + return this.exitCode; + } +} + +io = { + pollFds: null, + pollHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + messageCount: 0, + + running: true, + + polling: false, + + init(details) { + this.signal = new Signal(details.signalFd); + this.updatePollFds(); + + setTimeout(this.loop.bind(this), 0); + }, + + shutdown() { + if (this.running) { + this.running = false; + + this.signal.cleanup(); + this.signal = null; + + self.postMessage({ msg: "close" }); + self.close(); + } + }, + + getPipe(pipeId) { + let pipe = this.pipes.get(pipeId); + + if (!pipe) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + throw error; + } + return pipe; + }, + + getProcess(processId) { + let process = this.processes.get(processId); + + if (!process) { + throw new Error(`Invalid process ID: ${processId}`); + } + return process; + }, + + updatePollFds() { + let handlers = [ + this.signal, + ...this.pipes.values(), + ...this.processes.values(), + ]; + + handlers = handlers.filter(handler => handler.pollEvents); + + // Our poll loop is only useful if we've got at least 1 thing to poll other than our own + // signal. + if (handlers.length == 1) { + this.polling = false; + } else if (!this.polling && this.running) { + // Restart the poll loop if necessary: + setTimeout(this.loop.bind(this), 0); + this.polling = true; + } + + let pollfds = unix.pollfd.array(handlers.length)(); + + for (let [i, handler] of handlers.entries()) { + let pollfd = pollfds[i]; + + pollfd.fd = handler.fd; + pollfd.events = handler.pollEvents; + pollfd.revents = 0; + } + + this.pollFds = pollfds; + this.pollHandlers = handlers; + }, + + loop() { + this.poll(); + if (this.running && this.polling) { + setTimeout(this.loop.bind(this), 0); + } + }, + + poll() { + let handlers = this.pollHandlers; + let pollfds = this.pollFds; + + let timeout = this.messageCount > 0 ? 0 : POLL_TIMEOUT; + let count = libc.poll(pollfds, pollfds.length, timeout); + + for (let i = 0; count && i < pollfds.length; i++) { + let pollfd = pollfds[i]; + if (pollfd.revents) { + count--; + + let handler = handlers[i]; + try { + let success = false; + if (pollfd.revents & handler.pollEvents) { + success = handler.onReady(); + } + // Only call the error handler in this iteration if we didn't also + // have a success. This is necessary because Linux systems set POLLHUP + // on a pipe when it's closed but there's still buffered data to be + // read, and Darwin sets POLLIN and POLLHUP on a closed pipe, even + // when there's no data to be read. + if ( + !success && + pollfd.revents & (LIBC.POLLERR | LIBC.POLLHUP | LIBC.POLLNVAL) + ) { + handler.onError(); + } + } catch (e) { + console.error(e); + debug(`Worker error: ${e} :: ${e.stack}`); + handler.onError(); + } + + pollfd.revents = 0; + } + } + }, + + addProcess(process) { + this.processes.set(process.id, process); + + for (let pipe of process.pipes) { + this.pipes.set(pipe.id, pipe); + } + }, + + cleanupProcess(process) { + this.processes.delete(process.id); + }, +}; diff --git a/toolkit/modules/subprocess/subprocess_win.sys.mjs b/toolkit/modules/subprocess/subprocess_win.sys.mjs new file mode 100644 index 0000000000..baf8640235 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_win.sys.mjs @@ -0,0 +1,173 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { + BaseProcess, + PromiseWorker, +} from "resource://gre/modules/subprocess/subprocess_common.sys.mjs"; + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +var obj = { ctypes }; +Services.scriptloader.loadSubScript( + "resource://gre/modules/subprocess/subprocess_shared.js", + obj +); +Services.scriptloader.loadSubScript( + "resource://gre/modules/subprocess/subprocess_shared_win.js", + obj +); + +const { SubprocessConstants } = obj; + +// libc and win32 are exported for tests. +export const libc = obj.libc; +export const win32 = obj.win32; + +class WinPromiseWorker extends PromiseWorker { + constructor(...args) { + super(...args); + + this.signalEvent = libc.CreateSemaphoreW(null, 0, 32, null); + + this.call("init", [ + { + comspec: Services.env.get("COMSPEC"), + signalEvent: String( + ctypes.cast(this.signalEvent, ctypes.uintptr_t).value + ), + }, + ]); + } + + signalWorker() { + libc.ReleaseSemaphore(this.signalEvent, 1, null); + } + + postMessage(...args) { + this.signalWorker(); + return super.postMessage(...args); + } +} + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_win.worker.js"; + } + + static get WorkerClass() { + return WinPromiseWorker; + } +} + +var SubprocessWin = { + Process, + + call(options) { + return Process.create(options); + }, + + *getEnvironment() { + let env = libc.GetEnvironmentStringsW(); + try { + for (let p = env, q = env; ; p = p.increment()) { + if (p.contents == "\0") { + if (String(p) == String(q)) { + break; + } + + let str = q.readString(); + q = p.increment(); + + let idx = str.indexOf("="); + if (idx == 0) { + idx = str.indexOf("=", 1); + } + + if (idx >= 0) { + yield [str.slice(0, idx), str.slice(idx + 1)]; + } + } + } + } finally { + libc.FreeEnvironmentStringsW(env); + } + }, + + async isExecutableFile(path) { + if (!PathUtils.isAbsolute(path)) { + return false; + } + + try { + let info = await IOUtils.stat(path); + // On Windows, a FileType of "other" indicates it is a reparse point + // (i.e., a link). + return info.type !== "directory" && info.type !== "other"; + } catch (e) { + return false; + } + }, + + /** + * Searches for the given executable file in the system executable + * file paths as specified by the PATH environment variable. + * + * On Windows, if the unadorned filename cannot be found, the + * extensions in the semicolon-separated list in the PATHEXT + * environment variable are successively appended to the original + * name and searched for in turn. + * + * @param {string} bin + * The name of the executable to find. + * @param {object} environment + * An object containing a key for each environment variable to be used + * in the search. + * @returns {Promise<string>} + */ + async pathSearch(bin, environment) { + if (PathUtils.isAbsolute(bin)) { + if (await this.isExecutableFile(bin)) { + return bin; + } + let error = new Error( + `File at path "${bin}" does not exist, or is not a normal file` + ); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + } + + let dirs = []; + let exts = []; + if (environment.PATH) { + dirs = environment.PATH.split(";"); + } + if (environment.PATHEXT) { + exts = environment.PATHEXT.split(";"); + } + + for (let dir of dirs) { + let path = PathUtils.join(dir, bin); + + if (await this.isExecutableFile(path)) { + return path; + } + + for (let ext of exts) { + let file = path + ext; + + if (await this.isExecutableFile(file)) { + return file; + } + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }, +}; + +export var SubprocessImpl = SubprocessWin; diff --git a/toolkit/modules/subprocess/subprocess_win.worker.js b/toolkit/modules/subprocess/subprocess_win.worker.js new file mode 100644 index 0000000000..22d3857f8c --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_win.worker.js @@ -0,0 +1,785 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/* exported Process */ + +/* import-globals-from subprocess_shared.js */ +/* import-globals-from subprocess_shared_win.js */ +/* import-globals-from subprocess_worker_common.js */ +importScripts( + "resource://gre/modules/subprocess/subprocess_shared.js", + "resource://gre/modules/subprocess/subprocess_shared_win.js", + "resource://gre/modules/subprocess/subprocess_worker_common.js" +); + +const POLL_TIMEOUT = 5000; + +// The exit code that we send when we forcibly terminate a process. +const TERMINATE_EXIT_CODE = 0x7f; + +let io; + +let nextPipeId = 0; + +class Pipe extends BasePipe { + constructor(process, origHandle) { + super(); + + let handle = win32.HANDLE(); + + let curProc = libc.GetCurrentProcess(); + libc.DuplicateHandle( + curProc, + origHandle, + curProc, + handle.address(), + 0, + false /* inheritable */, + win32.DUPLICATE_SAME_ACCESS + ); + + origHandle.dispose(); + + this.id = nextPipeId++; + this.process = process; + + this.handle = win32.Handle(handle); + + let event = libc.CreateEventW(null, false, false, null); + + this.overlapped = win32.OVERLAPPED(); + this.overlapped.hEvent = event; + + this._event = win32.Handle(event); + + this.buffer = null; + } + + get event() { + if (this.pending.length) { + return this._event; + } + return null; + } + + maybeClose() {} + + /** + * Closes the file handle. + * + * @param {boolean} [force=false] + * If true, the file handle is closed immediately. If false, the + * file handle is closed after all current pending IO operations + * have completed. + * + * @returns {Promise<void>} + * Resolves when the file handle has been closed. + */ + close(force = false) { + if (!force && this.pending.length) { + this.closing = true; + return this.closedPromise; + } + + for (let { reject } of this.pending) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + reject(error); + } + this.pending.length = 0; + + this.buffer = null; + + if (!this.closed) { + this.handle.dispose(); + this._event.dispose(); + + io.pipes.delete(this.id); + + this.handle = null; + this.closed = true; + this.resolveClosed(); + + io.updatePollEvents(); + } + return this.closedPromise; + } + + /** + * Called when an error occurred while attempting an IO operation on our file + * handle. + */ + onError() { + this.close(true); + } +} + +class InputPipe extends Pipe { + /** + * Queues the next chunk of data to be read from the pipe if, and only if, + * there is no IO operation currently pending. + */ + readNext() { + if (this.buffer === null) { + this.readBuffer(this.pending[0].length); + } + } + + /** + * Closes the pipe if there is a pending read operation with no more + * buffered data to be read. + */ + maybeClose() { + if (this.buffer) { + let read = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, + this.overlapped.address(), + read.address(), + false + ); + + if (!ok) { + this.onError(); + } + } + } + + /** + * Asynchronously reads at most `length` bytes of binary data from the file + * descriptor into an ArrayBuffer of the same size. Returns a promise which + * resolves when the operation is complete. + * + * @param {integer} length + * The number of bytes to read. + * + * @returns {Promise<ArrayBuffer>} + */ + read(length) { + if (this.closing || this.closed) { + throw new Error("Attempt to read from closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject, length }); + this.readNext(); + }); + } + + /** + * Initializes an overlapped IO read operation to read exactly `count` bytes + * into a new ArrayBuffer, which is stored in the `buffer` property until the + * operation completes. + * + * @param {integer} count + * The number of bytes to read. + */ + readBuffer(count) { + this.buffer = new ArrayBuffer(count); + + let ok = libc.ReadFile( + this.handle, + this.buffer, + count, + null, + this.overlapped.address() + ); + + if (!ok && (!this.process.handle || libc.winLastError)) { + this.onError(); + } else { + io.updatePollEvents(); + } + } + + /** + * Called when our pending overlapped IO operation has completed, whether + * successfully or in failure. + */ + onReady() { + let read = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, + this.overlapped.address(), + read.address(), + false + ); + + read = read.value; + + if (!ok) { + this.onError(); + } else if (read > 0) { + let buffer = this.buffer; + this.buffer = null; + + let { resolve } = this.shiftPending(); + + if (read == buffer.byteLength) { + resolve(buffer); + } else { + resolve(ArrayBuffer_transfer(buffer, read)); + } + + if (this.pending.length) { + this.readNext(); + } else { + io.updatePollEvents(); + } + } + } +} + +class OutputPipe extends Pipe { + /** + * Queues the next chunk of data to be written to the pipe if, and only if, + * there is no IO operation currently pending. + */ + writeNext() { + if (this.buffer === null) { + this.writeBuffer(this.pending[0].buffer); + } + } + + /** + * Asynchronously writes the given buffer to our file descriptor, and returns + * a promise which resolves when the operation is complete. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + * + * @returns {Promise<integer>} + * Resolves to the number of bytes written when the operation is + * complete. + */ + write(buffer) { + if (this.closing || this.closed) { + throw new Error("Attempt to write to closed pipe"); + } + + return new Promise((resolve, reject) => { + this.pending.push({ resolve, reject, buffer }); + this.writeNext(); + }); + } + + /** + * Initializes an overapped IO read operation to write the data in `buffer` to + * our file descriptor. + * + * @param {ArrayBuffer} buffer + * The buffer to write. + */ + writeBuffer(buffer) { + this.buffer = buffer; + + let ok = libc.WriteFile( + this.handle, + buffer, + buffer.byteLength, + null, + this.overlapped.address() + ); + + if (!ok && libc.winLastError) { + this.onError(); + } else { + io.updatePollEvents(); + } + } + + /** + * Called when our pending overlapped IO operation has completed, whether + * successfully or in failure. + */ + onReady() { + let written = win32.DWORD(); + + let ok = libc.GetOverlappedResult( + this.handle, + this.overlapped.address(), + written.address(), + false + ); + + written = written.value; + + if (!ok || written != this.buffer.byteLength) { + this.onError(); + } else if (written > 0) { + let { resolve } = this.shiftPending(); + + this.buffer = null; + resolve(written); + + if (this.pending.length) { + this.writeNext(); + } else { + io.updatePollEvents(); + } + } + } +} + +class Signal { + constructor(event) { + this.event = event; + } + + cleanup() { + libc.CloseHandle(this.event); + this.event = null; + } + + onError() { + io.shutdown(); + } + + onReady() { + io.messageCount += 1; + } +} + +class Process extends BaseProcess { + constructor(...args) { + super(...args); + + this.killed = false; + } + + /** + * Returns our process handle for use as an event in a WaitForMultipleObjects + * call. + */ + get event() { + return this.handle; + } + + /** + * Forcibly terminates the process. + */ + kill() { + this.killed = true; + libc.TerminateJobObject(this.jobHandle, TERMINATE_EXIT_CODE); + } + + /** + * Initializes the IO pipes for use as standard input, output, and error + * descriptors in the spawned process. + * + * @returns {win32.Handle[]} + * The array of file handles belonging to the spawned process. + */ + initPipes({ stderr }) { + let our_pipes = []; + let their_pipes = []; + + let secAttr = new win32.SECURITY_ATTRIBUTES(); + secAttr.nLength = win32.SECURITY_ATTRIBUTES.size; + secAttr.bInheritHandle = true; + + let pipe = input => { + if (input) { + let handles = win32.createPipe(secAttr, win32.FILE_FLAG_OVERLAPPED); + our_pipes.push(new InputPipe(this, handles[0])); + return handles[1]; + } + let handles = win32.createPipe(secAttr, 0, win32.FILE_FLAG_OVERLAPPED); + our_pipes.push(new OutputPipe(this, handles[1])); + return handles[0]; + }; + + their_pipes[0] = pipe(false); + their_pipes[1] = pipe(true); + + if (stderr == "pipe") { + their_pipes[2] = pipe(true); + } else { + let srcHandle; + if (stderr == "stdout") { + srcHandle = their_pipes[1]; + } else { + srcHandle = libc.GetStdHandle(win32.STD_ERROR_HANDLE); + } + + // If we don't have a valid stderr handle, just pass it along without duplicating. + if ( + String(srcHandle) == win32.INVALID_HANDLE_VALUE || + String(srcHandle) == win32.NULL_HANDLE_VALUE + ) { + their_pipes[2] = srcHandle; + } else { + let handle = win32.HANDLE(); + + let curProc = libc.GetCurrentProcess(); + let ok = libc.DuplicateHandle( + curProc, + srcHandle, + curProc, + handle.address(), + 0, + true /* inheritable */, + win32.DUPLICATE_SAME_ACCESS + ); + + their_pipes[2] = ok && win32.Handle(handle); + } + } + + if (!their_pipes.every(handle => handle)) { + throw new Error("Failed to create pipe"); + } + + this.pipes = our_pipes; + + return their_pipes; + } + + /** + * Creates a null-separated, null-terminated string list. + * + * @param {Array<string>} strings + * @returns {win32.WCHAR.array} + */ + stringList(strings) { + // Remove empty strings, which would terminate the list early. + strings = strings.filter(string => string); + + let string = strings.join("\0") + "\0\0"; + + return win32.WCHAR.array()(string); + } + + /** + * Quotes a string for use as a single command argument, using Windows quoting + * conventions. + * + * @see https://msdn.microsoft.com/en-us/library/17w5ykft(v=vs.85).aspx + * + * @param {string} str + * The argument string to quote. + * @returns {string} + */ + quoteString(str) { + if (!/[\s"]/.test(str)) { + return str; + } + + let escaped = str.replace(/(\\*)("|$)/g, (m0, m1, m2) => { + if (m2) { + m2 = `\\${m2}`; + } + return `${m1}${m1}${m2}`; + }); + + return `"${escaped}"`; + } + + spawn(options) { + let { command, arguments: args } = options; + + if ( + /\\cmd\.exe$/i.test(command) && + args.length == 3 && + /^(\/S)?\/C$/i.test(args[1]) + ) { + // cmd.exe is insane and requires special treatment. + args = [this.quoteString(args[0]), "/S/C", `"${args[2]}"`]; + } else { + args = args.map(arg => this.quoteString(arg)); + } + + if (/\.(bat|cmd)$/i.test(command)) { + command = io.comspec; + args = ["cmd.exe", "/s/c", `"${args.join(" ")}"`]; + } + + let envp = this.stringList(options.environment); + + let handles = this.initPipes(options); + + let processFlags = + win32.CREATE_NO_WINDOW | + win32.CREATE_SUSPENDED | + win32.CREATE_UNICODE_ENVIRONMENT; + + let startupInfoEx = new win32.STARTUPINFOEXW(); + let startupInfo = startupInfoEx.StartupInfo; + + startupInfo.cb = win32.STARTUPINFOW.size; + startupInfo.dwFlags = win32.STARTF_USESTDHANDLES; + + startupInfo.hStdInput = handles[0]; + startupInfo.hStdOutput = handles[1]; + startupInfo.hStdError = handles[2]; + + // Note: This needs to be kept alive until we destroy the attribute list. + let handleArray = win32.HANDLE.array()(handles); + + let threadAttrs = win32.createThreadAttributeList(handleArray); + if (threadAttrs) { + // If have thread attributes to pass, pass the size of the full extended + // startup info struct. + processFlags |= win32.EXTENDED_STARTUPINFO_PRESENT; + startupInfo.cb = win32.STARTUPINFOEXW.size; + + startupInfoEx.lpAttributeList = threadAttrs; + } + + let procInfo = new win32.PROCESS_INFORMATION(); + + let errorMessage = "Failed to create process"; + let ok = libc.CreateProcessW( + command, + args.join(" "), + null /* Security attributes */, + null /* Thread security attributes */, + true /* Inherits handles */, + processFlags, + envp, + options.workdir, + startupInfo.address(), + procInfo.address() + ); + + for (let handle of new Set(handles)) { + // If any of our handles are invalid, they don't have finalizers. + if (handle && handle.dispose) { + handle.dispose(); + } + } + + if (threadAttrs) { + libc.DeleteProcThreadAttributeList(threadAttrs); + } + + if (ok) { + this.jobHandle = win32.Handle(libc.CreateJobObjectW(null, null)); + + let info = win32.JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = + win32.JOB_OBJECT_LIMIT_BREAKAWAY_OK; + + ok = libc.SetInformationJobObject( + this.jobHandle, + win32.JobObjectExtendedLimitInformation, + ctypes.cast(info.address(), ctypes.voidptr_t), + info.constructor.size + ); + errorMessage = `Failed to set job limits: 0x${( + ctypes.winLastError || 0 + ).toString(16)}`; + } + + if (ok) { + ok = libc.AssignProcessToJobObject(this.jobHandle, procInfo.hProcess); + if (!ok) { + errorMessage = `Failed to attach process to job object: 0x${( + ctypes.winLastError || 0 + ).toString(16)}`; + libc.TerminateProcess(procInfo.hProcess, TERMINATE_EXIT_CODE); + } + } + + if (!ok) { + for (let pipe of this.pipes) { + pipe.close(); + } + throw new Error(errorMessage); + } + + this.handle = win32.Handle(procInfo.hProcess); + this.pid = procInfo.dwProcessId; + + libc.ResumeThread(procInfo.hThread); + libc.CloseHandle(procInfo.hThread); + } + + /** + * Called when our process handle is signaled as active, meaning the process + * has exited. + */ + onReady() { + this.wait(); + } + + /** + * Attempts to wait for the process's exit status, without blocking. If + * successful, resolves the `exitPromise` to the process's exit value. + * + * @returns {integer|null} + * The process's exit status, if it has already exited. + */ + wait() { + if (this.exitCode !== null) { + return this.exitCode; + } + + let status = win32.DWORD(); + + let ok = libc.GetExitCodeProcess(this.handle, status.address()); + if (ok && status.value != win32.STILL_ACTIVE) { + let exitCode = status.value; + if (this.killed && exitCode == TERMINATE_EXIT_CODE) { + // If we forcibly terminated the process, return the force kill exit + // code that we return on other platforms. + exitCode = -9; + } + + this.resolveExit(exitCode); + this.exitCode = exitCode; + + this.handle.dispose(); + this.handle = null; + + libc.TerminateJobObject(this.jobHandle, TERMINATE_EXIT_CODE); + this.jobHandle.dispose(); + this.jobHandle = null; + + for (let pipe of this.pipes) { + pipe.maybeClose(); + } + + io.updatePollEvents(); + + return exitCode; + } + } +} + +io = { + events: null, + eventHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + messageCount: 0, + + running: true, + + polling: false, + + init(details) { + this.comspec = details.comspec; + + let signalEvent = ctypes.cast( + ctypes.uintptr_t(details.signalEvent), + win32.HANDLE + ); + this.signal = new Signal(signalEvent); + this.updatePollEvents(); + + setTimeout(this.loop.bind(this), 0); + }, + + shutdown() { + if (this.running) { + this.running = false; + + this.signal.cleanup(); + this.signal = null; + + self.postMessage({ msg: "close" }); + self.close(); + } + }, + + getPipe(pipeId) { + let pipe = this.pipes.get(pipeId); + + if (!pipe) { + let error = new Error("File closed"); + error.errorCode = SubprocessConstants.ERROR_END_OF_FILE; + throw error; + } + return pipe; + }, + + getProcess(processId) { + let process = this.processes.get(processId); + + if (!process) { + throw new Error(`Invalid process ID: ${processId}`); + } + return process; + }, + + updatePollEvents() { + let handlers = [ + this.signal, + ...this.pipes.values(), + ...this.processes.values(), + ]; + + handlers = handlers.filter(handler => handler.event); + + // Our poll loop is only useful if we've got at least 1 thing to poll other than our own + // signal. + if (handlers.length == 1) { + this.polling = false; + } else if (!this.polling && this.running) { + // Restart the poll loop if necessary: + setTimeout(this.loop.bind(this), 0); + this.polling = true; + } + + this.eventHandlers = handlers; + + let handles = handlers.map(handler => handler.event); + this.events = win32.HANDLE.array()(handles); + }, + + loop() { + this.poll(); + if (this.running && this.polling) { + setTimeout(this.loop.bind(this), 0); + } + }, + + poll() { + let timeout = this.messageCount > 0 ? 0 : POLL_TIMEOUT; + for (; ; timeout = 0) { + let events = this.events; + let handlers = this.eventHandlers; + + let result = libc.WaitForMultipleObjects( + events.length, + events, + false, + timeout + ); + + if (result < handlers.length) { + try { + handlers[result].onReady(); + } catch (e) { + console.error(e); + debug(`Worker error: ${e} :: ${e.stack}`); + handlers[result].onError(); + } + } else { + break; + } + } + }, + + addProcess(process) { + this.processes.set(process.id, process); + + for (let pipe of process.pipes) { + this.pipes.set(pipe.id, pipe); + } + }, + + cleanupProcess(process) { + this.processes.delete(process.id); + }, +}; diff --git a/toolkit/modules/subprocess/subprocess_worker_common.js b/toolkit/modules/subprocess/subprocess_worker_common.js new file mode 100644 index 0000000000..b22480c0dd --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_common.js @@ -0,0 +1,211 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// This file is loaded into the same context as subprocess_unix.worker.js +// and subprocess_win.worker.js +/* import-globals-from subprocess_unix.worker.js */ + +/* exported BasePipe, BaseProcess, debug */ + +function debug(message) { + self.postMessage({ msg: "debug", message }); +} + +class BasePipe { + constructor() { + this.closing = false; + this.closed = false; + + this.closedPromise = new Promise(resolve => { + this.resolveClosed = resolve; + }); + + this.pending = []; + } + + shiftPending() { + let result = this.pending.shift(); + + if (this.closing && !this.pending.length) { + this.close(); + } + + return result; + } +} + +let nextProcessId = 0; + +class BaseProcess { + constructor(options) { + this.id = nextProcessId++; + + this.exitCode = null; + + this.exitPromise = new Promise(resolve => { + this.resolveExit = resolve; + }); + this.exitPromise.then(() => { + // The input file descriptors will be closed after poll + // reports that their input buffers are empty. If we close + // them now, we may lose output. + this.pipes[0].close(true); + }); + + this.pid = null; + this.pipes = []; + + this.spawn(options); + } + + /** + * Waits for the process to exit and all of its pending IO operations to + * complete. + * + * @returns {Promise<void>} + */ + awaitFinished() { + return Promise.all([ + this.exitPromise, + ...this.pipes.map(pipe => pipe.closedPromise), + ]); + } +} + +let requests = { + init(details) { + io.init(details); + + return { data: {} }; + }, + + shutdown() { + io.shutdown(); + + return { data: {} }; + }, + + close(pipeId, force = false) { + let pipe = io.getPipe(pipeId); + + return pipe.close(force).then(() => ({ data: {} })); + }, + + spawn(options) { + let process = new Process(options); + let processId = process.id; + + io.addProcess(process); + + let fds = process.pipes.map(pipe => pipe.id); + + return { data: { processId, fds, pid: process.pid } }; + }, + + kill(processId, force = false) { + let process = io.getProcess(processId); + + process.kill(force ? 9 : 15); + + return { data: {} }; + }, + + wait(processId) { + let process = io.getProcess(processId); + + process.wait(); + + process.awaitFinished().then(() => { + io.cleanupProcess(process); + }); + + return process.exitPromise.then(exitCode => { + return { data: { exitCode } }; + }); + }, + + read(pipeId, count) { + let pipe = io.getPipe(pipeId); + + return pipe.read(count).then(buffer => { + return { data: { buffer } }; + }); + }, + + write(pipeId, buffer) { + let pipe = io.getPipe(pipeId); + + return pipe.write(buffer).then(bytesWritten => { + return { data: { bytesWritten } }; + }); + }, + + getOpenFiles() { + return { data: new Set(io.pipes.keys()) }; + }, + + getProcesses() { + let data = new Map( + Array.from(io.processes.values()) + .filter(proc => proc.exitCode == null) + .map(proc => [proc.id, proc.pid]) + ); + return { data }; + }, + + waitForNoProcesses() { + return Promise.all( + Array.from(io.processes.values(), proc => proc.awaitFinished()) + ); + }, +}; + +onmessage = event => { + io.messageCount--; + + let { msg, msgId, args } = event.data; + + new Promise(resolve => { + resolve(requests[msg](...args)); + }) + .then(result => { + let response = { + msg: "success", + msgId, + data: result.data, + }; + + self.postMessage(response, result.transfer || []); + }) + .catch(error => { + if (error instanceof Error) { + error = { + message: error.message, + fileName: error.fileName, + lineNumber: error.lineNumber, + column: error.column, + stack: error.stack, + errorCode: error.errorCode, + }; + } + + self.postMessage({ + msg: "failure", + msgId, + error, + }); + }) + .catch(error => { + console.error(error); + + self.postMessage({ + msg: "failure", + msgId, + error: {}, + }); + }); +}; diff --git a/toolkit/modules/subprocess/test/xpcshell/data_test_script.py b/toolkit/modules/subprocess/test/xpcshell/data_test_script.py new file mode 100644 index 0000000000..e1f5f5de93 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/data_test_script.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import os +import signal +import struct +import sys + + +def output(line, stream=sys.stdout, print_only=False): + if isinstance(line, str): + line = line.encode("utf-8", "surrogateescape") + if not print_only: + stream.buffer.write(struct.pack("@I", len(line))) + stream.buffer.write(line) + stream.flush() + + +def echo_loop(): + while True: + line = sys.stdin.buffer.readline() + if not line: + break + + output(line) + + +if sys.platform == "win32": + import msvcrt + + msvcrt.setmode(sys.stderr.fileno(), os.O_BINARY) + + +cmd = sys.argv[1] +if cmd == "echo": + echo_loop() +elif cmd == "exit": + sys.exit(int(sys.argv[2])) +elif cmd == "env": + for var in sys.argv[2:]: + output(os.environ.get(var, "!")) +elif cmd == "pwd": + output(os.path.abspath(os.curdir)) +elif cmd == "print_args": + for arg in sys.argv[2:]: + output(arg) +elif cmd == "ignore_sigterm": + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + output("Ready") + while True: + try: + signal.pause() + except AttributeError: + import time + + time.sleep(3600) +elif cmd == "print": + output(sys.argv[2], stream=sys.stdout, print_only=True) + output(sys.argv[3], stream=sys.stderr, print_only=True) diff --git a/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt b/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/data_text_file.txt diff --git a/toolkit/modules/subprocess/test/xpcshell/head.js b/toolkit/modules/subprocess/test/xpcshell/head.js new file mode 100644 index 0000000000..a2b85047d3 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/head.js @@ -0,0 +1,13 @@ +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line no-unused-vars +ChromeUtils.defineESModuleGetters(this, { + Subprocess: "resource://gre/modules/Subprocess.sys.mjs", +}); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js new file mode 100644 index 0000000000..51c9956d0d --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js @@ -0,0 +1,870 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +const MAX_ROUND_TRIP_TIME_MS = AppConstants.DEBUG || AppConstants.ASAN ? 18 : 9; +const MAX_RETRIES = 5; + +let PYTHON; +let PYTHON_BIN; +let PYTHON_DIR; + +const TEST_SCRIPT = do_get_file("data_test_script.py").path; + +let read = pipe => { + return pipe.readUint32().then(count => { + return pipe.readString(count); + }); +}; + +let readAll = async function (pipe) { + let result = []; + let string; + while ((string = await pipe.readString())) { + result.push(string); + } + + return result.join(""); +}; + +add_task(async function setup() { + PYTHON = await Subprocess.pathSearch(Services.env.get("PYTHON")); + + PYTHON_BIN = PathUtils.filename(PYTHON); + PYTHON_DIR = PathUtils.parent(PYTHON); +}); + +add_task(async function test_subprocess_io() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + Assert.throws(() => { + proc.stdout.read(-1); + }, /non-negative integer/); + Assert.throws(() => { + proc.stdout.read(1.1); + }, /non-negative integer/); + + Assert.throws(() => { + proc.stdout.read(Infinity); + }, /non-negative integer/); + Assert.throws(() => { + proc.stdout.read(NaN); + }, /non-negative integer/); + + Assert.throws(() => { + proc.stdout.readString(-1); + }, /non-negative integer/); + Assert.throws(() => { + proc.stdout.readString(1.1); + }, /non-negative integer/); + + Assert.throws(() => { + proc.stdout.readJSON(-1); + }, /positive integer/); + Assert.throws(() => { + proc.stdout.readJSON(0); + }, /positive integer/); + Assert.throws(() => { + proc.stdout.readJSON(1.1); + }, /positive integer/); + + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let outputPromise = read(proc.stdout); + + await new Promise(resolve => setTimeout(resolve, 100)); + + let [output] = await Promise.all([outputPromise, proc.stdin.write(LINE1)]); + + equal(output, LINE1, "Got expected output"); + + // Make sure it succeeds whether the write comes before or after the + // read. + let inputPromise = proc.stdin.write(LINE2); + + await new Promise(resolve => setTimeout(resolve, 100)); + + [output] = await Promise.all([read(proc.stdout), inputPromise]); + + equal(output, LINE2, "Got expected output"); + + let JSON_BLOB = { foo: { bar: "baz" } }; + + inputPromise = proc.stdin.write(JSON.stringify(JSON_BLOB) + "\n"); + + output = await proc.stdout.readUint32().then(count => { + return proc.stdout.readJSON(count); + }); + + Assert.deepEqual(output, JSON_BLOB, "Got expected JSON output"); + + await proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_large_io() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE = "I'm a leaf on the wind.\n"; + const BUFFER_SIZE = 4096; + + // Create a message that's ~3/4 the input buffer size. + let msg = + Array(((BUFFER_SIZE * 0.75) / 16) | 0) + .fill("0123456789abcdef") + .join("") + "\n"; + + // This sequence of writes and reads crosses several buffer size + // boundaries, and causes some branches of the read buffer code to be + // exercised which are not exercised by other tests. + proc.stdin.write(msg); + proc.stdin.write(msg); + proc.stdin.write(LINE); + + let output = await read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = await read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = await read(proc.stdout); + equal(output, LINE, "Got the expected output"); + + proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_huge() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // This should be large enough to fill most pipe input/output buffers. + const MESSAGE_SIZE = 1024 * 16; + + let msg = Array(MESSAGE_SIZE).fill("0123456789abcdef").join("") + "\n"; + + proc.stdin.write(msg); + + let output = await read(proc.stdout); + equal(output, msg, "Got the expected output"); + + proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task( + { skip_if: () => mozinfo.ccov }, + async function test_subprocess_round_trip_perf() { + let roundTripTime = Infinity; + for ( + let i = 0; + i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; + i++ + ) { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE = "I'm a leaf on the wind.\n"; + + let now = Date.now(); + const COUNT = 1000; + for (let j = 0; j < COUNT; j++) { + let [output] = await Promise.all([ + read(proc.stdout), + proc.stdin.write(LINE), + ]); + + // We don't want to log this for every iteration, but we still need + // to fail if it goes wrong. + if (output !== LINE) { + equal(output, LINE, "Got expected output"); + } + } + + roundTripTime = (Date.now() - now) / COUNT; + + await proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + } + + Assert.lessOrEqual( + roundTripTime, + MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms` + ); + } +); + +add_task(async function test_subprocess_stderr_default() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + }); + + equal(proc.stderr, undefined, "There should be no stderr pipe by default"); + + let stdout = await readAll(proc.stdout); + + equal(stdout, LINE1, "Got the expected stdout output"); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_stderr_pipe() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + let [stdout, stderr] = await Promise.all([ + readAll(proc.stdout), + readAll(proc.stderr), + ]); + + equal(stdout, LINE1, "Got the expected stdout output"); + equal(stderr, LINE2, "Got the expected stderr output"); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_stderr_merged() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "stdout", + }); + + equal(proc.stderr, undefined, "There should be no stderr pipe by default"); + + let stdout = await readAll(proc.stdout); + + equal(stdout, LINE1 + LINE2, "Got the expected merged stdout output"); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_read_after_exit() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + let { exitCode } = await proc.wait(); + equal(exitCode, 0, "Process exited with expected code"); + + let [stdout, stderr] = await Promise.all([ + readAll(proc.stdout), + readAll(proc.stderr), + ]); + + equal(stdout, LINE1, "Got the expected stdout output"); + equal(stderr, LINE2, "Got the expected stderr output"); +}); + +add_task(async function test_subprocess_lazy_close_output() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let writePromises = [proc.stdin.write(LINE1), proc.stdin.write(LINE2)]; + let closedPromise = proc.stdin.close(); + + let output1 = await read(proc.stdout); + let output2 = await read(proc.stdout); + + await Promise.all([...writePromises, closedPromise]); + + equal(output1, LINE1, "Got expected output"); + equal(output2, LINE2, "Got expected output"); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_lazy_close_input() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + let closedPromise = proc.stdout.close(); + + const LINE = "I'm a leaf on the wind.\n"; + + proc.stdin.write(LINE); + proc.stdin.close(); + + let len = await readPromise; + equal(len, LINE.length); + + await closedPromise; + + // Don't test for a successful exit here. The process may exit with a + // write error if we close the pipe after it's written the message + // size but before it's written the message. + await proc.wait(); +}); + +add_task(async function test_subprocess_force_close() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + let closedPromise = proc.stdout.close(true); + + await Assert.rejects( + readPromise, + function (e) { + equal( + e.errorCode, + Subprocess.ERROR_END_OF_FILE, + "Got the expected error code" + ); + return /File closed/.test(e.message); + }, + "Promise should be rejected when file is closed" + ); + + await closedPromise; + await proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_eof() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + + await proc.stdin.close(); + + await Assert.rejects( + readPromise, + function (e) { + equal( + e.errorCode, + Subprocess.ERROR_END_OF_FILE, + "Got the expected error code" + ); + return /File closed/.test(e.message); + }, + "Promise should be rejected on EOF" + ); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_invalid_json() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + const LINE = "I'm a leaf on the wind.\n"; + + proc.stdin.write(LINE); + proc.stdin.close(); + + let count = await proc.stdout.readUint32(); + let readPromise = proc.stdout.readJSON(count); + + await Assert.rejects( + readPromise, + function (e) { + equal( + e.errorCode, + Subprocess.ERROR_INVALID_JSON, + "Got the expected error code" + ); + return /SyntaxError/.test(e); + }, + "Promise should be rejected on EOF" + ); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +if (AppConstants.platform == "win") { + add_task(async function test_subprocess_inherited_descriptors() { + let { libc, win32 } = ChromeUtils.importESModule( + "resource://gre/modules/subprocess/subprocess_win.sys.mjs" + ); + const { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" + ); + + let secAttr = new win32.SECURITY_ATTRIBUTES(); + secAttr.nLength = win32.SECURITY_ATTRIBUTES.size; + secAttr.bInheritHandle = true; + + let handles = win32.createPipe(secAttr, 0); + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Close the output end of the pipe. + // Ours should be the only copy, so reads should fail after this. + handles[1].dispose(); + + let buffer = new ArrayBuffer(1); + let succeeded = libc.ReadFile( + handles[0], + buffer, + buffer.byteLength, + null, + null + ); + + ok(!succeeded, "ReadFile should fail on broken pipe"); + equal( + ctypes.winLastError, + win32.ERROR_BROKEN_PIPE, + "Read should fail with ERROR_BROKEN_PIPE" + ); + + proc.stdin.close(); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + }); +} + +add_task(async function test_subprocess_wait() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "exit", "42"], + }); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 42, "Got expected exit code"); +}); + +add_task(async function test_subprocess_pathSearch() { + let promise = Subprocess.call({ + command: PYTHON_BIN, + arguments: ["-u", TEST_SCRIPT, "exit", "13"], + environment: { + PATH: PYTHON_DIR, + }, + }); + + await Assert.rejects( + promise, + function (error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable" + ); +}); + +add_task(async function test_subprocess_workdir() { + let procDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path; + let tmpDir = PathUtils.normalize(PathUtils.tempDir); + + notEqual( + procDir, + tmpDir, + "Current process directory must not be the current temp directory" + ); + + async function pwd(options) { + let proc = await Subprocess.call( + Object.assign( + { + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "pwd"], + }, + options + ) + ); + + let pwdOutput = read(proc.stdout); + + let { exitCode } = await proc.wait(); + equal(exitCode, 0, "Got expected exit code"); + + return pwdOutput; + } + + let dir = await pwd({}); + equal( + dir, + procDir, + "Process should normally launch in current process directory" + ); + + dir = await pwd({ workdir: tmpDir }); + equal( + dir, + tmpDir, + "Process should launch in the directory specified in `workdir`" + ); +}); + +add_task(async function test_subprocess_term() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Windows does not support killing processes gracefully, so they will + // always exit with -9 there. + let retVal = AppConstants.platform == "win" ? -9 : -15; + + // Kill gracefully with the default timeout of 300ms. + let { exitCode } = await proc.kill(); + + equal(exitCode, retVal, "Got expected exit code"); + + ({ exitCode } = await proc.wait()); + + equal(exitCode, retVal, "Got expected exit code"); +}); + +add_task(async function test_subprocess_kill() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Force kill with no gracefull termination timeout. + let { exitCode } = await proc.kill(0); + + equal(exitCode, -9, "Got expected exit code"); + + ({ exitCode } = await proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + +add_task(async function test_subprocess_kill_timeout() { + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "ignore_sigterm"], + }); + + // Wait for the process to set up its signal handler and tell us it's + // ready. + let msg = await read(proc.stdout); + equal(msg, "Ready", "Process is ready"); + + // Kill gracefully with the default timeout of 300ms. + // Expect a force kill after 300ms, since the process traps SIGTERM. + const TIMEOUT = 300; + let startTime = Date.now(); + + let { exitCode } = await proc.kill(TIMEOUT); + + // Graceful termination is not supported on Windows, so don't bother + // testing the timeout there. + if (AppConstants.platform != "win") { + let diff = Date.now() - startTime; + Assert.greaterOrEqual( + diff, + TIMEOUT, + `Process was killed after ${diff}ms (expected ~${TIMEOUT}ms)` + ); + } + + equal(exitCode, -9, "Got expected exit code"); + + ({ exitCode } = await proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + +add_task(async function test_subprocess_arguments() { + let args = [ + String.raw`C:\Program Files\Company\Program.exe`, + String.raw`\\NETWORK SHARE\Foo Directory${"\\"}`, + String.raw`foo bar baz`, + String.raw`"foo bar baz"`, + String.raw`foo " bar`, + String.raw`Thing \" with "" "\" \\\" \\\\" quotes\\" \\`, + ]; + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print_args", ...args], + }); + + for (let [i, arg] of args.entries()) { + let val = await read(proc.stdout); + equal(val, arg, `Got correct value for args[${i}]`); + } + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_environment() { + let environment = { + FOO: "BAR", + EMPTY: "", + IGNORED: null, + }; + + // Our Windows environment can't handle launching python without + // PATH variables. + if (AppConstants.platform == "win") { + Object.assign(environment, { + PATH: Services.env.get("PATH"), + PATHEXT: Services.env.get("PATHEXT"), + SYSTEMROOT: Services.env.get("SYSTEMROOT"), + }); + } + + Services.env.set("BAR", "BAZ"); + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "FOO", "BAR", "EMPTY", "IGNORED"], + environment, + }); + + let foo = await read(proc.stdout); + let bar = await read(proc.stdout); + let empty = await read(proc.stdout); + let ignored = await read(proc.stdout); + + equal(foo, "BAR", "Got expected $FOO value"); + equal(bar, "!", "Got expected $BAR value"); + equal(empty, "", "Got expected $EMPTY value"); + equal(ignored, "!", "Got expected $IGNORED value"); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + +add_task(async function test_subprocess_environmentAppend() { + Services.env.set("VALUE_FROM_BASE_ENV", "untouched"); + Services.env.set("VALUE_FROM_BASE_ENV_EMPTY", "untouched"); + Services.env.set("VALUE_FROM_BASE_ENV_REMOVED", "untouched"); + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: [ + "-u", + TEST_SCRIPT, + "env", + "VALUE_FROM_BASE_ENV", + "VALUE_FROM_BASE_ENV_EMPTY", + "VALUE_FROM_BASE_ENV_REMOVED", + "VALUE_APPENDED_ONCE", + ], + environmentAppend: true, + environment: { + VALUE_FROM_BASE_ENV_EMPTY: "", + VALUE_FROM_BASE_ENV_REMOVED: null, + VALUE_APPENDED_ONCE: "soon empty", + }, + }); + + let valueFromBaseEnv = await read(proc.stdout); + let valueFromBaseEnvEmpty = await read(proc.stdout); + let valueFromBaseEnvRemoved = await read(proc.stdout); + let valueAppendedOnce = await read(proc.stdout); + + equal( + valueFromBaseEnv, + "untouched", + "Got expected $VALUE_FROM_BASE_ENV value" + ); + equal( + valueFromBaseEnvEmpty, + "", + "Got expected $VALUE_FROM_BASE_ENV_EMPTY value" + ); + equal( + valueFromBaseEnvRemoved, + "!", + "Got expected $VALUE_FROM_BASE_ENV_REMOVED value" + ); + equal( + valueAppendedOnce, + "soon empty", + "Got expected $VALUE_APPENDED_ONCE value" + ); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + + proc = await Subprocess.call({ + command: PYTHON, + arguments: [ + "-u", + TEST_SCRIPT, + "env", + "VALUE_FROM_BASE_ENV", + "VALUE_APPENDED_ONCE", + ], + environmentAppend: true, + }); + + valueFromBaseEnv = await read(proc.stdout); + valueAppendedOnce = await read(proc.stdout); + + equal( + valueFromBaseEnv, + "untouched", + "Got expected $VALUE_FROM_BASE_ENV value" + ); + equal(valueAppendedOnce, "!", "Got expected $VALUE_APPENDED_ONCE value"); + + ({ exitCode } = await proc.wait()); + + equal(exitCode, 0, "Got expected exit code"); +}); + +if (AppConstants.platform !== "win") { + add_task(async function test_subprocess_nonASCII() { + const { libc } = ChromeUtils.importESModule( + "resource://gre/modules/subprocess/subprocess_unix.sys.mjs" + ); + + // Use TextDecoder rather than a string with a \xff escape, since + // the latter will automatically be normalized to valid UTF-8. + let val = new TextDecoder().decode(Uint8Array.of(1, 255)); + + libc.setenv( + "FOO", + Uint8Array.from(val + "\0", c => c.charCodeAt(0)), + 1 + ); + + let proc = await Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "FOO"], + }); + + let foo = await read(proc.stdout); + + equal(foo, val, "Got expected $FOO value"); + + Services.env.set("FOO", ""); + + let { exitCode } = await proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + }); +} + +add_task(async function test_bad_executable() { + // Test with a non-executable file. + + let textFile = do_get_file("data_text_file.txt").path; + + let promise = Subprocess.call({ + command: textFile, + arguments: [], + }); + + await Assert.rejects( + promise, + function (error) { + if (AppConstants.platform == "win") { + return /Failed to create process/.test(error.message); + } + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable" + ); + + // Test with a nonexistent file. + promise = Subprocess.call({ + command: textFile + ".doesNotExist", + arguments: [], + }); + + await Assert.rejects( + promise, + function (error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable" + ); +}); + +add_task(async function test_cleanup() { + let { getSubprocessImplForTest } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" + ); + + let worker = getSubprocessImplForTest().Process.getWorker(); + + let openFiles = await worker.call("getOpenFiles", []); + let processes = await worker.call("getProcesses", []); + + equal(openFiles.size, 0, "No remaining open files"); + equal(processes.size, 0, "No remaining processes"); +}); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js new file mode 100644 index 0000000000..cb4ea7247d --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js @@ -0,0 +1,15 @@ +"use strict"; + +add_task(async function test_getEnvironment() { + Services.env.set("FOO", "BAR"); + + let environment = Subprocess.getEnvironment(); + + equal(environment.FOO, "BAR"); + equal(environment.PATH, Services.env.get("PATH")); + + Services.env.set("FOO", null); + + environment = Subprocess.getEnvironment(); + equal(environment.FOO || "", ""); +}); diff --git a/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js new file mode 100644 index 0000000000..9d9374f536 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js @@ -0,0 +1,87 @@ +"use strict"; + +const PYTHON = Services.env.get("PYTHON"); + +const PYTHON_BIN = PathUtils.filename(PYTHON); +const PYTHON_DIR = PathUtils.parent(PYTHON); + +const DOES_NOT_EXIST = PathUtils.join( + PathUtils.tempDir, + "ThisPathDoesNotExist" +); + +const PATH_SEP = AppConstants.platform == "win" ? ";" : ":"; + +add_task(async function test_pathSearchAbsolute() { + let env = {}; + + let path = await Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with no PATH."); + + env.PATH = ""; + path = await Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with empty PATH."); + + await Assert.rejects( + Subprocess.pathSearch(DOES_NOT_EXIST, env), + function (e) { + equal( + e.errorCode, + Subprocess.ERROR_BAD_EXECUTABLE, + "Got the expected error code" + ); + return /File at path .* does not exist, or is not (executable|a normal file)/.test( + e.message + ); + }, + "Absolute path should throw for a nonexistent execuable" + ); +}); + +add_task(async function test_pathSearchRelative() { + let env = {}; + + await Assert.rejects( + Subprocess.pathSearch(PYTHON_BIN, env), + function (e) { + equal( + e.errorCode, + Subprocess.ERROR_BAD_EXECUTABLE, + "Got the expected error code" + ); + return /Executable not found:/.test(e.message); + }, + "Relative path should not be found when PATH is missing" + ); + + env.PATH = [DOES_NOT_EXIST, PYTHON_DIR].join(PATH_SEP); + + let path = await Subprocess.pathSearch(PYTHON_BIN, env); + equal(path, PYTHON, "Correct executable should be found in the path"); +}); + +add_task( + { + skip_if: () => AppConstants.platform != "win", + }, + async function test_pathSearch_PATHEXT() { + ok(PYTHON_BIN.endsWith(".exe"), "Python executable must end with .exe"); + + const python_bin = PYTHON_BIN.slice(0, -4); + + let env = { + PATH: PYTHON_DIR, + PATHEXT: [".com", ".exe", ".foobar"].join(";"), + }; + + let path = await Subprocess.pathSearch(python_bin, env); + equal( + path, + PYTHON, + "Correct executable should be found in the path, with guessed extension" + ); + } +); +// IMPORTANT: Do not add any tests beyond this point without removing +// the `skip_if` condition from the previous task, or it will prevent +// all succeeding tasks from running when it does not match. diff --git a/toolkit/modules/subprocess/test/xpcshell/xpcshell.toml b/toolkit/modules/subprocess/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..776900b676 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" +skip-if = ["os == 'android'"] +subprocess = true +support-files = [ + "data_text_file.txt", + "data_test_script.py", +] + +["test_subprocess.js"] +skip-if = [ + "verify", + "apple_silicon", # bug 1729546 +] +run-sequentially = "very high failure rate in parallel" + +["test_subprocess_getEnvironment.js"] + +["test_subprocess_pathSearch.js"] |