summaryrefslogtreecommitdiffstats
path: root/services/settings/RemoteSettingsWorker.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--services/settings/RemoteSettingsWorker.sys.mjs238
1 files changed, 238 insertions, 0 deletions
diff --git a/services/settings/RemoteSettingsWorker.sys.mjs b/services/settings/RemoteSettingsWorker.sys.mjs
new file mode 100644
index 0000000000..c08adbca98
--- /dev/null
+++ b/services/settings/RemoteSettingsWorker.sys.mjs
@@ -0,0 +1,238 @@
+/* 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/. */
+
+/**
+ * Interface to a dedicated thread handling for Remote Settings heavy operations.
+ */
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gMaxIdleMilliseconds",
+ "services.settings.worker_idle_max_milliseconds",
+ 30 * 1000 // Default of 30 seconds.
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "SharedUtils",
+ "resource://services-settings/SharedUtils.jsm"
+);
+
+// Note: we currently only ever construct one instance of Worker.
+// If it stops being a singleton, the AsyncShutdown code at the bottom
+// of this file, as well as these globals, will need adjusting.
+let gShutdown = false;
+let gShutdownResolver = null;
+
+class RemoteSettingsWorkerError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "RemoteSettingsWorkerError";
+ }
+}
+
+class Worker {
+ constructor(source) {
+ if (gShutdown) {
+ console.error("Can't create worker once shutdown has started");
+ }
+ this.source = source;
+ this.worker = null;
+
+ this.callbacks = new Map();
+ this.lastCallbackId = 0;
+ this.idleTimeoutId = null;
+ }
+
+ async _execute(method, args = [], options = {}) {
+ // Check if we're shutting down.
+ if (gShutdown && method != "prepareShutdown") {
+ throw new RemoteSettingsWorkerError("Remote Settings has shut down.");
+ }
+ // Don't instantiate the worker to shut it down.
+ if (method == "prepareShutdown" && !this.worker) {
+ return null;
+ }
+
+ const { mustComplete = false } = options;
+ // (Re)instantiate the worker if it was terminated.
+ if (!this.worker) {
+ this.worker = new ChromeWorker(this.source);
+ this.worker.onmessage = this._onWorkerMessage.bind(this);
+ this.worker.onerror = error => {
+ // Worker crashed. Reject each pending callback.
+ for (const { reject } of this.callbacks.values()) {
+ reject(error);
+ }
+ this.callbacks.clear();
+ // And terminate it.
+ this.stop();
+ };
+ }
+ // New activity: reset the idle timer.
+ if (this.idleTimeoutId) {
+ clearTimeout(this.idleTimeoutId);
+ }
+ let identifier = method + "-";
+ // Include the collection details in the importJSONDump case.
+ if (identifier == "importJSONDump-") {
+ identifier += `${args[0]}-${args[1]}-`;
+ }
+ return new Promise((resolve, reject) => {
+ const callbackId = `${identifier}${++this.lastCallbackId}`;
+ this.callbacks.set(callbackId, { resolve, reject, mustComplete });
+ this.worker.postMessage({ callbackId, method, args });
+ });
+ }
+
+ _onWorkerMessage(event) {
+ const { callbackId, result, error } = event.data;
+ // If we're shutting down, we may have already rejected this operation
+ // and removed its callback from our map:
+ if (!this.callbacks.has(callbackId)) {
+ return;
+ }
+ const { resolve, reject } = this.callbacks.get(callbackId);
+ if (error) {
+ reject(new RemoteSettingsWorkerError(error));
+ } else {
+ resolve(result);
+ }
+ this.callbacks.delete(callbackId);
+
+ // Terminate the worker when it's unused for some time.
+ // But don't terminate it if an operation is pending.
+ if (!this.callbacks.size) {
+ if (gShutdown) {
+ this.stop();
+ if (gShutdownResolver) {
+ gShutdownResolver();
+ }
+ } else {
+ this.idleTimeoutId = setTimeout(() => {
+ this.stop();
+ }, lazy.gMaxIdleMilliseconds);
+ }
+ }
+ }
+
+ /**
+ * Called at shutdown to abort anything the worker is doing that isn't
+ * critical.
+ */
+ _abortCancelableRequests() {
+ // End all tasks that we can.
+ const callbackCopy = Array.from(this.callbacks.entries());
+ const error = new Error("Shutdown, aborting read-only worker requests.");
+ for (const [id, { reject, mustComplete }] of callbackCopy) {
+ if (!mustComplete) {
+ this.callbacks.delete(id);
+ reject(error);
+ }
+ }
+ // There might be nothing left now:
+ if (!this.callbacks.size) {
+ this.stop();
+ if (gShutdownResolver) {
+ gShutdownResolver();
+ }
+ }
+ // If there was something left, we'll stop as soon as we get messages from
+ // those tasks, too.
+ // Let's hurry them along a bit:
+ this._execute("prepareShutdown");
+ }
+
+ stop() {
+ this.worker.terminate();
+ this.worker = null;
+ this.idleTimeoutId = null;
+ }
+
+ async canonicalStringify(localRecords, remoteRecords, timestamp) {
+ return this._execute("canonicalStringify", [
+ localRecords,
+ remoteRecords,
+ timestamp,
+ ]);
+ }
+
+ async importJSONDump(bucket, collection) {
+ return this._execute("importJSONDump", [bucket, collection], {
+ mustComplete: true,
+ });
+ }
+
+ async checkFileHash(filepath, size, hash) {
+ return this._execute("checkFileHash", [filepath, size, hash]);
+ }
+
+ async checkContentHash(buffer, size, hash) {
+ // The implementation does little work on the current thread, so run the
+ // task on the current thread instead of the worker thread.
+ return lazy.SharedUtils.checkContentHash(buffer, size, hash);
+ }
+}
+
+// Now, first add a shutdown blocker. If that fails, we must have
+// shut down already.
+// We're doing this here rather than in the Worker constructor because in
+// principle having just 1 shutdown blocker for the entire file should be
+// fine. If we ever start creating more than one Worker instance, this
+// code will need adjusting to deal with that.
+try {
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "Remote Settings profile-before-change",
+ async () => {
+ // First, indicate we've shut down.
+ gShutdown = true;
+ // Then, if we have no worker or no callbacks, we're done.
+ if (
+ !RemoteSettingsWorker.worker ||
+ !RemoteSettingsWorker.callbacks.size
+ ) {
+ return null;
+ }
+ // Otherwise, there's something left to do. Set up a promise:
+ let finishedPromise = new Promise(resolve => {
+ gShutdownResolver = resolve;
+ });
+
+ // Try to cancel most of the work:
+ RemoteSettingsWorker._abortCancelableRequests();
+
+ // Return a promise that the worker will resolve.
+ return finishedPromise;
+ },
+ {
+ fetchState() {
+ const remainingCallbacks = RemoteSettingsWorker.callbacks;
+ const details = Array.from(remainingCallbacks.keys()).join(", ");
+ return `Remaining: ${remainingCallbacks.size} callbacks (${details}).`;
+ },
+ }
+ );
+} catch (ex) {
+ console.error(
+ "Couldn't add shutdown blocker, assuming shutdown has started."
+ );
+ console.error(ex);
+ // If AsyncShutdown throws, `profileBeforeChange` has already fired. Ignore it
+ // and mark shutdown. Constructing the worker will report an error and do
+ // nothing.
+ gShutdown = true;
+}
+
+export var RemoteSettingsWorker = new Worker(
+ "resource://services-settings/RemoteSettingsWorker.js"
+);