diff --git a/dom/system/OSFileConstants.cpp b/dom/system/OSFileConstants.cpp index 0c738ea78e7f..1d15c3f86c77 100644 --- a/dom/system/OSFileConstants.cpp +++ b/dom/system/OSFileConstants.cpp @@ -500,7 +500,9 @@ static const dom::ConstantSpec gLibcProperties[] = INT_CONSTANT(POLLOUT), // wait +#if defined(WNOHANG) INT_CONSTANT(WNOHANG), +#endif // defined(WNOHANG) // fcntl command values INT_CONSTANT(F_GETLK), diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index 0572d18a0252..563b9b90035b 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -106,6 +106,10 @@ if 'Android' != CONFIG['OS_TARGET']: EXTRA_JS_MODULES += [ 'LightweightThemeConsumer.jsm', ] + + DIRS += [ + 'subprocess', + ] else: DEFINES['ANDROID'] = True diff --git a/toolkit/modules/subprocess/.eslintrc b/toolkit/modules/subprocess/.eslintrc new file mode 100644 index 000000000000..ce2ff918dfd8 --- /dev/null +++ b/toolkit/modules/subprocess/.eslintrc @@ -0,0 +1,26 @@ +{ + "extends": "../../components/extensions/.eslintrc", + + "env": { + "worker": true, + }, + + "globals": { + "ChromeWorker": false, + "Components": false, + "LIBC": true, + "Library": true, + "OS": false, + "Services": false, + "SubprocessConstants": true, + "ctypes": false, + "debug": true, + "dump": false, + "libc": true, + "unix": true, + }, + + "rules": { + "no-console": 0, + }, +} diff --git a/toolkit/modules/subprocess/Subprocess.jsm b/toolkit/modules/subprocess/Subprocess.jsm new file mode 100644 index 000000000000..d32ac2382704 --- /dev/null +++ b/toolkit/modules/subprocess/Subprocess.jsm @@ -0,0 +1,163 @@ +/* -*- 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. + */ + +"use strict"; + +let EXPORTED_SYMBOLS = ["Subprocess"]; + +/* exported Subprocess */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +if (AppConstants.platform == "win") { + XPCOMUtils.defineLazyModuleGetter(this, "SubprocessImpl", + "resource://gre/modules/subprocess/subprocess_win.jsm"); +} else { + XPCOMUtils.defineLazyModuleGetter(this, "SubprocessImpl", + "resource://gre/modules/subprocess/subprocess_unix.jsm"); +} + +/** + * Allows for creation of and communication with OS-level sub-processes. + * @namespace + */ +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 execuable 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. 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. 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. + * + * @returns {Promise} + * + * @rejects {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; + + let environment = {}; + if (!options.environment || options.environmentAppend) { + environment = this.getEnvironment(); + } + + if (options.environment) { + Object.assign(environment, options.environment); + } + + options.environment = Object.keys(environment) + .map(key => `${key}=${environment[key]}`); + + options.arguments = Array.from(options.arguments || []); + + return Promise.resolve(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 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 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} 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. If not provided, full the current process environment + * is used. + * @returns {Promise} + */ + 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 = SubprocessImpl.pathSearch(command, environment); + return Promise.resolve(path); + }, +}; + +Object.assign(Subprocess, SubprocessConstants); +Object.freeze(Subprocess); diff --git a/toolkit/modules/subprocess/docs/index.rst b/toolkit/modules/subprocess/docs/index.rst new file mode 100644 index 000000000000..cb2d439a4979 --- /dev/null +++ b/toolkit/modules/subprocess/docs/index.rst @@ -0,0 +1,227 @@ +.. _Subprocess: + +================= +Supbrocess 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 000000000000..ff4ac6d4137b --- /dev/null +++ b/toolkit/modules/subprocess/moz.build @@ -0,0 +1,32 @@ +# -*- Mode: python; c-basic-offset: 4; 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.jsm', +] + +EXTRA_JS_MODULES.subprocess += [ + 'subprocess_common.jsm', + 'subprocess_shared.js', + 'subprocess_worker_common.js', +] + +if CONFIG['OS_TARGET'] == 'WINNT': + EXTRA_JS_MODULES.subprocess += [ + 'subprocess_shared_win.js', + 'subprocess_win.jsm', + 'subprocess_worker_win.js', + ] +else: + EXTRA_JS_MODULES.subprocess += [ + 'subprocess_shared_unix.js', + 'subprocess_unix.jsm', + 'subprocess_worker_unix.js', + ] + +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini'] + +SPHINX_TREES['toolkit_modules/subprocess'] = ['docs'] diff --git a/toolkit/modules/subprocess/subprocess_common.jsm b/toolkit/modules/subprocess/subprocess_common.jsm new file mode 100644 index 000000000000..5d304320d3cf --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_common.jsm @@ -0,0 +1,681 @@ +/* -*- 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported BaseProcess, PromiseWorker */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.importGlobalProperties(["TextDecoder"]); + +XPCOMUtils.defineLazyModuleGetter(this, "setTimeout", + "resource://gre/modules/Timer.jsm"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); + +var EXPORTED_SYMBOLS = ["BaseProcess", "PromiseWorker", "SubprocessConstants"]; + +const BUFFER_SIZE = 4096; + +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. + */ +class PromiseWorker extends ChromeWorker { + constructor(url) { + super(url); + + this.listeners = new Map(); + this.pendingResponses = new Map(); + + 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); + } + + /** + * 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) { + Cu.reportError(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} + * 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} + * Resolves to an object with a `bytesWritten` property, containing + * the number of bytes successfully written, once the operation has + * completed. + * + * @rejects {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. + * + * @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} + * + * @rejects {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} + * + * @rejects {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} + * + * @rejects {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} + * + * @rejects {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 + * @extends BaseProcess + */ + +/** + * Represents a currently-running process, and allows interaction with it. + */ +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} + */ + 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"); + } + + /** + * 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 PromiseWorker(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} + * 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) { + 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} + * 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 000000000000..b52d22ecccff --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared.js @@ -0,0 +1,94 @@ +/* -*- 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 Library, SubprocessConstants */ + +if (!ArrayBuffer.transfer) { + /** + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer/transfer + */ + 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 = {}; + +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 + */ +const 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, +}; + +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 000000000000..534c4be2c367 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_unix.js @@ -0,0 +1,157 @@ +/* -*- 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 */ + +const LIBC = OS.Constants.libc; + +const LIBC_CHOICES = ["libc.so", "libSystem.B.dylib", "a.out"]; + +const unix = { + pid_t: ctypes.int32_t, + + pollfd: new ctypes.StructType("pollfd", [ + {"fd": ctypes.int}, + {"events": ctypes.short}, + {"revents": ctypes.short}, + ]), + + posix_spawn_file_actions_t: ctypes.uint8_t.array( + LIBC.OSFILE_SIZEOF_POSIX_SPAWN_FILE_ACTIONS_T), + + 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, + ], + + 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 */ + ], + + posix_spawn: [ + ctypes.default_abi, + ctypes.int, + unix.pid_t.ptr, /* pid */ + ctypes.char.ptr, /* path */ + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.voidptr_t, /* attrp */ + ctypes.char.ptr.ptr, /* argv */ + ctypes.char.ptr.ptr, /* envp */ + ], + + posix_spawn_file_actions_addclose: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.int, /* fildes */ + ], + + posix_spawn_file_actions_adddup2: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ctypes.int, /* fildes */ + ctypes.int, /* newfildes */ + ], + + posix_spawn_file_actions_destroy: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ], + + posix_spawn_file_actions_init: [ + ctypes.default_abi, + ctypes.int, + unix.posix_spawn_file_actions_t.ptr, /* file_actions */ + ], + + 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 000000000000..774a0c35ff2f --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_shared_win.js @@ -0,0 +1,343 @@ +/* -*- 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, Win, createPipe, libc */ + +const LIBC = OS.Constants.libc; + +const Win = OS.Constants.Win; + +const LIBC_CHOICES = ["kernel32.dll"]; + +const win32 = { + // On Windows 64, winapi_abi is an alias for default_abi. + WINAPI: ctypes.winapi_abi, + + BYTE: ctypes.uint8_t, + WORD: ctypes.uint16_t, + DWORD: ctypes.uint32_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, +}; + +Object.assign(win32, { + LPSTR: win32.CHAR.ptr, + LPWSTR: win32.WCHAR.ptr, + + LPBYTE: win32.BYTE.ptr, + LPDWORD: win32.DWORD.ptr, + LPHANDLE: win32.HANDLE.ptr, +}); + +Object.assign(win32, { + LPCSTR: win32.LPSTR, + LPCWSTR: win32.LPWSTR, + LPCVOID: win32.LPVOID, +}); + +Object.assign(win32, { + CREATE_NEW_CONSOLE: 0x00000010, + CREATE_UNICODE_ENVIRONMENT: 0x00000400, + CREATE_NO_WINDOW: 0x08000000, + + STARTF_USESTDHANDLES: 0x0100, + + DUPLICATE_CLOSE_SOURCE: 0x01, + DUPLICATE_SAME_ACCESS: 0x02, + + ERROR_HANDLE_EOF: 38, + ERROR_BROKEN_PIPE: 109, + + FILE_FLAG_OVERLAPPED: 0x40000000, + + 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, + + // 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, { + 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}, + ]), +}); + +var libc = new Library("libc", LIBC_CHOICES, { + 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 */ + ], + + 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 */ + ], + + 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 */ + ], + + 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 */ + ], + + TerminateProcess: [ + win32.WINAPI, + win32.BOOL, + win32.HANDLE, /* hProcess */ + win32.UINT, /* uExitCode */ + ], + + 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 |= Win.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.HANDLE(Win.INVALID_HANDLE_VALUE)); + + if (isInvalid(readHandle)) { + return []; + } + + let writeHandle = libc.CreateFileW( + pipeName, Win.GENERIC_WRITE, 0, secAttr.address(), + Win.OPEN_EXISTING, writeFlags, null); + + if (isInvalid(writeHandle)) { + libc.CloseHandle(readHandle); + return []; + } + + return [win32.Handle(readHandle), + win32.Handle(writeHandle)]; +}; diff --git a/toolkit/modules/subprocess/subprocess_unix.jsm b/toolkit/modules/subprocess/subprocess_unix.jsm new file mode 100644 index 000000000000..be725126068c --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_unix.jsm @@ -0,0 +1,120 @@ +/* -*- 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported SubprocessImpl */ + +/* globals BaseProcess */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +var EXPORTED_SYMBOLS = ["SubprocessImpl"]; + +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared_unix.js", this); + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_worker_unix.js"; + } +} + +var SubprocessUnix = { + Process, + + call(options) { + return Process.create(options); + }, + + * getEnvironment() { + let environ; + if (OS.Constants.Sys.Name == "Darwin") { + environ = libc._NSGetEnviron().contents; + } else { + environ = libc.environ; + } + + for (let envp = environ; !envp.contents.isNull(); envp = envp.increment()) { + let str = envp.contents.readString(); + + let idx = str.indexOf("="); + if (idx >= 0) { + yield [str.slice(0, idx), + str.slice(idx + 1)]; + } + } + }, + + isExecutableFile: Task.async(function* isExecutable(path) { + if (!OS.Path.split(path).absolute) { + return false; + } + + try { + let info = yield OS.File.stat(path); + + // FIXME: We really want access(path, X_OK) here, but OS.File does not + // support it. + return !info.isDir && (info.unixMode & 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} + */ + pathSearch: Task.async(function* (bin, environment) { + let split = OS.Path.split(bin); + if (split.absolute) { + if (yield 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 (environment.PATH) { + dirs = environment.PATH.split(":"); + } + + for (let dir of dirs) { + let path = OS.Path.join(dir, bin); + + if (yield this.isExecutableFile(path)) { + return path; + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }), +}; + +var SubprocessImpl = SubprocessUnix; diff --git a/toolkit/modules/subprocess/subprocess_win.jsm b/toolkit/modules/subprocess/subprocess_win.jsm new file mode 100644 index 000000000000..de8887b478f1 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_win.jsm @@ -0,0 +1,138 @@ +/* -*- 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"; + +/* eslint-disable mozilla/balanced-listeners */ + +/* exported SubprocessImpl */ + +/* globals BaseProcess */ + +var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +var EXPORTED_SYMBOLS = ["SubprocessImpl"]; + +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/subprocess/subprocess_common.jsm"); + +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared.js", this); +Services.scriptloader.loadSubScript("resource://gre/modules/subprocess/subprocess_shared_win.js", this); + +class Process extends BaseProcess { + static get WORKER_URL() { + return "resource://gre/modules/subprocess/subprocess_worker_win.js"; + } +} + +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); + } + }, + + isExecutableFile: Task.async(function* (path) { + if (!OS.Path.split(path).absolute) { + return false; + } + + try { + let info = yield OS.File.stat(path); + return !(info.isDir || info.isSymlink); + } 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} + */ + pathSearch: Task.async(function* (bin, environment) { + let split = OS.Path.split(bin); + if (split.absolute) { + if (yield 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 = OS.Path.join(dir, bin); + + if (yield this.isExecutableFile(path)) { + return path; + } + + for (let ext of exts) { + let file = path + ext; + + if (yield this.isExecutableFile(file)) { + return file; + } + } + } + let error = new Error(`Executable not found: ${bin}`); + error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE; + throw error; + }), +}; + +var SubprocessImpl = SubprocessWin; diff --git a/toolkit/modules/subprocess/subprocess_worker_common.js b/toolkit/modules/subprocess/subprocess_worker_common.js new file mode 100644 index 000000000000..bda7c014d02c --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_common.js @@ -0,0 +1,193 @@ +/* -*- 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 BasePipe, BaseProcess, debug */ +/* globals Process, io */ + +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 == 0) { + 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.stringArrays = []; + + this.spawn(options); + } + + /** + * Creates a null-terminated array of pointers to null-terminated C-strings, + * and returns it. + * + * @param {string[]} strings + * The strings to convert into a C string array. + * + * @returns {ctypes.char.ptr.array()} + */ + stringArray(strings) { + let result = ctypes.char.ptr.array(strings.length + 1)(); + + let cstrings = strings.map(str => ctypes.char.array()(str)); + for (let [i, cstring] of cstrings.entries()) { + result[i] = cstring; + } + + // Char arrays used in char arg and environment vectors must be + // explicitly kept alive in a JS object, or they will be reaped + // by the GC if it runs before our process is started. + this.stringArrays.push(cstrings); + + return result; + } +} + +let requests = { + 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(); + + return process.exitPromise.then(exitCode => { + io.cleanupProcess(process); + 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(), + proc => [proc.id, proc.pid])); + return {data}; + }, +}; + +onmessage = event => { + 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/subprocess_worker_unix.js b/toolkit/modules/subprocess/subprocess_worker_unix.js new file mode 100644 index 000000000000..9cea0cee8902 --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_unix.js @@ -0,0 +1,539 @@ +/* -*- 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 */ +/* globals BaseProcess, BasePipe */ + +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_INTERVAL = 50; +const POLL_TIMEOUT = 0; + +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} + * 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} + */ + 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. + */ + onReady() { + let reads = this.pending; + while (reads.length) { + let {resolve, length} = reads[0]; + + let buffer = this.readBuffer(length); + if (buffer) { + this.shiftPending(); + resolve(buffer); + } else { + break; + } + } + + if (reads.length == 0) { + io.updatePollFds(); + } + } +} + +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} + * 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 == 0) { + io.updatePollFds(); + } + } +} + +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. + * + * @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 {command, arguments: args} = options; + + let argv = this.stringArray(args); + let envp = this.stringArray(options.environment); + + let actions = unix.posix_spawn_file_actions_t(); + let actionsp = actions.address(); + + let fds = this.initPipes(options); + + let cwd; + try { + if (options.workdir) { + cwd = ctypes.char.array(LIBC.PATH_MAX)(); + libc.getcwd(cwd, cwd.length); + + if (libc.chdir(options.workdir) < 0) { + throw new Error(`Unable to change working directory to ${options.workdir}`); + } + } + + libc.posix_spawn_file_actions_init(actionsp); + for (let [i, fd] of fds.entries()) { + libc.posix_spawn_file_actions_adddup2(actionsp, fd, i); + } + + let pid = unix.pid_t(); + let rv = libc.posix_spawn(pid.address(), command, actionsp, null, argv, envp); + + if (rv != 0) { + for (let pipe of this.pipes) { + pipe.close(); + } + throw new Error(`Failed to execute command "${command}"`); + } + + this.pid = pid.value; + } finally { + libc.posix_spawn_file_actions_destroy(actionsp); + + this.stringArrays.length = 0; + + if (cwd) { + libc.chdir(cwd); + } + 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 (res == this.pid) { + let sig = unix.WTERMSIG(status.value); + if (sig) { + this.exitCode = -sig; + } else { + this.exitCode = unix.WEXITSTATUS(status.value); + } + + this.fd.dispose(); + this.resolveExit(this.exitCode); + return this.exitCode; + } + } +} + +io = { + pollFds: null, + pollHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + interval: null, + + 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.pipes.values(), + ...this.processes.values()]; + + handlers = handlers.filter(handler => handler.pollEvents); + + 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; + + if (pollfds.length && !this.interval) { + this.interval = setInterval(this.poll.bind(this), POLL_INTERVAL); + } else if (!pollfds.length && this.interval) { + clearInterval(this.interval); + this.interval = null; + } + }, + + poll() { + let handlers = this.pollHandlers; + let pollfds = this.pollFds; + + let count = libc.poll(pollfds, pollfds.length, POLL_TIMEOUT); + + for (let i = 0; count && i < pollfds.length; i++) { + let pollfd = pollfds[i]; + if (pollfd.revents) { + count--; + + let handler = handlers[i]; + try { + if (pollfd.revents & handler.pollEvents) { + handler.onReady(); + } + if (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_worker_win.js b/toolkit/modules/subprocess/subprocess_worker_win.js new file mode 100644 index 000000000000..89fb476afc8f --- /dev/null +++ b/toolkit/modules/subprocess/subprocess_worker_win.js @@ -0,0 +1,594 @@ +/* -*- 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 */ +/* globals BaseProcess, BasePipe, win32 */ + +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_INTERVAL = 50; +const POLL_TIMEOUT = 0; + +// 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} + * 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} + */ + 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} + * 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 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.TerminateProcess(this.handle, 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]; + } else { + 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); + } + + 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. + */ + 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 + */ + 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; + + args = args.map(arg => this.quoteString(arg)); + + let envp = this.stringList(options.environment); + + let handles = this.initPipes(options); + + let processFlags = win32.CREATE_NO_WINDOW + | win32.CREATE_UNICODE_ENVIRONMENT; + + let startupInfo = new win32.STARTUPINFOW(); + startupInfo.cb = win32.STARTUPINFOW.size; + startupInfo.dwFlags = win32.STARTF_USESTDHANDLES; + + startupInfo.hStdInput = handles[0]; + startupInfo.hStdOutput = handles[1]; + startupInfo.hStdError = handles[2]; + + let procInfo = new win32.PROCESS_INFORMATION(); + + 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)) { + handle.dispose(); + } + + if (!ok) { + for (let pipe of this.pipes) { + pipe.close(); + } + throw new Error("Failed to create process"); + } + + libc.CloseHandle(procInfo.hThread); + + this.handle = win32.Handle(procInfo.hProcess); + this.pid = procInfo.dwProcessId; + } + + /** + * 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; + + for (let pipe of this.pipes) { + pipe.maybeClose(); + } + + io.updatePollEvents(); + + return exitCode; + } + } +} + +io = { + events: null, + eventHandlers: null, + + pipes: new Map(), + + processes: new Map(), + + interval: null, + + 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.pipes.values(), + ...this.processes.values()]; + + handlers = handlers.filter(handler => handler.event); + + this.eventHandlers = handlers; + + let handles = handlers.map(handler => handler.event); + this.events = win32.HANDLE.array()(handles); + + if (handles.length && !this.interval) { + this.interval = setInterval(this.poll.bind(this), POLL_INTERVAL); + } else if (!handlers.length && this.interval) { + clearInterval(this.interval); + this.interval = null; + } + }, + + poll() { + for (;;) { + let events = this.events; + let handlers = this.eventHandlers; + + let result = libc.WaitForMultipleObjects(events.length, events, + false, POLL_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/test/xpcshell/.eslintrc b/toolkit/modules/subprocess/test/xpcshell/.eslintrc new file mode 100644 index 000000000000..60fdab2176e8 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "../../../../../testing/xpcshell/xpcshell.eslintrc", +} 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 000000000000..035d8ac569f6 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/data_test_script.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python2 +from __future__ import print_function + +import os +import signal +import struct +import sys + + +def output(line): + sys.stdout.write(struct.pack('@I', len(line))) + sys.stdout.write(line) + sys.stdout.flush() + + +def echo_loop(): + while True: + line = sys.stdin.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': + sys.stdout.write(sys.argv[2]) + sys.stderr.write(sys.argv[3]) 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 000000000000..e69de29bb2d1 diff --git a/toolkit/modules/subprocess/test/xpcshell/head.js b/toolkit/modules/subprocess/test/xpcshell/head.js new file mode 100644 index 000000000000..b3175d08a067 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/head.js @@ -0,0 +1,14 @@ +"use strict"; + +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Subprocess", + "resource://gre/modules/Subprocess.jsm"); 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 000000000000..a79a0d006bbe --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess.js @@ -0,0 +1,682 @@ +"use strict"; + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + + +const env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +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 = Task.async(function* (pipe) { + let result = []; + let string; + while ((string = yield pipe.readString())) { + result.push(string); + } + + return result.join(""); +}); + + +add_task(function* setup() { + PYTHON = yield Subprocess.pathSearch(env.get("PYTHON")); + + PYTHON_BIN = OS.Path.basename(PYTHON); + PYTHON_DIR = OS.Path.dirname(PYTHON); +}); + + +add_task(function* test_subprocess_io() { + let proc = yield 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); + + yield new Promise(resolve => setTimeout(resolve, 100)); + + let [output] = yield 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); + + yield new Promise(resolve => setTimeout(resolve, 100)); + + [output] = yield 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 = yield proc.stdout.readUint32().then(count => { + return proc.stdout.readJSON(count); + }); + + Assert.deepEqual(output, JSON_BLOB, "Got expected JSON output"); + + + yield proc.stdin.close(); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_large_io() { + let proc = yield 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 * .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 = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + output = yield read(proc.stdout); + equal(output, LINE, "Got the expected output"); + + proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_huge() { + let proc = yield 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 = yield read(proc.stdout); + equal(output, msg, "Got the expected output"); + + proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_stderr_default() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield 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 = yield readAll(proc.stdout); + + equal(stdout, LINE1, "Got the expected stdout output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_stderr_pipe() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + let [stdout, stderr] = yield 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} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_stderr_merged() { + const LINE1 = "I'm a leaf on the wind.\n"; + const LINE2 = "Watch how I soar.\n"; + + let proc = yield 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 = yield readAll(proc.stdout); + + equal(stdout, LINE1 + LINE2, "Got the expected merged stdout output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(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 = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print", LINE1, LINE2], + stderr: "pipe", + }); + + + let {exitCode} = yield proc.wait(); + equal(exitCode, 0, "Process exited with expected code"); + + + let [stdout, stderr] = yield 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(function* test_subprocess_lazy_close_output() { + let proc = yield 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 = yield read(proc.stdout); + let output2 = yield read(proc.stdout); + + yield Promise.all([...writePromises, closedPromise]); + + equal(output1, LINE1, "Got expected output"); + equal(output2, LINE2, "Got expected output"); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_lazy_close_input() { + let proc = yield 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 = yield readPromise; + equal(len, LINE.length); + + yield closedPromise; + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_force_close() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + let closedPromise = proc.stdout.close(true); + + yield 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"); + + yield closedPromise; + yield proc.stdin.close(); + + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_eof() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + let readPromise = proc.stdout.readUint32(); + + yield proc.stdin.close(); + + yield 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} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_invalid_json() { + let proc = yield 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 = yield proc.stdout.readUint32(); + let readPromise = proc.stdout.readJSON(count); + + yield 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} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_wait() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "exit", "42"], + }); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 42, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_pathSearch() { + let promise = Subprocess.call({ + command: PYTHON_BIN, + arguments: ["-u", TEST_SCRIPT, "exit", "13"], + environment: { + PATH: PYTHON_DIR, + }, + }); + + yield Assert.rejects( + promise, + function(error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable"); +}); + + +add_task(function* test_subprocess_workdir() { + let procDir = yield OS.File.getCurrentDirectory(); + let tmpDir = OS.Constants.Path.tmpDir; + + notEqual(procDir, tmpDir, + "Current process directory must not be the current temp directory"); + + function* pwd(options) { + let proc = yield Subprocess.call(Object.assign({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "pwd"], + }, options)); + + let pwd = read(proc.stdout); + + let {exitCode} = yield proc.wait(); + equal(exitCode, 0, "Got expected exit code"); + + return pwd; + } + + let dir = yield pwd({}); + equal(dir, procDir, "Process should normally launch in current process directory"); + + dir = yield pwd({workdir: tmpDir}); + equal(dir, tmpDir, "Process should launch in the directory specified in `workdir`"); + + dir = yield OS.File.getCurrentDirectory(); + equal(dir, procDir, "`workdir` should not change the working directory of the current process"); +}); + + +add_task(function* test_subprocess_term() { + let proc = yield 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} = yield proc.kill(); + + equal(exitCode, retVal, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, retVal, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_kill() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "echo"], + }); + + // Force kill with no gracefull termination timeout. + let {exitCode} = yield proc.kill(0); + + equal(exitCode, -9, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + + +add_task(function* test_subprocess_kill_timeout() { + let proc = yield 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 = yield 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} = yield 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; + ok(diff >= TIMEOUT, `Process was killed after ${diff}ms (expected ~${TIMEOUT}ms)`); + } + + equal(exitCode, -9, "Got expected exit code"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, -9, "Got expected exit code"); +}); + + +add_task(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 = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "print_args", ...args], + }); + + for (let [i, arg] of args.entries()) { + let val = yield read(proc.stdout); + equal(val, arg, `Got correct value for args[${i}]`); + } + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +// Windows XP can't handle launching Python with a partial environment. +if (!AppConstants.isPlatformAndVersionAtMost("win", "5.2")) { + add_task(function* test_subprocess_environment() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environment: { + FOO: "BAR", + }, + }); + + let path = yield read(proc.stdout); + let foo = yield read(proc.stdout); + + equal(path, "", "Got expected $PATH value"); + equal(foo, "BAR", "Got expected $FOO value"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + }); +} + + +add_task(function* test_subprocess_environmentAppend() { + let proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environmentAppend: true, + environment: { + FOO: "BAR", + }, + }); + + let path = yield read(proc.stdout); + let foo = yield read(proc.stdout); + + equal(path, env.get("PATH"), "Got expected $PATH value"); + equal(foo, "BAR", "Got expected $FOO value"); + + let {exitCode} = yield proc.wait(); + + equal(exitCode, 0, "Got expected exit code"); + + proc = yield Subprocess.call({ + command: PYTHON, + arguments: ["-u", TEST_SCRIPT, "env", "PATH", "FOO"], + environmentAppend: true, + }); + + path = yield read(proc.stdout); + foo = yield read(proc.stdout); + + equal(path, env.get("PATH"), "Got expected $PATH value"); + equal(foo, "", "Got expected $FOO value"); + + ({exitCode} = yield proc.wait()); + + equal(exitCode, 0, "Got expected exit code"); +}); + + +add_task(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: [], + }); + + yield 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: [], + }); + + yield Assert.rejects( + promise, + function(error) { + return error.errorCode == Subprocess.ERROR_BAD_EXECUTABLE; + }, + "Subprocess.call should fail for a bad executable"); +}); + + +add_task(function* test_cleanup() { + let {SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm"); + + let worker = SubprocessImpl.Process.getWorker(); + + let openFiles = yield worker.call("getOpenFiles", []); + let processes = yield 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 000000000000..4606aec04e67 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_getEnvironment.js @@ -0,0 +1,17 @@ +"use strict"; + +let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +add_task(function* test_getEnvironment() { + env.set("FOO", "BAR"); + + let environment = Subprocess.getEnvironment(); + + equal(environment.FOO, "BAR"); + equal(environment.PATH, env.get("PATH")); + + 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 000000000000..fc5d26b2da8f --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/test_subprocess_pathSearch.js @@ -0,0 +1,73 @@ +"use strict"; + +let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + +const PYTHON = env.get("PYTHON"); + +const PYTHON_BIN = OS.Path.basename(PYTHON); +const PYTHON_DIR = OS.Path.dirname(PYTHON); + +const DOES_NOT_EXIST = OS.Path.join(OS.Constants.Path.tmpDir, + "ThisPathDoesNotExist"); + +const PATH_SEP = AppConstants.platform == "win" ? ";" : ":"; + + +add_task(function* test_pathSearchAbsolute() { + let env = {}; + + let path = yield Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with no PATH."); + + env.PATH = ""; + path = yield Subprocess.pathSearch(PYTHON, env); + equal(path, PYTHON, "Full path resolves even with empty PATH."); + + yield 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(function* test_pathSearchRelative() { + let env = {}; + + yield 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 = yield Subprocess.pathSearch(PYTHON_BIN, env); + equal(path, PYTHON, "Correct executable should be found in the path"); +}); + + +add_task({ + skip_if: () => AppConstants.platform != "win", +}, 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 = yield 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.ini b/toolkit/modules/subprocess/test/xpcshell/xpcshell.ini new file mode 100644 index 000000000000..328ae52db454 --- /dev/null +++ b/toolkit/modules/subprocess/test/xpcshell/xpcshell.ini @@ -0,0 +1,13 @@ +[DEFAULT] +head = head.js +tail = +firefox-appdir = browser +skip-if = os == 'android' +subprocess = true +support-files = + data_text_file.txt + data_test_script.py + +[test_subprocess.js] +[test_subprocess_getEnvironment.js] +[test_subprocess_pathSearch.js]