Bug 1270357 Implement runtime.connectNative() r?kmag draft
authorAndrew Swan <aswan@mozilla.com>
Wed, 08 Jun 2016 20:23:40 -0700
changeset 376926 b0686acac8313c39e1c174fcd2a887dbb453a43c
parent 376925 a5f6261a71dae62891d88c4677b2e37a0dab25c3
child 523283 8c61e213508deb665d1518035dae1597bf717537
push id20716
push useraswan@mozilla.com
push dateThu, 09 Jun 2016 03:28:12 +0000
reviewerskmag
bugs1270357
milestone50.0a1
Bug 1270357 Implement runtime.connectNative() r?kmag MozReview-Commit-ID: Fo4BxEo3xus
toolkit/components/extensions/NativeMessaging.jsm
toolkit/components/extensions/ext-runtime.js
toolkit/components/extensions/schemas/runtime.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_native_messaging.html
toolkit/components/extensions/test/xpcshell/test_native_messaging.js
--- a/toolkit/components/extensions/NativeMessaging.jsm
+++ b/toolkit/components/extensions/NativeMessaging.jsm
@@ -1,33 +1,66 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["HostManifestManager"];
+this.EXPORTED_SYMBOLS = ["HostManifestManager", "NativeApp"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/AppConstants.jsm");
-Cu.import("resource://devtools/shared/event-emitter.js");
+
+const {EventEmitter} = Cu.import("resource://devtools/shared/event-emitter.js", {});
 
+XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
+                                  "resource://gre/modules/AppConstants.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
+                                  "resource://gre/modules/AsyncShutdown.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionUtils",
+                                  "resource://gre/modules/ExtensionUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                   "resource://gre/modules/Schemas.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Subprocess",
+                                  "resource://gre/modules/Subprocess.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "clearTimeout",
+                                  "resource://gre/modules/Timer.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "setTimeout",
+                                  "resource://gre/modules/Timer.jsm");
 
 const HOST_MANIFEST_SCHEMA = "chrome://extensions/content/schemas/native_host_manifest.json";
 const VALID_APPLICATION = /^\w+(\.\w+)*$/;
 
+// For a graceful shutdown (i.e., when the extension is unloaded or when it
+// explicitly calls disconnect() on a native port), how long we give the native
+// application to exit before we start trying to kill it.  (in milliseconds)
+const GRACEFUL_SHUTDOWN_TIME = 3000;
+
+// Hard limits on maximum message size that can be read/written
+// These are defined in the native messaging documentation, note that
+// the write limit is imposed by the "wire protocol" in which message
+// boundaries are defined by preceding each message with its length as
+// 4-byte unsigned integer so this is the largest value that can be
+// represented.  Good luck generating a serialized message that large,
+// the practical write limit is likely to be dictated by available memory.
+const MAX_READ = 1024 * 1024;
+const MAX_WRITE = 0xffffffff;
+
+// Preferences that can lower the message size limits above,
+// used for testing the limits.
+const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
+const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes";
+
 this.HostManifestManager = {
   _initializePromise: null,
   _lookup: null,
 
   init() {
     if (!this._initializePromise) {
       let platform = AppConstants.platform;
       if (platform == "win") {
@@ -97,13 +130,230 @@ this.HostManifestManager = {
    *
    * @param {string} application The name of the applciation to search for.
    * @param {object} context A context object as expected by Schemas.normalize.
    * @returns {object} The contents of the validated manifest, or null if
    *                   no valid manifest can be found for this application.
    */
   lookupApplication(application, context) {
     if (!VALID_APPLICATION.test(application)) {
-      throw new context.cloneScope.Error(`Invalid application "${application}"`);
+      throw new Error(`Invalid application "${application}"`);
     }
     return this.init().then(() => this._lookup(application, context));
   },
 };
+
+this.NativeApp = class extends EventEmitter {
+  constructor(extension, context, application) {
+    super();
+
+    this.context = context;
+    this.name = application;
+
+    // We want a close() notification when the window is destroyed.
+    this.context.callOnClose(this);
+
+    this.encoder = new TextEncoder();
+    this.proc = null;
+    this.readPromise = null;
+    this.sendQueue = [];
+    this.writePromise = null;
+    this.sentDisconnect = false;
+
+    // Grab these once at startup
+    XPCOMUtils.defineLazyPreferenceGetter(this, "maxRead", PREF_MAX_READ, MAX_READ);
+    XPCOMUtils.defineLazyPreferenceGetter(this, "maxWrite", PREF_MAX_WRITE, MAX_WRITE);
+
+    this.startupPromise = HostManifestManager.lookupApplication(application, context)
+      .then(hostInfo => {
+        if (!hostInfo) {
+          throw new Error(`No such native application ${application}`);
+        }
+
+        if (!hostInfo.manifest.allowed_extensions.includes(extension.id)) {
+          throw new Error(`This extension does not have permission to use native application ${application}`);
+        }
+
+        let subprocessOpts = {
+          command: hostInfo.manifest.path,
+          arguments: [hostInfo.path],
+          workdir: OS.Path.dirname(hostInfo.manifest.path),
+        };
+        return Subprocess.call(subprocessOpts);
+      }).then(proc => {
+        this.startupPromise = null;
+        this.proc = proc;
+        this._startRead();
+        this._startWrite();
+      }).catch(err => {
+        this.startupPromise = null;
+        Cu.reportError(err);
+        this._cleanup(err);
+      });
+  }
+
+  // A port is definitely "alive" if this.proc is non-null.  But we have
+  // to provide a live port object immediately when connecting so we also
+  // need to consider a port alive if proc is null but the startupPromise
+  // is still pending.
+  get _isDisconnected() {
+    return (!this.proc && !this.startupPromise);
+  }
+
+  _startRead() {
+    if (this.readPromise) {
+      throw new Error("Entered _startRead() while readPromise is non-null");
+    }
+    this.readPromise = this.proc.stdout.readUint32()
+      .then(len => {
+        if (len > this.maxRead) {
+          throw new Error(`Native application tried to send a message of ${len} bytes, which exceeds the limit of ${this.maxRead} bytes.`);
+        }
+        return this.proc.stdout.readJSON(len);
+      }).then(msg => {
+        this.emit("message", msg);
+        this.readPromise = null;
+        this._startRead();
+      }).catch(err => {
+        Cu.reportError(err);
+        this._cleanup(err);
+      });
+  }
+
+  _startWrite() {
+    if (this.sendQueue.length == 0) {
+      return;
+    }
+
+    if (this.writePromise) {
+      throw new Error("Entered _startWrite() while writePromise is non-null");
+    }
+
+    let buffer = this.sendQueue.shift();
+    let uintArray = Uint32Array.of(buffer.byteLength);
+
+    this.writePromise = Promise.all([
+      this.proc.stdin.write(uintArray.buffer),
+      this.proc.stdin.write(buffer),
+    ]).then(() => {
+      this.writePromise = null;
+      this._startWrite();
+    }).catch(err => {
+      Cu.reportError(err);
+      this._cleanup(err);
+    });
+  }
+
+  send(msg) {
+    if (this._isDisconnected) {
+      throw new this.context.cloneScope.Error("Attempt to postMessage on disconnected port");
+    }
+
+    let json;
+    try {
+      json = JSON.stringify(msg);
+    } catch (err) {
+      throw new this.context.cloneScope.Error(err.message);
+    }
+    let buffer = this.encoder.encode(json).buffer;
+
+    if (buffer.byteLength > this.maxWrite) {
+      throw new this.context.cloneScope.Error("Write too big");
+    }
+
+    this.sendQueue.push(buffer);
+    if (!this.startupPromise && !this.writePromise) {
+      this._startWrite();
+    }
+  }
+
+  // Shut down the native application and also signal to the extension
+  // that the connect has been disconnected.
+  _cleanup(err) {
+    this.context.forgetOnClose(this);
+
+    let doCleanup = () => {
+      // Set a timer to kill the process gracefully after one timeout
+      // interval and kill it forcefully after two intervals.
+      let timer = setTimeout(() => {
+        this.proc.kill(GRACEFUL_SHUTDOWN_TIME);
+      }, GRACEFUL_SHUTDOWN_TIME);
+
+      let promise = Promise.all([
+        this.proc.stdin.close()
+          .catch(err => {
+            if (err.errorCode != Subprocess.ERROR_END_OF_FILE) {
+              throw err;
+            }
+          }),
+        this.proc.wait(),
+      ]).then(() => {
+        this.proc = null;
+        clearTimeout(timer);
+      });
+
+      AsyncShutdown.profileBeforeChange.addBlocker(
+        `Native Messaging: Wait for application ${this.name} to exit`,
+        promise);
+
+      promise.then(() => {
+        AsyncShutdown.profileBeforeChange.removeBlocker(promise);
+      });
+
+      return promise;
+    };
+
+    if (this.proc) {
+      doCleanup();
+    } else if (this.startupPromise) {
+      this.startupPromise.then(doCleanup);
+    }
+
+    if (!this.sentDisconnect) {
+      this.sentDisconnect = true;
+      this.emit("disconnect", err);
+    }
+  }
+
+  // Called from Context when the extension is shut down.
+  close() {
+    this._cleanup();
+  }
+
+  portAPI() {
+    let api = {
+      name: this.name,
+
+      disconnect: () => {
+        if (this._isDisconnected) {
+          throw new this.context.cloneScope.Error("Attempt to disconnect an already disconnected port");
+        }
+        this._cleanup();
+      },
+
+      postMessage: msg => {
+        this.send(msg);
+      },
+
+      onDisconnect: new ExtensionUtils.SingletonEventManager(this.context, "native.onDisconnect", fire => {
+        let listener = what => {
+          this.context.runSafe(fire);
+        };
+        this.on("disconnect", listener);
+        return () => {
+          this.off("disconnect", listener);
+        };
+      }).api(),
+
+      onMessage: new ExtensionUtils.SingletonEventManager(this.context, "native.onMessage", fire => {
+        let listener = (what, msg) => {
+          this.context.runSafe(fire, msg);
+        };
+        this.on("message", listener);
+        return () => {
+          this.off("message", listener);
+        };
+      }).api(),
+    };
+
+    return Cu.cloneInto(api, this.context.cloneScope, {cloneFunctions: true});
+  }
+};
--- a/toolkit/components/extensions/ext-runtime.js
+++ b/toolkit/components/extensions/ext-runtime.js
@@ -1,18 +1,23 @@
 "use strict";
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
   ignoreEvent,
 } = ExtensionUtils;
 
+XPCOMUtils.defineLazyModuleGetter(this, "NativeApp",
+                                  "resource://gre/modules/NativeMessaging.jsm");
+
 extensions.registerSchemaAPI("runtime", null, (extension, context) => {
   return {
     runtime: {
       onStartup: new EventManager(context, "runtime.onStartup", fire => {
         extension.onStartup = fire;
         return () => {
           extension.onStartup = null;
         };
@@ -45,16 +50,25 @@ extensions.registerSchemaAPI("runtime", 
 
         if (!GlobalManager.extensionMap.has(recipient.extensionId)) {
           return context.wrapPromise(Promise.reject({message: "Invalid extension ID"}),
                                      responseCallback);
         }
         return context.messenger.sendMessage(Services.cpmm, message, recipient, responseCallback);
       },
 
+      connectNative(application) {
+        if (!extension.hasPermission("nativeMessaging")) {
+          throw new context.cloneScope.Error("Permission denied because 'nativeMessaging' permission is missing.");
+        }
+
+        let app = new NativeApp(extension, context, application);
+        return app.portAPI();
+      },
+
       get lastError() {
         return context.lastError;
       },
 
       getManifest() {
         return Cu.cloneInto(extension.manifest, context.cloneScope);
       },
 
--- a/toolkit/components/extensions/schemas/runtime.json
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -1,14 +1,28 @@
 // Copyright 2014 The Chromium Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
 [
   {
+    "namespace": "manifest",
+    "types": [
+      {
+        "$extend": "Permission",
+        "choices": [{
+          "type": "string",
+          "enum": [
+            "nativeMessaging"
+          ]
+        }]
+      }
+    ]
+  },
+  {
     "namespace": "runtime",
     "description": "Use the <code>browser.runtime</code> API to retrieve the background page, return details about the manifest, and listen for and respond to events in the app or extension lifecycle. You can also use this API to convert the relative path of URLs to fully-qualified URLs.",
     "types": [
       {
         "id": "Port",
         "type": "object",
         "description": "An object which allows two way communication with other pages.",
         "properties": {
@@ -257,17 +271,16 @@
         ],
         "returns": {
           "$ref": "Port",
           "description": "Port through which messages can be sent and received. The port's $(ref:runtime.Port onDisconnect) event is fired if the extension/app does not exist. "
         }
       },
       {
         "name": "connectNative",
-        "unsupported": true,
         "type": "function",
         "description": "Connects to a native application in the host machine.",
         "parameters": [
           {
             "type": "string",
             "name": "application",
             "description": "The name of the registered application to connect to."
           }
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -8,16 +8,19 @@ support-files =
 [test_chrome_ext_background_debug_global.html]
 skip-if = (os == 'android') # android doesn't have devtools
 [test_chrome_ext_background_page.html]
 skip-if = (toolkit == 'android') # android doesn't have devtools
 [test_chrome_ext_downloads_download.html]
 [test_chrome_ext_downloads_misc.html]
 [test_chrome_ext_downloads_search.html]
 [test_chrome_ext_eventpage_warning.html]
+[test_chrome_ext_native_messaging.html]
+# Re-enable for Windows with bug 1270359.
+skip-if = os != "mac" && os != "linux"
 [test_chrome_ext_contentscript_unrecognizedprop_warning.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_ext_webnavigation_resolved_urls.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_native_messaging_paths.html]
 # Re-enable for Windows with bug 1270359.
 skip-if = os != "mac" && os != "linux"
 [test_ext_cookies_expiry.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_native_messaging.html
@@ -0,0 +1,508 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <script type="text/javascript" src="test_constants.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* globals OS */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+let {Subprocess, SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm");
+
+const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
+const PREF_MAX_WRITE = "webextensions.native-messaging.max-output-message-bytes";
+
+function getSubprocessCount() {
+  return SubprocessImpl.Process.getWorker().call("getProcesses", [])
+                       .then(result => result.size);
+}
+function waitForSubprocessExit() {
+  return SubprocessImpl.Process.getWorker().call("waitForNoProcesses", []);
+}
+
+let dir = FileUtils.getDir("TmpD", ["NativeMessaging"]);
+dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+info(`Using local directory ${dir.path}\n`);
+
+let dirProvider = {
+  getFile(property) {
+    if (property == "XREUserNativeMessaging") {
+      return dir.clone();
+    }
+    return null;
+  },
+};
+
+Services.dirsvc.registerProvider(dirProvider);
+SimpleTest.registerCleanupFunction(() => {
+  Services.dirsvc.unregisterProvider(dirProvider);
+  dir.remove(true);
+});
+
+function getPath(filename) {
+  return OS.Path.join(dir.path, filename);
+}
+
+// Set up a couple of native applications and their manifests for
+// test to use.
+const ID = "native@tests.mozilla.org";
+
+const ECHO_PATH = getPath("echo.py");
+const ECHO_MANIFEST_PATH = getPath("echo.json");
+const ECHO_MANIFEST = {
+  name: "echo",
+  description: "a native app that echoes back messages it receives",
+  path: ECHO_PATH,
+  type: "stdio",
+  allowed_extensions: [ID],
+};
+
+const INFO_PATH = getPath("info.py");
+const INFO_MANIFEST_PATH = getPath("info.json");
+const INFO_MANIFEST = {
+  name: "info",
+  description: "a native app that gives some info about how it was started",
+  path: INFO_PATH,
+  type: "stdio",
+  allowed_extensions: [ID],
+};
+
+const WONTDIE_PATH = getPath("wontdie.py");
+const WONTDIE_MANIFEST_PATH = getPath("wontdie.json");
+const WONTDIE_MANIFEST = {
+  name: "wontdie",
+  description: "a native app that does not exit when stdin closes or on SIGTERM",
+  path: WONTDIE_PATH,
+  type: "stdio",
+  allowed_extensions: [ID],
+};
+
+add_task(function* setup_scripts() {
+  const PERMS = {unixMode: 0o755};
+  let pythonPath = yield Subprocess.pathSearch("python2.7").catch(err => {
+    if (err.errorCode != Subprocess.ERROR_BAD_EXECUTABLE) {
+      throw err;
+    }
+    return Subprocess.pathSearch("python");
+  });
+
+  const ECHO_SCRIPT = String.raw`#!${pythonPath} -u
+import struct
+import sys
+
+while True:
+    rawlen = sys.stdin.read(4)
+    if len(rawlen) == 0:
+        sys.exit(0)
+    msglen = struct.unpack('@I', rawlen)[0]
+    msg = sys.stdin.read(msglen)
+
+    sys.stdout.write(struct.pack('@I', msglen))
+    sys.stdout.write(msg)
+`;
+
+  yield OS.File.writeAtomic(ECHO_PATH, ECHO_SCRIPT);
+  yield OS.File.setPermissions(ECHO_PATH, PERMS);
+  yield OS.File.writeAtomic(ECHO_MANIFEST_PATH, JSON.stringify(ECHO_MANIFEST));
+
+  const INFO_SCRIPT = String.raw`#!${pythonPath} -u
+import json
+import os
+import struct
+import sys
+
+msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()})
+sys.stdout.write(struct.pack('@I', len(msg)))
+sys.stdout.write(msg)
+sys.exit(0)
+`;
+
+  yield OS.File.writeAtomic(INFO_PATH, INFO_SCRIPT);
+  yield OS.File.setPermissions(INFO_PATH, PERMS);
+  yield OS.File.writeAtomic(INFO_MANIFEST_PATH, JSON.stringify(INFO_MANIFEST));
+
+  const WONTDIE_SCRIPT = String.raw`#!${pythonPath} -u
+import signal
+import struct
+import sys
+
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+while True:
+    rawlen = sys.stdin.read(4)
+    if len(rawlen) == 0:
+        signal.pause()
+    msglen = struct.unpack('@I', rawlen)[0]
+    msg = sys.stdin.read(msglen)
+
+    sys.stdout.write(struct.pack('@I', msglen))
+    sys.stdout.write(msg)
+`;
+
+  yield OS.File.writeAtomic(WONTDIE_PATH, WONTDIE_SCRIPT);
+  yield OS.File.setPermissions(WONTDIE_PATH, PERMS);
+  yield OS.File.writeAtomic(WONTDIE_MANIFEST_PATH, JSON.stringify(WONTDIE_MANIFEST));
+});
+
+// Test the basic operation of native messaging with a simple
+// script that echoes back whatever message is sent to it.
+add_task(function* test_happy_path() {
+  function background() {
+    let port = browser.runtime.connectNative("echo");
+    port.onMessage.addListener(msg => {
+      browser.test.sendMessage("message", msg);
+    });
+    browser.test.onMessage.addListener((what, payload) => {
+      if (what == "send") {
+        if (payload._json) {
+          let json = payload._json;
+          payload.toJSON = () => json;
+          delete payload._json;
+        }
+        port.postMessage(payload);
+      }
+    });
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+  const tests = [
+    {
+      data: "this is a string",
+      what: "simple string",
+    },
+    {
+      data: "Это юникода",
+      what: "unicode string",
+    },
+    {
+      data: {test: "hello"},
+      what: "simple object",
+    },
+    {
+      data: {
+        what: "An object with a few properties",
+        number: 123,
+        bool: true,
+        nested: {what: "another object"},
+      },
+      what: "object with several properties",
+    },
+
+    // enable with bug 1274708
+    //{
+    //  data: {
+    //    ignoreme: true,
+    //    _json: {data: "i have a tojson method"},
+    //  },
+    //  expected: {data: "i have a tojson method"},
+    //  what: "object with toJSON() method",
+    //},
+  ];
+  for (let test of tests) {
+    extension.sendMessage("send", test.data);
+    let response = yield extension.awaitMessage("message");
+    let expected = test.expected || test.data;
+    isDeeply(response, expected, `Echoed a message of type ${test.what}`);
+  }
+
+  let procCount = yield getSubprocessCount();
+  is(procCount, 1, "subprocess is still running");
+  let exitPromise = waitForSubprocessExit();
+  yield extension.unload();
+  yield exitPromise;
+});
+
+// Test calling Port.disconnect()
+add_task(function* test_disconnect() {
+  function background() {
+    let port = browser.runtime.connectNative("echo");
+    port.onMessage.addListener(msg => {
+      browser.test.sendMessage("message", msg);
+    });
+    browser.test.onMessage.addListener((what, payload) => {
+      if (what == "send") {
+        if (payload._json) {
+          let json = payload._json;
+          payload.toJSON = () => json;
+          delete payload._json;
+        }
+        port.postMessage(payload);
+      } else if (what == "disconnect") {
+        try {
+          port.disconnect();
+          browser.test.sendMessage("disconnect-result", {success: true});
+        } catch (err) {
+          browser.test.sendMessage("disconnect-result", {
+            success: false,
+            errmsg: err.message,
+          });
+        }
+      }
+    });
+    browser.test.sendMessage("ready");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+
+  extension.sendMessage("send", "test");
+  let response = yield extension.awaitMessage("message");
+  is(response, "test", "Echoed a string");
+
+  let procCount = yield getSubprocessCount();
+  is(procCount, 1, "subprocess is running");
+
+  extension.sendMessage("disconnect");
+  response = yield extension.awaitMessage("disconnect-result");
+  is(response.success, true, "disconnect succeeded");
+
+  info("waiting for subprocess to exit");
+  yield waitForSubprocessExit();
+  procCount = yield getSubprocessCount();
+  is(procCount, 0, "subprocess is no longer running");
+
+  extension.sendMessage("disconnect");
+  response = yield extension.awaitMessage("disconnect-result");
+  is(response.success, false, "second call to disconnect failed");
+  ok(/already disconnected/.test(response.errmsg), "disconnect error message is reasonable");
+
+  yield extension.unload();
+});
+
+// Test the limit on message size for writing
+add_task(function* test_write_limit() {
+  Services.prefs.setIntPref(PREF_MAX_WRITE, 10);
+  function clearPref() {
+    Services.prefs.clearUserPref(PREF_MAX_WRITE);
+  }
+  SimpleTest.registerCleanupFunction(clearPref);
+
+  function background() {
+    const PAYLOAD = "0123456789A";
+    let port = browser.runtime.connectNative("echo");
+    try {
+      port.postMessage(PAYLOAD);
+      browser.test.sendMessage("result", null);
+    } catch (ex) {
+      browser.test.sendMessage("result", ex.message);
+    }
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+
+  let errmsg = yield extension.awaitMessage("result");
+  isnot(errmsg, null, "native postMessage() failed for overly large message");
+
+  yield extension.unload();
+  yield waitForSubprocessExit();
+
+  clearPref();
+});
+
+// Test the limit on message size for reading
+add_task(function* test_read_limit() {
+  Services.prefs.setIntPref(PREF_MAX_READ, 10);
+  function clearPref() {
+    Services.prefs.clearUserPref(PREF_MAX_READ);
+  }
+  SimpleTest.registerCleanupFunction(clearPref);
+
+  function background() {
+    const PAYLOAD = "0123456789A";
+    let port = browser.runtime.connectNative("echo");
+    port.onDisconnect.addListener(() => {
+      browser.test.sendMessage("result", "disconnected");
+    });
+    port.onMessage.addListener(msg => {
+      browser.test.sendMessage("result", "message");
+    });
+    port.postMessage(PAYLOAD);
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+
+  let result = yield extension.awaitMessage("result");
+  is(result, "disconnected", "native port disconnected on receiving large message");
+
+  yield extension.unload();
+  yield waitForSubprocessExit();
+
+  clearPref();
+});
+
+// Test that an extension without the nativeMessaging permission cannot
+// use native messaging.
+add_task(function* test_ext_permission() {
+  function background() {
+    try {
+      browser.runtime.connectNative("test");
+      browser.test.sendMessage("result", null);
+    } catch (ex) {
+      browser.test.sendMessage("result", ex.message);
+    }
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {},
+  });
+
+  yield extension.startup();
+
+  let errmsg = yield extension.awaitMessage("result");
+  isnot(errmsg, null, "connectNative() failed without nativeMessaging permission");
+  ok(/Permission denied/.test(errmsg), "error message for missing extension permission is reasonable");
+
+  yield extension.unload();
+
+  let procCount = yield getSubprocessCount();
+  is(procCount, 0, "No child process was started");
+});
+
+// Test that an extension that is not listed in allowed_extensions for
+// a native application cannot use that application.
+add_task(function* test_app_permission() {
+  function background() {
+    let port = browser.runtime.connectNative("echo");
+    port.onDisconnect.addListener(() => {
+      browser.test.sendMessage("result", "disconnected");
+    });
+    port.onMessage.addListener(msg => {
+      browser.test.sendMessage("result", "message");
+    });
+    port.postMessage({test: "test"});
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, "somethingelse@tests.mozilla.org");
+
+  yield extension.startup();
+
+  let result = yield extension.awaitMessage("result");
+  is(result, "disconnected", "connectNative() failed without native app permission");
+
+  yield extension.unload();
+
+  let procCount = yield getSubprocessCount();
+  is(procCount, 0, "No child process was started");
+});
+
+// Test that the command-line arguments and working directory for the
+// native application are as expected.
+add_task(function* test_child_process() {
+  function background() {
+    let port = browser.runtime.connectNative("info");
+    port.onMessage.addListener(msg => {
+      browser.test.sendMessage("result", msg);
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+
+  let msg = yield extension.awaitMessage("result");
+  is(msg.args.length, 2, "Received one command line argument");
+  is(msg.args[1], INFO_MANIFEST_PATH, "Command line argument is the path to the native host manifest");
+  is(msg.cwd, OS.Path.dirname(INFO_PATH), "Working directory is the directory containing the native appliation");
+
+  let exitPromise = waitForSubprocessExit();
+  yield extension.unload();
+  yield exitPromise;
+});
+
+// Test that an unresponsive native application still gets killed eventually
+add_task(function* test_unresponsive_native_app() {
+  // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it
+  // just for this test?
+
+  function background() {
+    let port = browser.runtime.connectNative("wontdie");
+
+    const MSG = "echo me";
+    // bounce a message to make sure the process actually starts
+    port.onMessage.addListener(msg => {
+      browser.test.assertEq(msg, MSG, "Received echoed message");
+      browser.test.sendMessage("ready");
+    });
+    port.postMessage(MSG);
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${background})()`,
+    manifest: {
+      permissions: ["nativeMessaging"],
+    },
+  }, ID);
+
+  yield extension.startup();
+  yield extension.awaitMessage("ready");
+
+  let procCount = yield getSubprocessCount();
+  is(procCount, 1, "subprocess is running");
+
+  let exitPromise = waitForSubprocessExit();
+  yield extension.unload();
+  yield exitPromise;
+
+  procCount = yield getSubprocessCount();
+  is(procCount, 0, "subprocess was succesfully killed");
+});
+
+</script>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_native_messaging.js
+++ b/toolkit/components/extensions/test/xpcshell/test_native_messaging.js
@@ -1,20 +1,19 @@
 "use strict";
 
-Cu.import("resource://gre/modules/Services.jsm");
+/* global OS, HostManifestManager, NativeApp */
+Cu.import("resource://gre/modules/AsyncShutdown.jsm");
 Cu.import("resource://gre/modules/FileUtils.jsm");
-
-/* global OS */
+Cu.import("resource://gre/modules/Schemas.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+const {Subprocess, SubprocessImpl} = Cu.import("resource://gre/modules/Subprocess.jsm");
+Cu.import("resource://gre/modules/NativeMessaging.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
 
-/* global HostManifestManager */
-Cu.import("resource://gre/modules/NativeMessaging.jsm");
-
-Components.utils.import("resource://gre/modules/Schemas.jsm");
 const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
 
 let dir = FileUtils.getDir("TmpD", ["NativeMessaging"]);
 dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
 
 let userDir = dir.clone();
 userDir.append("user");
 userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
@@ -43,26 +42,34 @@ do_register_cleanup(() => {
 
 function writeManifest(path, manifest) {
   if (typeof manifest != "string") {
     manifest = JSON.stringify(manifest);
   }
   return OS.File.writeAtomic(path, manifest);
 }
 
+let PYTHON;
 add_task(function* setup() {
   yield Schemas.load(BASE_SCHEMA);
+
+  PYTHON = yield Subprocess.pathSearch("python2.7");
+  if (PYTHON == null) {
+    PYTHON = yield Subprocess.pathSearch("python");
+  }
+  notEqual(PYTHON, null, "Found a suitable python interpreter");
 });
 
 // Test of HostManifestManager.lookupApplication() begin here...
-
 let context = {
   url: null,
   logError() {},
   preprocessors: {},
+  callOnClose: () => {},
+  forgetOnClose: () => {},
 };
 
 let templateManifest = {
   name: "test",
   description: "this is only a test",
   path: "/bin/cat",
   type: "stdio",
   allowed_extensions: ["extension@tests.mozilla.org"],
@@ -158,8 +165,70 @@ add_task(function* test_user_dir_precede
   // test.json is still in the global directory from the previous test
 
   let result = yield HostManifestManager.lookupApplication("test", context);
   notEqual(result, null, "lookupApplication finds a manifest when entries exist in both user-specific and system-wide directories");
   equal(result.path, USER_TEST_JSON, "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist");
   deepEqual(result.manifest, templateManifest, "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist");
 });
 
+// Test shutdown handling in NativeApp
+add_task(function* test_native_app_shutdown() {
+  const SCRIPT = String.raw`#!${PYTHON} -u
+import signal
+import struct
+import sys
+
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+while True:
+    rawlen = sys.stdin.read(4)
+    if len(rawlen) == 0:
+        signal.pause()
+    msglen = struct.unpack('@I', rawlen)[0]
+    msg = sys.stdin.read(msglen)
+
+    sys.stdout.write(struct.pack('@I', msglen))
+    sys.stdout.write(msg)
+`;
+
+  let scriptPath = OS.Path.join(userDir.path, "wontdie.py");
+  yield OS.File.writeAtomic(scriptPath, SCRIPT);
+  yield OS.File.setPermissions(scriptPath, {unixMode: 0o755});
+
+  const ID = "native@tests.mozilla.org";
+  const MANIFEST = {
+    name: "wontdie",
+    description: "test async shutdown of native apps",
+    path: scriptPath,
+    type: "stdio",
+    allowed_extensions: [ID],
+  };
+  yield writeManifest(OS.Path.join(userDir.path, "wontdie.json"), MANIFEST);
+
+  let extension = {id: ID};
+  let app = new NativeApp(extension, context, "wontdie");
+
+  // send a message and wait for the reply to make sure the app is running
+  let MSG = "test";
+  let recvPromise = new Promise(resolve => {
+    let listener = (what, msg) => {
+      equal(msg, MSG, "Received test message");
+      app.off("message", listener);
+      resolve();
+    };
+    app.on("message", listener);
+  });
+
+  app.send(MSG);
+  yield recvPromise;
+
+  app._cleanup();
+
+  do_print("waiting for async shutdown");
+  Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+  AsyncShutdown.profileBeforeChange._trigger();
+  Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+
+  let procs = yield SubprocessImpl.Process.getWorker().call("getProcesses", []);
+  equal(procs.size, 0, "native process exited");
+});
+