RemoteSettingsWorker.sys.mjs (7308B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * Interface to a dedicated thread handling for Remote Settings heavy operations. 7 */ 8 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 9 10 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; 11 12 const lazy = {}; 13 14 XPCOMUtils.defineLazyPreferenceGetter( 15 lazy, 16 "gMaxIdleMilliseconds", 17 "services.settings.worker_idle_max_milliseconds", 18 30 * 1000 // Default of 30 seconds. 19 ); 20 21 ChromeUtils.defineESModuleGetters(lazy, { 22 AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", 23 SharedUtils: "resource://services-settings/SharedUtils.sys.mjs", 24 }); 25 26 // Note: we currently only ever construct one instance of Worker. 27 // If it stops being a singleton, the AsyncShutdown code at the bottom 28 // of this file, as well as these globals, will need adjusting. 29 let gShutdown = false; 30 let gShutdownResolver = null; 31 32 class RemoteSettingsWorkerError extends Error { 33 constructor(message) { 34 super(message); 35 this.name = "RemoteSettingsWorkerError"; 36 } 37 } 38 39 class Worker { 40 constructor(source) { 41 if (gShutdown) { 42 console.error("Can't create worker once shutdown has started"); 43 } 44 this.source = source; 45 this.worker = null; 46 47 this.callbacks = new Map(); 48 this.lastCallbackId = 0; 49 this.idleTimeoutId = null; 50 } 51 52 async _execute(method, args = [], options = {}) { 53 // Check if we're shutting down. 54 if (gShutdown && method != "prepareShutdown") { 55 throw new RemoteSettingsWorkerError("Remote Settings has shut down."); 56 } 57 // Don't instantiate the worker to shut it down. 58 if (method == "prepareShutdown" && !this.worker) { 59 return null; 60 } 61 62 const { mustComplete = false } = options; 63 // (Re)instantiate the worker if it was terminated. 64 if (!this.worker) { 65 this.worker = new ChromeWorker(this.source, { type: "module" }); 66 this.worker.onmessage = this._onWorkerMessage.bind(this); 67 this.worker.onerror = error => { 68 // Worker crashed. Reject each pending callback. 69 for (const { reject } of this.callbacks.values()) { 70 reject(error); 71 } 72 this.callbacks.clear(); 73 // And terminate it. 74 this.stop(); 75 }; 76 } 77 // New activity: reset the idle timer. 78 if (this.idleTimeoutId) { 79 clearTimeout(this.idleTimeoutId); 80 } 81 let identifier = method + "-"; 82 // Include the collection details in the importJSONDump case. 83 if (identifier == "importJSONDump-") { 84 identifier += `${args[0]}-${args[1]}-`; 85 } 86 return new Promise((resolve, reject) => { 87 const callbackId = `${identifier}${++this.lastCallbackId}`; 88 this.callbacks.set(callbackId, { resolve, reject, mustComplete }); 89 this.worker.postMessage({ callbackId, method, args }); 90 }); 91 } 92 93 _onWorkerMessage(event) { 94 const { callbackId, result, error } = event.data; 95 // If we're shutting down, we may have already rejected this operation 96 // and removed its callback from our map: 97 if (!this.callbacks.has(callbackId)) { 98 return; 99 } 100 const { resolve, reject } = this.callbacks.get(callbackId); 101 if (error) { 102 reject(new RemoteSettingsWorkerError(error)); 103 } else { 104 resolve(result); 105 } 106 this.callbacks.delete(callbackId); 107 108 // Terminate the worker when it's unused for some time. 109 // But don't terminate it if an operation is pending. 110 if (!this.callbacks.size) { 111 if (gShutdown) { 112 this.stop(); 113 if (gShutdownResolver) { 114 gShutdownResolver(); 115 } 116 } else { 117 this.idleTimeoutId = setTimeout(() => { 118 this.stop(); 119 }, lazy.gMaxIdleMilliseconds); 120 } 121 } 122 } 123 124 /** 125 * Called at shutdown to abort anything the worker is doing that isn't 126 * critical. 127 */ 128 _abortCancelableRequests() { 129 // End all tasks that we can. 130 const callbackCopy = Array.from(this.callbacks.entries()); 131 const error = new Error("Shutdown, aborting read-only worker requests."); 132 for (const [id, { reject, mustComplete }] of callbackCopy) { 133 if (!mustComplete) { 134 this.callbacks.delete(id); 135 reject(error); 136 } 137 } 138 // There might be nothing left now: 139 if (!this.callbacks.size) { 140 this.stop(); 141 if (gShutdownResolver) { 142 gShutdownResolver(); 143 } 144 } 145 // If there was something left, we'll stop as soon as we get messages from 146 // those tasks, too. 147 // Let's hurry them along a bit: 148 this._execute("prepareShutdown"); 149 } 150 151 stop() { 152 this.worker.terminate(); 153 this.worker = null; 154 this.idleTimeoutId = null; 155 } 156 157 async canonicalStringify(localRecords, remoteRecords, timestamp) { 158 return this._execute("canonicalStringify", [ 159 localRecords, 160 remoteRecords, 161 timestamp, 162 ]); 163 } 164 165 async importJSONDump(bucket, collection) { 166 return this._execute("importJSONDump", [bucket, collection], { 167 mustComplete: true, 168 }); 169 } 170 171 async checkFileHash(filepath, size, hash) { 172 return this._execute("checkFileHash", [filepath, size, hash]); 173 } 174 175 async checkContentHash(buffer, size, hash) { 176 // The implementation does little work on the current thread, so run the 177 // task on the current thread instead of the worker thread. 178 return lazy.SharedUtils.checkContentHash(buffer, size, hash); 179 } 180 } 181 182 // Now, first add a shutdown blocker. If that fails, we must have 183 // shut down already. 184 // We're doing this here rather than in the Worker constructor because in 185 // principle having just 1 shutdown blocker for the entire file should be 186 // fine. If we ever start creating more than one Worker instance, this 187 // code will need adjusting to deal with that. 188 try { 189 lazy.AsyncShutdown.profileBeforeChange.addBlocker( 190 "Remote Settings profile-before-change", 191 async () => { 192 // First, indicate we've shut down. 193 gShutdown = true; 194 // Then, if we have no worker or no callbacks, we're done. 195 if ( 196 !RemoteSettingsWorker.worker || 197 !RemoteSettingsWorker.callbacks.size 198 ) { 199 return null; 200 } 201 // Otherwise, there's something left to do. Set up a promise: 202 let finishedPromise = new Promise(resolve => { 203 gShutdownResolver = resolve; 204 }); 205 206 // Try to cancel most of the work: 207 RemoteSettingsWorker._abortCancelableRequests(); 208 209 // Return a promise that the worker will resolve. 210 return finishedPromise; 211 }, 212 { 213 fetchState() { 214 const remainingCallbacks = RemoteSettingsWorker.callbacks; 215 const details = Array.from(remainingCallbacks.keys()).join(", "); 216 return `Remaining: ${remainingCallbacks.size} callbacks (${details}).`; 217 }, 218 } 219 ); 220 } catch (ex) { 221 console.error( 222 "Couldn't add shutdown blocker, assuming shutdown has started." 223 ); 224 console.error(ex); 225 // If AsyncShutdown throws, `profileBeforeChange` has already fired. Ignore it 226 // and mark shutdown. Constructing the worker will report an error and do 227 // nothing. 228 gShutdown = true; 229 } 230 231 export var RemoteSettingsWorker = new Worker( 232 "resource://services-settings/RemoteSettings.worker.mjs" 233 );